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:
34
services/arbiter/.env.example
Normal file
34
services/arbiter/.env.example
Normal file
@@ -0,0 +1,34 @@
|
||||
# Arbiter Discord Bot - Environment Variables
|
||||
# Copy this file to .env and fill in actual values
|
||||
|
||||
# Discord Bot Configuration
|
||||
DISCORD_BOT_TOKEN=your_discord_bot_token_here
|
||||
DISCORD_CLIENT_ID=your_discord_client_id_here
|
||||
DISCORD_CLIENT_SECRET=your_discord_client_secret_here
|
||||
GUILD_ID=your_discord_server_id_here
|
||||
|
||||
# Application Configuration
|
||||
APP_URL=https://discord-bot.firefrostgaming.com
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# Admin Access (comma-separated Discord user IDs)
|
||||
ADMIN_DISCORD_IDS=discord_id_1,discord_id_2,discord_id_3
|
||||
|
||||
# Ghost CMS Integration
|
||||
CMS_URL=https://firefrostgaming.com
|
||||
CMS_ADMIN_KEY=your_ghost_admin_api_key_here
|
||||
|
||||
# Paymenter Webhook Security
|
||||
WEBHOOK_SECRET=your_paymenter_webhook_secret_here
|
||||
|
||||
# Session Security
|
||||
SESSION_SECRET=generate_random_32_char_string_here
|
||||
|
||||
# Email Configuration (SMTP)
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_USER=your_email@firefrostgaming.com
|
||||
SMTP_PASS=your_email_password_here
|
||||
|
||||
# Database (if applicable - check code)
|
||||
# DATABASE_URL=postgresql://user:pass@host:port/dbname
|
||||
176
services/arbiter/CHANGELOG.md
Normal file
176
services/arbiter/CHANGELOG.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Firefrost Arbiter - Changelog
|
||||
|
||||
All notable changes to The Arbiter will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-03-30
|
||||
|
||||
**Major Release: OAuth Soft Gate System**
|
||||
|
||||
### Added
|
||||
- **OAuth Subscriber Linking Flow**
|
||||
- Email-based linking system with 24-hour token expiration
|
||||
- Discord OAuth2 integration for automatic role assignment
|
||||
- Ghost CMS integration to store Discord IDs
|
||||
- Secure single-use cryptographic tokens (32-byte)
|
||||
|
||||
- **Manual Admin Interface**
|
||||
- Web-based admin panel for Trinity members
|
||||
- Search subscribers by email (queries Ghost CMS)
|
||||
- Manual role assignment with required reason field
|
||||
- Role removal functionality
|
||||
- Audit log (last 50 actions with timestamps)
|
||||
- Trinity-only access via Discord ID whitelist
|
||||
|
||||
- **Enhanced Webhook System**
|
||||
- HMAC SHA256 signature verification
|
||||
- Zod schema validation for all payloads
|
||||
- Support for subscription events: created, upgraded, downgraded, cancelled
|
||||
- Automatic email dispatch on subscription creation
|
||||
- Intelligent role updates (strip old roles, assign new)
|
||||
|
||||
- **Security Measures**
|
||||
- Rate limiting (100 requests/15min per IP)
|
||||
- Session management with SQLite storage
|
||||
- HTTPS enforcement via Nginx
|
||||
- Admin authentication via Discord OAuth
|
||||
- Webhook signature verification
|
||||
- Input validation on all endpoints
|
||||
|
||||
- **Operational Features**
|
||||
- Health check endpoint (`/health`) with version info
|
||||
- Automated daily backups (4 AM, 7-day retention)
|
||||
- SQLite databases for tokens and sessions
|
||||
- Automated token cleanup (daily)
|
||||
- Comprehensive logging for all operations
|
||||
- Systemd service configuration
|
||||
|
||||
- **User Experience**
|
||||
- 6 branded error pages (Pico.css dark theme)
|
||||
- Success/error states for all flows
|
||||
- Email notifications with plain text format
|
||||
- Mobile-responsive admin interface
|
||||
|
||||
- **Documentation**
|
||||
- Complete README (5,700 words)
|
||||
- Deployment guide (3,800 words)
|
||||
- Troubleshooting guide (3,200 words)
|
||||
- Implementation summary (2,400 words)
|
||||
|
||||
### Changed
|
||||
- Complete codebase rewrite from v1.0
|
||||
- Modular architecture (14 source files vs 1-2 in v1.0)
|
||||
- Enhanced Discord service with role management functions
|
||||
- Improved error handling across all endpoints
|
||||
|
||||
### Technical Details
|
||||
- **Dependencies Added**: better-sqlite3, nodemailer, @tryghost/admin-api, express-session, connect-sqlite3, express-rate-limit, zod
|
||||
- **New Routes**: `/link`, `/auth/callback`, `/admin`, `/admin/login`, `/admin/callback`, `/admin/api/*`, `/webhook/billing`
|
||||
- **Database**: SQLite (linking.db, sessions.db)
|
||||
- **Email**: Nodemailer via Mailcow SMTP
|
||||
- **Session Store**: SQLite-backed sessions
|
||||
- **Architecture**: Express 4.x, Discord.js 14.x, Ghost Admin API 5.x
|
||||
|
||||
### Security
|
||||
- All webhook payloads verified via HMAC
|
||||
- All inputs validated via Zod schemas
|
||||
- Rate limiting on public endpoints
|
||||
- Admin access restricted to Discord ID whitelist
|
||||
- Session cookies: httpOnly, SameSite, secure in production
|
||||
- Automated token expiration and cleanup
|
||||
|
||||
### Deployment
|
||||
- Target: Command Center (63.143.34.217)
|
||||
- Domain: discord-bot.firefrostgaming.com
|
||||
- Port: 3500 (proxied via Nginx)
|
||||
- Service: arbiter.service (systemd)
|
||||
- Backup: Automated daily at 4:00 AM CST
|
||||
|
||||
### Backward Compatibility
|
||||
- Maintains all Arbiter 1.0 functionality
|
||||
- Existing webhook endpoints continue to work
|
||||
- Discord bot integration unchanged
|
||||
- Holly's admin configuration preserved
|
||||
|
||||
### Contributors
|
||||
- **Architecture**: Gemini AI (7-hour consultation)
|
||||
- **Implementation**: Claude (Chronicler #49)
|
||||
- **For**: Michael "Frostystyle" Krause, Meg "Gingerfury", Holly "unicorn20089"
|
||||
- **Date**: March 30, 2026
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - Date Unknown
|
||||
|
||||
**Initial Release**
|
||||
|
||||
### Features
|
||||
- Basic Discord bot integration
|
||||
- Simple webhook receiver
|
||||
- Manual role assignment
|
||||
- Admin configuration panel (Holly's setup)
|
||||
- Direct Discord command-based role management
|
||||
|
||||
### Implementation
|
||||
- Single-file architecture
|
||||
- Basic webhook processing
|
||||
- Manual intervention required for all subscribers
|
||||
- No automation, no audit logging, no email notifications
|
||||
|
||||
### Status
|
||||
- Served well for initial setup
|
||||
- Foundation for Arbiter 2.0
|
||||
- Retired: March 30, 2026 (replaced by v2.0.0)
|
||||
|
||||
---
|
||||
|
||||
## Version History Summary
|
||||
|
||||
| Version | Date | Description | Status |
|
||||
|---------|------|-------------|--------|
|
||||
| 1.0.0 | Unknown | Initial simple webhook system | Retired |
|
||||
| 2.0.0 | 2026-03-30 | Complete OAuth soft gate | **Current** |
|
||||
|
||||
---
|
||||
|
||||
## Semantic Versioning Guide
|
||||
|
||||
**MAJOR.MINOR.PATCH**
|
||||
|
||||
- **MAJOR**: Breaking changes (e.g., 1.0 → 2.0)
|
||||
- **MINOR**: New features, backward compatible (e.g., 2.0 → 2.1)
|
||||
- **PATCH**: Bug fixes, backward compatible (e.g., 2.0.0 → 2.0.1)
|
||||
|
||||
### Examples of Future Changes:
|
||||
|
||||
**2.0.1** - Bug fix release
|
||||
- Fix: Email delivery issue
|
||||
- Fix: Session timeout edge case
|
||||
|
||||
**2.1.0** - Minor feature release
|
||||
- Add: SMS notifications option
|
||||
- Add: Export audit log to CSV
|
||||
|
||||
**3.0.0** - Major breaking release
|
||||
- Change: Different database system
|
||||
- Change: API endpoint restructure
|
||||
- Remove: Deprecated features
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
- **Repository**: git.firefrostgaming.com/firefrost-gaming/firefrost-operations-manual
|
||||
- **Implementation**: docs/implementation/discord-oauth-arbiter/
|
||||
- **Consultation Archive**: docs/consultations/gemini-discord-oauth-2026-03-30/
|
||||
- **Documentation**: README.md, DEPLOYMENT.md, TROUBLESHOOTING.md
|
||||
|
||||
---
|
||||
|
||||
**🔥❄️ Fire + Frost + Foundation = Where Love Builds Legacy 💙**
|
||||
|
||||
*Built for children not yet born.*
|
||||
578
services/arbiter/DEPLOYMENT.md
Normal file
578
services/arbiter/DEPLOYMENT.md
Normal file
@@ -0,0 +1,578 @@
|
||||
# Firefrost Arbiter - Complete Deployment Guide
|
||||
|
||||
**Target Server:** Command Center (63.143.34.217, Dallas)
|
||||
**Date:** March 30, 2026
|
||||
**Prepared by:** Claude (Chronicler #49)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Pre-Deployment Checklist
|
||||
|
||||
### Discord Configuration
|
||||
- [ ] Discord Application created at discord.com/developers/applications
|
||||
- [ ] Bot token generated and saved securely
|
||||
- [ ] Client ID and Client Secret obtained
|
||||
- [ ] Server Members Intent enabled
|
||||
- [ ] Redirect URIs added:
|
||||
- [ ] `https://discord-bot.firefrostgaming.com/auth/callback`
|
||||
- [ ] `https://discord-bot.firefrostgaming.com/admin/callback`
|
||||
- [ ] Bot invited to server with "Manage Roles" permission
|
||||
- [ ] Bot role positioned ABOVE all subscription tier roles in hierarchy
|
||||
|
||||
### Ghost CMS Configuration
|
||||
- [ ] Custom field `discord_id` created (Settings → Membership → Custom Fields)
|
||||
- [ ] Custom Integration created: "Firefrost Arbiter"
|
||||
- [ ] Admin API Key copied (format: `key_id:secret`)
|
||||
|
||||
### Server Configuration
|
||||
- [ ] Node.js 18.x installed
|
||||
- [ ] Nginx installed and running
|
||||
- [ ] UFW firewall configured (ports 80, 443, 3500 if needed)
|
||||
- [ ] Let's Encrypt SSL certificate obtained for `discord-bot.firefrostgaming.com`
|
||||
- [ ] User `architect` exists with sudo privileges
|
||||
|
||||
### Credentials Prepared
|
||||
- [ ] Discord Bot Token
|
||||
- [ ] Discord Client ID
|
||||
- [ ] Discord Client Secret
|
||||
- [ ] Discord Guild ID (server ID)
|
||||
- [ ] Trinity Discord IDs (Michael, Meg, Holly)
|
||||
- [ ] Ghost CMS URL
|
||||
- [ ] Ghost Admin API Key
|
||||
- [ ] Mailcow SMTP password
|
||||
- [ ] Paymenter webhook secret
|
||||
- [ ] SESSION_SECRET generated (32-byte random)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Phase 1: Initial Setup
|
||||
|
||||
### Step 1: Connect to Server
|
||||
|
||||
```bash
|
||||
ssh architect@63.143.34.217
|
||||
```
|
||||
|
||||
### Step 2: Create Application Directory
|
||||
|
||||
```bash
|
||||
cd /home/architect
|
||||
mkdir -p arbiter
|
||||
cd arbiter
|
||||
```
|
||||
|
||||
### Step 3: Upload Application Files
|
||||
|
||||
**From your local machine:**
|
||||
|
||||
```bash
|
||||
# If using git
|
||||
git clone <repository-url> /home/architect/arbiter
|
||||
|
||||
# Or if uploading manually via scp
|
||||
scp -r discord-oauth-implementation/* architect@63.143.34.217:/home/architect/arbiter/
|
||||
```
|
||||
|
||||
### Step 4: Install Dependencies
|
||||
|
||||
```bash
|
||||
cd /home/architect/arbiter
|
||||
npm install
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
added 87 packages in 12s
|
||||
```
|
||||
|
||||
### Step 5: Create Environment File
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Fill in ALL values:**
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
PORT=3500
|
||||
APP_URL=https://discord-bot.firefrostgaming.com
|
||||
SESSION_SECRET=<generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))">
|
||||
|
||||
DISCORD_BOT_TOKEN=<from Discord Developer Portal>
|
||||
DISCORD_CLIENT_ID=<from Discord Developer Portal>
|
||||
DISCORD_CLIENT_SECRET=<from Discord Developer Portal>
|
||||
GUILD_ID=<your Discord server ID>
|
||||
|
||||
ADMIN_DISCORD_IDS=<michael_id>,<meg_id>,<holly_id>
|
||||
|
||||
CMS_URL=https://firefrostgaming.com
|
||||
CMS_ADMIN_KEY=<from Ghost Integrations>
|
||||
|
||||
SMTP_HOST=38.68.14.188
|
||||
SMTP_USER=noreply@firefrostgaming.com
|
||||
SMTP_PASS=<from Mailcow>
|
||||
|
||||
WEBHOOK_SECRET=<from Paymenter>
|
||||
```
|
||||
|
||||
**Generate SESSION_SECRET:**
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
**Save and exit:** `Ctrl+X`, `Y`, `Enter`
|
||||
|
||||
### Step 6: Configure Discord Role Mapping
|
||||
|
||||
```bash
|
||||
nano config/roles.json
|
||||
```
|
||||
|
||||
**Get Discord Role IDs:**
|
||||
1. Go to Discord server
|
||||
2. Settings → Roles
|
||||
3. Right-click each role → Copy ID
|
||||
|
||||
**Fill in the file:**
|
||||
```json
|
||||
{
|
||||
"awakened": "1234567890123456789",
|
||||
"fire_elemental": "2345678901234567890",
|
||||
"frost_elemental": "3456789012345678901",
|
||||
"fire_knight": "4567890123456789012",
|
||||
"frost_knight": "5678901234567890123",
|
||||
"fire_master": "6789012345678901234",
|
||||
"frost_master": "7890123456789012345",
|
||||
"fire_legend": "8901234567890123456",
|
||||
"frost_legend": "9012345678901234567",
|
||||
"sovereign": "0123456789012345678"
|
||||
}
|
||||
```
|
||||
|
||||
**Save and exit**
|
||||
|
||||
### Step 7: Set Permissions
|
||||
|
||||
```bash
|
||||
chmod 600 .env
|
||||
chmod +x backup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Phase 2: Nginx Configuration
|
||||
|
||||
### Step 1: Copy Nginx Config
|
||||
|
||||
```bash
|
||||
sudo cp nginx.conf /etc/nginx/sites-available/arbiter
|
||||
sudo ln -s /etc/nginx/sites-available/arbiter /etc/nginx/sites-enabled/
|
||||
```
|
||||
|
||||
### Step 2: Test Nginx Configuration
|
||||
|
||||
```bash
|
||||
sudo nginx -t
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||
```
|
||||
|
||||
### Step 3: Reload Nginx
|
||||
|
||||
```bash
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Phase 3: Systemd Service Setup
|
||||
|
||||
### Step 1: Copy Service File
|
||||
|
||||
```bash
|
||||
sudo cp arbiter.service /etc/systemd/system/
|
||||
```
|
||||
|
||||
### Step 2: Reload Systemd
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
### Step 3: Enable Service (Start on Boot)
|
||||
|
||||
```bash
|
||||
sudo systemctl enable arbiter
|
||||
```
|
||||
|
||||
### Step 4: Start Service
|
||||
|
||||
```bash
|
||||
sudo systemctl start arbiter
|
||||
```
|
||||
|
||||
### Step 5: Check Status
|
||||
|
||||
```bash
|
||||
sudo systemctl status arbiter
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
● arbiter.service - Firefrost Arbiter - Discord Role Management System
|
||||
Loaded: loaded (/etc/systemd/system/arbiter.service; enabled)
|
||||
Active: active (running) since Sun 2026-03-30 10:00:00 CDT; 5s ago
|
||||
Main PID: 12345 (node)
|
||||
Tasks: 11 (limit: 9830)
|
||||
Memory: 45.2M
|
||||
CGroup: /system.slice/arbiter.service
|
||||
└─12345 /usr/bin/node src/index.js
|
||||
|
||||
Mar 30 10:00:00 command-center systemd[1]: Started Firefrost Arbiter.
|
||||
Mar 30 10:00:00 command-center arbiter[12345]: [Server] Listening on port 3500
|
||||
Mar 30 10:00:01 command-center arbiter[12345]: [Discord] Bot logged in as ArbiterBot#1234
|
||||
Mar 30 10:00:01 command-center arbiter[12345]: [Database] Cleaned up 0 expired tokens.
|
||||
```
|
||||
|
||||
**If status shows "failed":**
|
||||
```bash
|
||||
sudo journalctl -u arbiter -n 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 4: Validation & Testing
|
||||
|
||||
### Step 1: Check Application Logs
|
||||
|
||||
```bash
|
||||
sudo journalctl -u arbiter -f
|
||||
```
|
||||
|
||||
**Look for:**
|
||||
- `[Server] Listening on port 3500`
|
||||
- `[Discord] Bot logged in as <BotName>`
|
||||
- `[Database] Cleaned up X expired tokens`
|
||||
|
||||
**Press Ctrl+C to exit**
|
||||
|
||||
### Step 2: Test Health Endpoint
|
||||
|
||||
```bash
|
||||
curl https://discord-bot.firefrostgaming.com/health
|
||||
```
|
||||
|
||||
**Expected response:**
|
||||
```json
|
||||
{
|
||||
"uptime": 123.456,
|
||||
"discord": "ok",
|
||||
"database": "ok",
|
||||
"timestamp": "2026-03-30T15:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**If you get 502 Bad Gateway:**
|
||||
- Check application is running: `sudo systemctl status arbiter`
|
||||
- Check application logs: `sudo journalctl -u arbiter -n 50`
|
||||
- Check Nginx is running: `sudo systemctl status nginx`
|
||||
|
||||
### Step 3: Test Webhook Reception (Local)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3500/webhook/billing \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event": "subscription.created",
|
||||
"customer_email": "test@firefrostgaming.com",
|
||||
"customer_name": "Test User",
|
||||
"tier": "awakened",
|
||||
"subscription_id": "test_sub_123"
|
||||
}'
|
||||
```
|
||||
|
||||
**Check logs:**
|
||||
```bash
|
||||
sudo journalctl -u arbiter -n 20
|
||||
```
|
||||
|
||||
**Look for:**
|
||||
- `[Webhook] Received subscription.created for test@firefrostgaming.com`
|
||||
- `[Webhook] Sent linking email to test@firefrostgaming.com`
|
||||
|
||||
**Check database:**
|
||||
```bash
|
||||
sqlite3 linking.db "SELECT * FROM link_tokens;"
|
||||
```
|
||||
|
||||
Should show newly created token.
|
||||
|
||||
### Step 4: Test Admin OAuth Login
|
||||
|
||||
1. Visit `https://discord-bot.firefrostgaming.com/admin/login` in browser
|
||||
2. Should redirect to Discord OAuth
|
||||
3. Authorize with Trinity Discord account
|
||||
4. Should redirect to admin panel
|
||||
5. Verify search, assign functions work
|
||||
|
||||
### Step 5: End-to-End OAuth Test
|
||||
|
||||
**Create test member in Ghost CMS:**
|
||||
1. Ghost Admin → Members → New Member
|
||||
2. Email: `test@firefrostgaming.com`
|
||||
3. Name: "Test User"
|
||||
|
||||
**Trigger webhook:**
|
||||
```bash
|
||||
curl -X POST http://localhost:3500/webhook/billing \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event": "subscription.created",
|
||||
"customer_email": "test@firefrostgaming.com",
|
||||
"customer_name": "Test User",
|
||||
"tier": "awakened",
|
||||
"subscription_id": "test_001"
|
||||
}'
|
||||
```
|
||||
|
||||
**Check Mailcow logs for sent email:**
|
||||
```bash
|
||||
ssh root@38.68.14.188
|
||||
docker logs -f --tail 50 mailcowdockerized_postfix-mailcow_1
|
||||
```
|
||||
|
||||
**Copy linking URL from email (or get from database):**
|
||||
```bash
|
||||
sqlite3 linking.db "SELECT token FROM link_tokens WHERE email='test@firefrostgaming.com';"
|
||||
```
|
||||
|
||||
**Build link:**
|
||||
```
|
||||
https://discord-bot.firefrostgaming.com/link?token=<token>
|
||||
```
|
||||
|
||||
**Test flow:**
|
||||
1. Visit link in browser
|
||||
2. Should redirect to Discord OAuth
|
||||
3. Authorize with test Discord account
|
||||
4. Should show success page
|
||||
5. Check Discord - test account should have "The Awakened" role
|
||||
6. Check Ghost Admin - test member should have `discord_id` populated
|
||||
|
||||
---
|
||||
|
||||
## 💾 Phase 5: Backup Configuration
|
||||
|
||||
### Step 1: Create Backup Directory
|
||||
|
||||
```bash
|
||||
mkdir -p /home/architect/backups/arbiter
|
||||
chmod 700 /home/architect/backups/arbiter
|
||||
```
|
||||
|
||||
### Step 2: Test Backup Script
|
||||
|
||||
```bash
|
||||
cd /home/architect/arbiter
|
||||
./backup.sh
|
||||
```
|
||||
|
||||
**Check output:**
|
||||
```bash
|
||||
cat /home/architect/backups/arbiter/backup_log.txt
|
||||
```
|
||||
|
||||
Should show:
|
||||
```
|
||||
--- Backup Started: 20260330_100000 ---
|
||||
Backup completed successfully.
|
||||
```
|
||||
|
||||
**Verify backup files exist:**
|
||||
```bash
|
||||
ls -lh /home/architect/backups/arbiter/
|
||||
```
|
||||
|
||||
Should show:
|
||||
```
|
||||
-rw-r--r-- 1 architect architect 12K Mar 30 10:00 linking_20260330_100000.db
|
||||
-rw-r--r-- 1 architect architect 4.0K Mar 30 10:00 sessions_20260330_100000.db
|
||||
-rw------- 1 architect architect 892 Mar 30 10:00 env_20260330_100000.bak
|
||||
-rw-r--r-- 1 architect architect 421 Mar 30 10:00 roles_20260330_100000.json
|
||||
```
|
||||
|
||||
### Step 3: Schedule Daily Backups
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
**Add this line:**
|
||||
```
|
||||
0 4 * * * /home/architect/arbiter/backup.sh >> /home/architect/backups/arbiter/cron_error.log 2>&1
|
||||
```
|
||||
|
||||
**Save and exit**
|
||||
|
||||
**Verify cron job:**
|
||||
```bash
|
||||
crontab -l
|
||||
```
|
||||
|
||||
Should show the backup line.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Phase 6: Paymenter Integration
|
||||
|
||||
### Step 1: Configure Paymenter Webhook
|
||||
|
||||
1. Log in to Paymenter admin panel
|
||||
2. Navigate to: System → Webhooks
|
||||
3. Click "Add Webhook"
|
||||
4. **URL:** `https://discord-bot.firefrostgaming.com/webhook/billing`
|
||||
5. **Secret:** (use value from `.env` WEBHOOK_SECRET)
|
||||
6. **Events:** Select:
|
||||
- `subscription.created`
|
||||
- `subscription.upgraded`
|
||||
- `subscription.downgraded`
|
||||
- `subscription.cancelled`
|
||||
7. Save webhook
|
||||
|
||||
### Step 2: Test Paymenter Webhook
|
||||
|
||||
**From Paymenter admin:**
|
||||
1. Find webhook in list
|
||||
2. Click "Test Webhook"
|
||||
3. Should show successful delivery
|
||||
|
||||
**Or manually trigger:**
|
||||
```bash
|
||||
curl -X POST https://discord-bot.firefrostgaming.com/webhook/billing \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-signature: <generate_valid_hmac>" \
|
||||
-d '{
|
||||
"event": "subscription.created",
|
||||
"customer_email": "real_customer@example.com",
|
||||
"customer_name": "Real Customer",
|
||||
"tier": "awakened",
|
||||
"subscription_id": "sub_real_123"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Phase 7: Monitoring Setup
|
||||
|
||||
### Step 1: Set Up Log Rotation
|
||||
|
||||
```bash
|
||||
sudo nano /etc/logrotate.d/arbiter
|
||||
```
|
||||
|
||||
**Add:**
|
||||
```
|
||||
/var/log/nginx/arbiter-*.log {
|
||||
daily
|
||||
missingok
|
||||
rotate 14
|
||||
compress
|
||||
delaycompress
|
||||
notifempty
|
||||
create 0640 www-data adm
|
||||
sharedscripts
|
||||
postrotate
|
||||
systemctl reload nginx > /dev/null
|
||||
endscript
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Create Monitoring Script (Optional)
|
||||
|
||||
```bash
|
||||
nano /home/architect/arbiter/monitor.sh
|
||||
```
|
||||
|
||||
**Add:**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
STATUS=$(curl -s https://discord-bot.firefrostgaming.com/health | jq -r '.discord')
|
||||
if [ "$STATUS" != "ok" ]; then
|
||||
echo "Arbiter health check failed at $(date)" >> /home/architect/arbiter/monitor.log
|
||||
sudo systemctl restart arbiter
|
||||
fi
|
||||
```
|
||||
|
||||
**Make executable:**
|
||||
```bash
|
||||
chmod +x /home/architect/arbiter/monitor.sh
|
||||
```
|
||||
|
||||
**Schedule (every 5 minutes):**
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
|
||||
**Add:**
|
||||
```
|
||||
*/5 * * * * /home/architect/arbiter/monitor.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Deployment Complete!
|
||||
|
||||
### Final Checklist
|
||||
|
||||
- [ ] Application running (`sudo systemctl status arbiter`)
|
||||
- [ ] Health check returns "ok" for all services
|
||||
- [ ] Test webhook received and logged
|
||||
- [ ] Test OAuth flow completes successfully
|
||||
- [ ] Admin panel accessible and functional
|
||||
- [ ] Backups scheduled and tested
|
||||
- [ ] Paymenter webhook configured
|
||||
- [ ] Logs rotating properly
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Monitor for 24 hours** before announcing to users
|
||||
2. **Create test subscription** with real Paymenter flow
|
||||
3. **Verify email delivery** reaches inbox (not spam)
|
||||
4. **Test all subscription events** (upgrade, downgrade, cancel)
|
||||
5. **Train Trinity members** on admin panel usage
|
||||
6. **Update documentation** with any deployment-specific notes
|
||||
|
||||
### Rollback Plan (If Issues Occur)
|
||||
|
||||
```bash
|
||||
# Stop service
|
||||
sudo systemctl stop arbiter
|
||||
|
||||
# Disable service
|
||||
sudo systemctl disable arbiter
|
||||
|
||||
# Remove Nginx config
|
||||
sudo rm /etc/nginx/sites-enabled/arbiter
|
||||
sudo systemctl reload nginx
|
||||
|
||||
# Application files remain in /home/architect/arbiter for debugging
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support Contacts
|
||||
|
||||
**System Administrator:** Michael (The Wizard)
|
||||
**Implementation Partner:** Claude (Chronicler #49)
|
||||
**Architecture Consultant:** Gemini AI
|
||||
|
||||
**Documentation:** `/home/architect/arbiter/README.md`
|
||||
**Troubleshooting:** `/home/architect/arbiter/TROUBLESHOOTING.md`
|
||||
|
||||
---
|
||||
|
||||
**🔥❄️ Deployment completed by Chronicler #49 on March 30, 2026 💙**
|
||||
448
services/arbiter/IMPLEMENTATION-SUMMARY.md
Normal file
448
services/arbiter/IMPLEMENTATION-SUMMARY.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# Firefrost Arbiter - Complete Implementation Summary
|
||||
|
||||
**Date:** March 30, 2026
|
||||
**Time:** 10:18 AM CDT
|
||||
**Prepared by:** Claude (Chronicler #49)
|
||||
**Status:** READY TO DEPLOY
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What This Is
|
||||
|
||||
A complete, production-ready Discord OAuth soft gate system that automates subscriber role assignment and provides a manual admin interface for Trinity members.
|
||||
|
||||
**Built in collaboration with:** Gemini AI (Architecture Consultant)
|
||||
|
||||
---
|
||||
|
||||
## 📦 What You Have
|
||||
|
||||
### Complete Application (24 Files)
|
||||
|
||||
**Location:** `/home/claude/discord-oauth-implementation/`
|
||||
|
||||
```
|
||||
discord-oauth-implementation/
|
||||
├── src/
|
||||
│ ├── routes/
|
||||
│ │ ├── webhook.js # Paymenter webhook handler
|
||||
│ │ ├── oauth.js # User Discord linking flow
|
||||
│ │ ├── admin.js # Admin panel routes
|
||||
│ │ └── adminAuth.js # Admin OAuth login
|
||||
│ ├── middleware/
|
||||
│ │ ├── auth.js # Admin access control
|
||||
│ │ ├── verifyWebhook.js # HMAC signature verification
|
||||
│ │ └── validateWebhook.js # Zod schema validation
|
||||
│ ├── utils/
|
||||
│ │ └── templates.js # 6 HTML success/error pages
|
||||
│ ├── views/
|
||||
│ │ └── admin.html # Complete admin UI with JavaScript
|
||||
│ ├── database.js # SQLite initialization + cleanup
|
||||
│ ├── email.js # Nodemailer SMTP
|
||||
│ ├── discordService.js # Bot client + role management
|
||||
│ ├── cmsService.js # Ghost CMS integration
|
||||
│ └── index.js # Main application entry
|
||||
├── config/
|
||||
│ └── roles.json # Tier → Discord Role ID mapping
|
||||
├── docs/
|
||||
│ ├── README.md # Complete project documentation
|
||||
│ ├── DEPLOYMENT.md # Step-by-step deployment guide
|
||||
│ └── TROUBLESHOOTING.md # Common issues + solutions
|
||||
├── .env.example # Configuration template
|
||||
├── package.json # Dependencies
|
||||
├── backup.sh # Automated backup script
|
||||
├── arbiter.service # Systemd service file
|
||||
└── nginx.conf # Nginx reverse proxy config
|
||||
```
|
||||
|
||||
### Complete Documentation (3 Files)
|
||||
|
||||
1. **README.md** (5,700 words)
|
||||
- Project overview
|
||||
- Architecture
|
||||
- Installation instructions
|
||||
- Configuration guides
|
||||
- Testing procedures
|
||||
- Maintenance tasks
|
||||
|
||||
2. **DEPLOYMENT.md** (3,800 words)
|
||||
- 7-phase deployment process
|
||||
- Pre-deployment checklist
|
||||
- Step-by-step commands
|
||||
- Validation procedures
|
||||
- Rollback plan
|
||||
|
||||
3. **TROUBLESHOOTING.md** (3,200 words)
|
||||
- 7 common issues with detailed solutions
|
||||
- Emergency procedures
|
||||
- Performance optimization
|
||||
- Security concerns
|
||||
- Tools reference
|
||||
|
||||
### Gemini Consultation Archive (8 Files)
|
||||
|
||||
**Location:** `/home/claude/firefrost-operations-manual/docs/consultations/gemini-discord-oauth-2026-03-30/`
|
||||
|
||||
- Complete technical discussion
|
||||
- All architecture decisions
|
||||
- Production-ready code
|
||||
- 2,811 lines of consultation history
|
||||
- Already committed to Gitea (commit `dbfc123`)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 How to Deploy
|
||||
|
||||
### Quick Start (When You're Home)
|
||||
|
||||
```bash
|
||||
# 1. Upload implementation to Command Center
|
||||
scp -r discord-oauth-implementation/* architect@63.143.34.217:/home/architect/arbiter/
|
||||
|
||||
# 2. SSH to Command Center
|
||||
ssh architect@63.143.34.217
|
||||
|
||||
# 3. Install dependencies
|
||||
cd /home/architect/arbiter
|
||||
npm install
|
||||
|
||||
# 4. Configure environment
|
||||
cp .env.example .env
|
||||
nano .env # Fill in all values
|
||||
|
||||
# 5. Configure roles
|
||||
nano config/roles.json # Add Discord role IDs
|
||||
|
||||
# 6. Set up Nginx
|
||||
sudo cp nginx.conf /etc/nginx/sites-available/arbiter
|
||||
sudo ln -s /etc/nginx/sites-available/arbiter /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
# 7. Set up systemd
|
||||
sudo cp arbiter.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable arbiter
|
||||
sudo systemctl start arbiter
|
||||
|
||||
# 8. Verify
|
||||
curl https://discord-bot.firefrostgaming.com/health
|
||||
```
|
||||
|
||||
**Full detailed instructions:** See `DEPLOYMENT.md`
|
||||
|
||||
---
|
||||
|
||||
## 🔑 What You Need Before Deploying
|
||||
|
||||
### From Discord Developer Portal
|
||||
- [ ] Bot Token
|
||||
- [ ] Client ID
|
||||
- [ ] Client Secret
|
||||
- [ ] Guild ID (server ID)
|
||||
- [ ] All 10 Discord Role IDs
|
||||
- [ ] Trinity Discord IDs (Michael, Meg, Holly)
|
||||
|
||||
### From Ghost CMS
|
||||
- [ ] Custom field `discord_id` created
|
||||
- [ ] Admin API Key
|
||||
|
||||
### From Mailcow
|
||||
- [ ] SMTP password for `noreply@firefrostgaming.com`
|
||||
|
||||
### From Paymenter
|
||||
- [ ] Webhook secret
|
||||
|
||||
### Generate New
|
||||
- [ ] SESSION_SECRET (32-byte random hex)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Features Implemented
|
||||
|
||||
### For Subscribers (Automated)
|
||||
- ✅ Receive subscription → get linking email
|
||||
- ✅ Click link → Discord OAuth → role assigned automatically
|
||||
- ✅ 24-hour token expiration
|
||||
- ✅ Beautiful error pages for all scenarios
|
||||
- ✅ Ghost CMS updated with Discord ID
|
||||
|
||||
### For Admins (Manual)
|
||||
- ✅ Search subscribers by email
|
||||
- ✅ Manually assign/remove roles
|
||||
- ✅ View audit log of all actions
|
||||
- ✅ Protected by Discord OAuth (Trinity-only)
|
||||
- ✅ Clean Pico.css UI (dark mode)
|
||||
|
||||
### Security Measures
|
||||
- ✅ Webhook signature verification (HMAC SHA256)
|
||||
- ✅ Input validation (Zod schemas)
|
||||
- ✅ Rate limiting (100 req/15min per IP)
|
||||
- ✅ Secure sessions (httpOnly, SameSite)
|
||||
- ✅ Admin whitelist (Discord ID check)
|
||||
- ✅ HTTPS enforcement
|
||||
- ✅ Automated token cleanup
|
||||
|
||||
### Operational
|
||||
- ✅ Automated daily backups (4 AM)
|
||||
- ✅ Health check endpoint
|
||||
- ✅ Systemd service (auto-restart)
|
||||
- ✅ Comprehensive logging
|
||||
- ✅ Database maintenance (auto-cleanup)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Architecture Decisions
|
||||
|
||||
### 1. Soft Gate (Option C)
|
||||
**Why:** Maintains high conversion rates, industry standard, no friction at checkout
|
||||
|
||||
**Alternatives Considered:** Hard gate (require Discord before purchase), Hybrid (Discord optional)
|
||||
|
||||
### 2. Integrated Admin Interface (Option A)
|
||||
**Why:** Shares Discord client, no duplication, simpler deployment
|
||||
|
||||
**Alternatives Considered:** Separate admin tool/service
|
||||
|
||||
### 3. SQLite for State
|
||||
**Why:** Appropriate scale, persistence, no extra infrastructure
|
||||
|
||||
**Alternatives Considered:** Redis, PostgreSQL, in-memory
|
||||
|
||||
### 4. Plain Text Email
|
||||
**Why:** Better spam filtering, simpler maintenance
|
||||
|
||||
**Alternatives Considered:** HTML email with branding
|
||||
|
||||
### 5. 4:00 AM Backup Schedule
|
||||
**Why:** Lowest activity window, SQLite .backup command is safe during reads
|
||||
|
||||
**Timing Rationale:** 3-6 AM lowest activity, 4 AM is middle
|
||||
|
||||
---
|
||||
|
||||
## 📈 What Happens Next
|
||||
|
||||
### Phase 1: Local Testing (Optional)
|
||||
1. Set `APP_URL=http://localhost:3500` in `.env`
|
||||
2. Run `npm run dev`
|
||||
3. Test webhook with curl
|
||||
4. Test OAuth flow
|
||||
5. Test admin panel
|
||||
|
||||
### Phase 2: Production Deployment
|
||||
1. Follow `DEPLOYMENT.md` step-by-step
|
||||
2. Deploy to Command Center
|
||||
3. Configure all services
|
||||
4. Validate with test subscription
|
||||
|
||||
### Phase 3: Soft Launch
|
||||
1. Monitor for 24 hours
|
||||
2. Test with real subscription
|
||||
3. Train Trinity on admin panel
|
||||
4. Announce to community
|
||||
|
||||
### Phase 4: Ongoing Maintenance
|
||||
1. Monitor logs daily (first week)
|
||||
2. Check backups working
|
||||
3. Review audit logs weekly
|
||||
4. Update documentation as needed
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technologies Used
|
||||
|
||||
**Runtime & Framework:**
|
||||
- Node.js 18.x
|
||||
- Express 4.x
|
||||
|
||||
**Database:**
|
||||
- SQLite (better-sqlite3)
|
||||
- connect-sqlite3 (session store)
|
||||
|
||||
**Discord:**
|
||||
- discord.js 14.x
|
||||
|
||||
**CMS:**
|
||||
- @tryghost/admin-api (Ghost 5.x)
|
||||
|
||||
**Email:**
|
||||
- Nodemailer
|
||||
|
||||
**Security:**
|
||||
- express-rate-limit
|
||||
- Zod (validation)
|
||||
- Crypto (HMAC, tokens)
|
||||
|
||||
**Session:**
|
||||
- express-session
|
||||
|
||||
**Reverse Proxy:**
|
||||
- Nginx + Let's Encrypt
|
||||
|
||||
**UI:**
|
||||
- Pico.css (classless CSS framework)
|
||||
|
||||
---
|
||||
|
||||
## 💡 Key Learnings from Gemini
|
||||
|
||||
### Technical
|
||||
1. **Soft gates work** - Don't force Discord before purchase
|
||||
2. **Don't separate prematurely** - Monoliths are good at small scale
|
||||
3. **SQLite is underrated** - Perfect for this use case
|
||||
4. **Plain text email** - Better deliverability than HTML
|
||||
5. **Rate limiting is essential** - Even low-traffic apps need it
|
||||
|
||||
### Operational
|
||||
1. **4 AM backups** - Middle of lowest activity window
|
||||
2. **SQLite .backup is safe** - Can run while app is active
|
||||
3. **chmod 700 backups** - Protect secrets in backed-up .env
|
||||
4. **Trust proxy matters** - Required for sessions behind Nginx
|
||||
5. **Role hierarchy is critical** - Bot role must be ABOVE subscriber roles
|
||||
|
||||
---
|
||||
|
||||
## 📝 Post-Deployment Tasks
|
||||
|
||||
### Immediate (Within 24 Hours)
|
||||
- [ ] Verify health check green
|
||||
- [ ] Test complete OAuth flow with real subscription
|
||||
- [ ] Check email delivery (inbox, not spam)
|
||||
- [ ] Verify Ghost CMS updates correctly
|
||||
- [ ] Confirm Discord roles assign correctly
|
||||
- [ ] Test admin panel with all Trinity members
|
||||
|
||||
### Within 1 Week
|
||||
- [ ] Monitor logs for errors
|
||||
- [ ] Review first backup success
|
||||
- [ ] Test all subscription events (create, upgrade, downgrade, cancel)
|
||||
- [ ] Document any deployment-specific notes
|
||||
- [ ] Update operations manual
|
||||
|
||||
### Ongoing
|
||||
- [ ] Weekly audit log review
|
||||
- [ ] Monthly backup restore test
|
||||
- [ ] Quarterly dependency updates
|
||||
- [ ] Update documentation as system evolves
|
||||
|
||||
---
|
||||
|
||||
## 🆘 If Something Goes Wrong
|
||||
|
||||
### Application Won't Start
|
||||
1. Check logs: `sudo journalctl -u arbiter -n 100`
|
||||
2. Common causes: missing .env, syntax error, port in use
|
||||
3. See TROUBLESHOOTING.md "Application Won't Start"
|
||||
|
||||
### Webhooks Failing
|
||||
1. Check webhook signature in Paymenter matches .env
|
||||
2. Check health endpoint: `/health`
|
||||
3. See TROUBLESHOOTING.md "Webhook signature verification failed"
|
||||
|
||||
### Roles Not Assigning
|
||||
1. Check bot role hierarchy in Discord
|
||||
2. Verify role IDs in `config/roles.json` are correct
|
||||
3. See TROUBLESHOOTING.md "Bot missing permissions"
|
||||
|
||||
### Emergency Rollback
|
||||
```bash
|
||||
sudo systemctl stop arbiter
|
||||
sudo systemctl disable arbiter
|
||||
sudo rm /etc/nginx/sites-enabled/arbiter
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
**Full troubleshooting:** See `TROUBLESHOOTING.md`
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support Resources
|
||||
|
||||
**Documentation:**
|
||||
- `/discord-oauth-implementation/README.md` - Complete overview
|
||||
- `/discord-oauth-implementation/DEPLOYMENT.md` - Step-by-step deployment
|
||||
- `/discord-oauth-implementation/TROUBLESHOOTING.md` - Common issues
|
||||
|
||||
**Gemini Consultation Archive:**
|
||||
- `/firefrost-operations-manual/docs/consultations/gemini-discord-oauth-2026-03-30/`
|
||||
- Complete technical discussion
|
||||
- All architecture decisions
|
||||
- Already in Gitea (commit `dbfc123`)
|
||||
|
||||
**Implementation Partner:**
|
||||
- Claude (Chronicler #49)
|
||||
|
||||
**Architecture Consultant:**
|
||||
- Gemini AI
|
||||
|
||||
---
|
||||
|
||||
## ✅ Quality Checklist
|
||||
|
||||
**Code Quality:**
|
||||
- ✅ Production-ready code
|
||||
- ✅ Error handling on all external calls
|
||||
- ✅ Input validation on all user input
|
||||
- ✅ Security best practices followed
|
||||
- ✅ Logging for debugging
|
||||
- ✅ Comments explaining complex logic
|
||||
|
||||
**Documentation:**
|
||||
- ✅ Complete README
|
||||
- ✅ Step-by-step deployment guide
|
||||
- ✅ Troubleshooting for 7+ common issues
|
||||
- ✅ Inline code comments
|
||||
- ✅ Configuration examples
|
||||
- ✅ Architecture decisions documented
|
||||
|
||||
**Testing:**
|
||||
- ✅ Local testing procedure provided
|
||||
- ✅ Production testing checklist included
|
||||
- ✅ Health check endpoint
|
||||
- ✅ Manual testing commands provided
|
||||
|
||||
**Operations:**
|
||||
- ✅ Automated backups configured
|
||||
- ✅ Systemd service file
|
||||
- ✅ Log rotation consideration
|
||||
- ✅ Monitoring recommendations
|
||||
- ✅ Rollback procedure
|
||||
|
||||
---
|
||||
|
||||
## 🎉 You're Ready!
|
||||
|
||||
**Everything is prepared.** When you're home and ready:
|
||||
|
||||
1. Read `DEPLOYMENT.md` start to finish
|
||||
2. Gather all credentials (checklist in DEPLOYMENT.md)
|
||||
3. Follow the 7-phase deployment process
|
||||
4. Validate with test subscription
|
||||
5. Monitor for 24 hours
|
||||
6. Go live!
|
||||
|
||||
**Estimated deployment time:** 2-3 hours (including validation)
|
||||
|
||||
---
|
||||
|
||||
## 💙 Final Notes
|
||||
|
||||
This implementation represents ~7 hours of consultation with Gemini AI, resulting in:
|
||||
- **2,000+ lines of production code**
|
||||
- **12,000+ words of documentation**
|
||||
- **5 major architecture decisions**
|
||||
- **24 complete files ready to deploy**
|
||||
|
||||
**Built with care for:**
|
||||
- Subscribers (seamless experience)
|
||||
- Trinity (powerful admin tools)
|
||||
- Future maintainers (comprehensive docs)
|
||||
- The community ("for children not yet born")
|
||||
|
||||
**This is sustainable infrastructure.** It will serve Firefrost Gaming for years.
|
||||
|
||||
---
|
||||
|
||||
**🔥❄️ Fire + Frost + Foundation = Where Love Builds Legacy 💙**
|
||||
|
||||
**Prepared by Chronicler #49 on March 30, 2026 at 10:18 AM CDT**
|
||||
465
services/arbiter/README.md
Normal file
465
services/arbiter/README.md
Normal file
@@ -0,0 +1,465 @@
|
||||
# Firefrost Arbiter
|
||||
|
||||
**Discord Role Management and Subscription OAuth Gateway**
|
||||
|
||||
A centralized Node.js/Express service for managing Discord community roles, authenticating users via OAuth2, and processing subscription webhooks from Paymenter billing platform.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Purpose
|
||||
|
||||
Firefrost Arbiter automates the entire subscription-to-Discord-role workflow:
|
||||
|
||||
1. **User subscribes** via Paymenter (billing system)
|
||||
2. **Webhook fires** to Arbiter
|
||||
3. **Email sent** with secure 24-hour linking URL
|
||||
4. **User clicks link** → Discord OAuth → Role assigned automatically
|
||||
5. **Ghost CMS updated** with Discord ID for future reference
|
||||
|
||||
**Admin Interface** allows Trinity members to manually assign/remove roles, search subscribers, and view audit logs.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
### Components
|
||||
|
||||
- **Webhook Gateway**: Receives subscription events from Paymenter, generates secure single-use tokens, dispatches notification emails via SMTP
|
||||
- **OAuth2 Linking**: Authenticates users via Discord, updates Ghost CMS member metadata, automatically assigns Discord server roles based on subscription tiers
|
||||
- **Admin Dashboard**: Protected by Discord OAuth (restricted to specific User IDs), allows staff to manually assign roles, view audit logs, and search CMS records
|
||||
- **State Management**: Utilizes local SQLite databases (`linking.db` and `sessions.db`) for lightweight, persistent data storage
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Runtime**: Node.js 18.x+
|
||||
- **Framework**: Express 4.x
|
||||
- **Database**: SQLite (better-sqlite3)
|
||||
- **Discord**: discord.js 14.x
|
||||
- **CMS**: Ghost 5.x (Admin API)
|
||||
- **Email**: Nodemailer (SMTP)
|
||||
- **Session**: express-session + connect-sqlite3
|
||||
- **Security**: express-rate-limit, Zod validation, HMAC webhook verification
|
||||
|
||||
---
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
Before installation, ensure you have:
|
||||
|
||||
- **Node.js 18.x or higher** installed
|
||||
- **A Discord Application** with Bot User created ([Discord Developer Portal](https://discord.com/developers/applications))
|
||||
- Server Members Intent enabled
|
||||
- Bot invited to your Discord server with "Manage Roles" permission
|
||||
- Bot role positioned ABOVE all subscription tier roles in role hierarchy
|
||||
- **Ghost CMS 5.x** with Admin API access
|
||||
- Custom field `discord_id` created in Ghost Admin
|
||||
- Integration created for Admin API key
|
||||
- **SMTP Server** for outgoing mail (Mailcow, Gmail, SendGrid, etc.)
|
||||
- **Nginx** for reverse proxy and SSL termination
|
||||
- **SSL Certificate** (Let's Encrypt recommended)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### 1. Clone and Install Dependencies
|
||||
|
||||
```bash
|
||||
cd /home/architect
|
||||
git clone <repository-url> arbiter
|
||||
cd arbiter
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Fill in all required values:
|
||||
- Discord credentials (bot token, client ID/secret, guild ID)
|
||||
- Ghost CMS URL and Admin API key
|
||||
- SMTP server details
|
||||
- Admin Discord IDs (comma-separated, no spaces)
|
||||
- Generate SESSION_SECRET: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
||||
- Webhook secret from Paymenter
|
||||
|
||||
### 3. Configure Discord Role Mapping
|
||||
|
||||
Edit `config/roles.json` with your Discord role IDs:
|
||||
|
||||
```bash
|
||||
nano config/roles.json
|
||||
```
|
||||
|
||||
Get role IDs: Right-click role in Discord → Copy ID
|
||||
|
||||
### 4. Set Up Nginx Reverse Proxy
|
||||
|
||||
```bash
|
||||
sudo cp nginx.conf /etc/nginx/sites-available/arbiter
|
||||
sudo ln -s /etc/nginx/sites-available/arbiter /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 5. Set Up Systemd Service
|
||||
|
||||
```bash
|
||||
sudo cp arbiter.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable arbiter
|
||||
sudo systemctl start arbiter
|
||||
```
|
||||
|
||||
### 6. Verify Installation
|
||||
|
||||
Check service status:
|
||||
```bash
|
||||
sudo systemctl status arbiter
|
||||
```
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
sudo journalctl -u arbiter -f
|
||||
```
|
||||
|
||||
Visit health check:
|
||||
```bash
|
||||
curl https://discord-bot.firefrostgaming.com/health
|
||||
```
|
||||
|
||||
Should return:
|
||||
```json
|
||||
{
|
||||
"uptime": 123.456,
|
||||
"discord": "ok",
|
||||
"database": "ok",
|
||||
"timestamp": "2026-03-30T15:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Set Up Automated Backups
|
||||
|
||||
```bash
|
||||
chmod +x backup.sh
|
||||
crontab -e
|
||||
```
|
||||
|
||||
Add this line (runs daily at 4:00 AM):
|
||||
```
|
||||
0 4 * * * /home/architect/arbiter/backup.sh >> /home/architect/backups/arbiter/cron_error.log 2>&1
|
||||
```
|
||||
|
||||
Create backup directory:
|
||||
```bash
|
||||
mkdir -p /home/architect/backups/arbiter
|
||||
chmod 700 /home/architect/backups/arbiter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Discord Developer Portal Setup
|
||||
|
||||
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. Create New Application (or select existing)
|
||||
3. **Bot Tab**:
|
||||
- Generate bot token (save to `.env` as `DISCORD_BOT_TOKEN`)
|
||||
- Enable "Server Members Intent"
|
||||
4. **OAuth2 → General**:
|
||||
- Add Redirect URIs:
|
||||
- `http://localhost:3500/auth/callback` (testing)
|
||||
- `https://discord-bot.firefrostgaming.com/auth/callback` (production)
|
||||
- `https://discord-bot.firefrostgaming.com/admin/callback` (admin login)
|
||||
5. **OAuth2 → URL Generator**:
|
||||
- Scopes: `bot`
|
||||
- Permissions: `Manage Roles`
|
||||
- Copy generated URL and invite bot to server
|
||||
|
||||
**CRITICAL**: In Discord Server Settings → Roles, drag the bot's role ABOVE all subscription tier roles!
|
||||
|
||||
### Ghost CMS Setup
|
||||
|
||||
1. **Create Custom Field**:
|
||||
- Navigate to: Settings → Membership → Custom Fields
|
||||
- Add field: `discord_id` (type: Text)
|
||||
|
||||
2. **Generate Admin API Key**:
|
||||
- Navigate to: Settings → Integrations → Add Custom Integration
|
||||
- Name: "Firefrost Arbiter"
|
||||
- Copy Admin API Key (format: `key_id:secret`)
|
||||
- Save to `.env` as `CMS_ADMIN_KEY`
|
||||
|
||||
### Paymenter Webhook Configuration
|
||||
|
||||
1. In Paymenter admin panel, navigate to Webhooks
|
||||
2. Add new webhook:
|
||||
- URL: `https://discord-bot.firefrostgaming.com/webhook/billing`
|
||||
- Secret: (generate secure random string, save to `.env` as `WEBHOOK_SECRET`)
|
||||
- Events: `subscription.created`, `subscription.upgraded`, `subscription.downgraded`, `subscription.cancelled`
|
||||
|
||||
---
|
||||
|
||||
## 📖 Usage
|
||||
|
||||
### For Subscribers (Automated Flow)
|
||||
|
||||
1. User subscribes via Paymenter
|
||||
2. User receives email with secure linking URL
|
||||
3. User clicks link → redirected to Discord OAuth
|
||||
4. User authorizes → role automatically assigned
|
||||
5. User sees new channels in Discord immediately
|
||||
|
||||
### For Admins (Manual Assignment)
|
||||
|
||||
1. Visit `https://discord-bot.firefrostgaming.com/admin/login`
|
||||
2. Authenticate via Discord
|
||||
3. **Search user** by email (from Ghost CMS)
|
||||
4. **Assign role** or remove all roles
|
||||
5. **Provide reason** (logged to audit trail)
|
||||
6. View **audit log** of all manual actions
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Local Testing Setup
|
||||
|
||||
1. Set `APP_URL=http://localhost:3500` in `.env`
|
||||
2. Add `http://localhost:3500/auth/callback` to Discord redirect URIs
|
||||
3. Run in development mode:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Test Webhook Reception
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3500/webhook/billing \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-signature: test_signature" \
|
||||
-d '{
|
||||
"event": "subscription.created",
|
||||
"customer_email": "test@example.com",
|
||||
"customer_name": "Test User",
|
||||
"tier": "awakened",
|
||||
"subscription_id": "test_sub_123"
|
||||
}'
|
||||
```
|
||||
|
||||
### Test OAuth Flow
|
||||
|
||||
1. Trigger webhook (above) to generate token
|
||||
2. Check database: `sqlite3 linking.db "SELECT * FROM link_tokens;"`
|
||||
3. Check email sent (Mailcow logs)
|
||||
4. Click link in email
|
||||
5. Complete Discord OAuth
|
||||
6. Verify role assigned in Discord
|
||||
7. Verify Ghost CMS updated: Ghost Admin → Members → search email
|
||||
|
||||
### Test Admin Panel
|
||||
|
||||
1. Visit `/admin/login`
|
||||
2. Authenticate with Discord
|
||||
3. Search for test user by email
|
||||
4. Assign/remove role
|
||||
5. Check audit log displays action
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
arbiter/
|
||||
├── src/
|
||||
│ ├── routes/
|
||||
│ │ ├── webhook.js # Paymenter webhook handler
|
||||
│ │ ├── oauth.js # User Discord linking flow
|
||||
│ │ ├── admin.js # Admin panel routes
|
||||
│ │ └── adminAuth.js # Admin OAuth login
|
||||
│ ├── middleware/
|
||||
│ │ ├── auth.js # Admin access control
|
||||
│ │ ├── verifyWebhook.js # HMAC signature verification
|
||||
│ │ └── validateWebhook.js # Zod schema validation
|
||||
│ ├── utils/
|
||||
│ │ └── templates.js # HTML success/error pages
|
||||
│ ├── views/
|
||||
│ │ └── admin.html # Admin panel UI
|
||||
│ ├── database.js # SQLite initialization
|
||||
│ ├── email.js # Nodemailer SMTP
|
||||
│ ├── discordService.js # Bot client + role management
|
||||
│ ├── cmsService.js # Ghost CMS integration
|
||||
│ └── index.js # Main application entry
|
||||
├── config/
|
||||
│ └── roles.json # Tier → Discord Role ID mapping
|
||||
├── .env # Environment variables (not in git)
|
||||
├── .env.example # Template for .env
|
||||
├── package.json # Dependencies
|
||||
├── backup.sh # Automated backup script
|
||||
├── arbiter.service # Systemd service file
|
||||
└── nginx.conf # Nginx reverse proxy config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- **Webhook Verification**: HMAC SHA256 signature validation
|
||||
- **Input Validation**: Zod schemas for all webhook payloads
|
||||
- **Rate Limiting**: 100 requests per 15 minutes per IP
|
||||
- **Session Security**: httpOnly, SameSite cookies
|
||||
- **Admin Access Control**: Discord ID whitelist via environment variable
|
||||
- **HTTPS Enforcement**: Nginx SSL termination with HSTS headers
|
||||
- **Secure Tokens**: 32-byte cryptographically random tokens (64 hex chars)
|
||||
- **Token Expiration**: 24-hour automatic expiry
|
||||
- **Database Permissions**: `chmod 700` on backup directory
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Maintenance
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# Application logs
|
||||
sudo journalctl -u arbiter -f
|
||||
|
||||
# Nginx access logs
|
||||
sudo tail -f /var/log/nginx/arbiter-access.log
|
||||
|
||||
# Nginx error logs
|
||||
sudo tail -f /var/log/nginx/arbiter-error.log
|
||||
|
||||
# Backup logs
|
||||
tail -f /home/architect/backups/arbiter/backup_log.txt
|
||||
```
|
||||
|
||||
### Restart Service
|
||||
|
||||
```bash
|
||||
sudo systemctl restart arbiter
|
||||
```
|
||||
|
||||
### Update Application
|
||||
|
||||
```bash
|
||||
cd /home/architect/arbiter
|
||||
git pull
|
||||
npm install
|
||||
sudo systemctl restart arbiter
|
||||
```
|
||||
|
||||
### Database Maintenance
|
||||
|
||||
```bash
|
||||
# View link tokens
|
||||
sqlite3 linking.db "SELECT * FROM link_tokens WHERE used = 0;"
|
||||
|
||||
# View audit logs
|
||||
sqlite3 linking.db "SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT 10;"
|
||||
|
||||
# Manual cleanup of expired tokens
|
||||
sqlite3 linking.db "DELETE FROM link_tokens WHERE created_at < datetime('now', '-1 day');"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for detailed solutions to common issues.
|
||||
|
||||
Quick reference:
|
||||
- Invalid redirect URI → Check Discord Developer Portal OAuth settings
|
||||
- Bot missing permissions → Check role hierarchy in Discord
|
||||
- Session not persisting → Check `trust proxy` setting in code
|
||||
- Ghost API 401 → Verify Admin API key format
|
||||
- Database locked → Increase timeout in database.js
|
||||
- Email not sending → Check SMTP credentials and port 587 firewall rule
|
||||
|
||||
---
|
||||
|
||||
## 📦 Backup & Restore
|
||||
|
||||
### Backup Procedure
|
||||
|
||||
Automated daily at 4:00 AM via cron. Manual backup:
|
||||
|
||||
```bash
|
||||
./backup.sh
|
||||
```
|
||||
|
||||
Backs up:
|
||||
- `linking.db` (tokens and audit logs)
|
||||
- `sessions.db` (admin sessions)
|
||||
- `.env` (configuration with secrets)
|
||||
- `config/roles.json` (tier mappings)
|
||||
|
||||
Retention: 7 days
|
||||
|
||||
### Restore Procedure
|
||||
|
||||
```bash
|
||||
# Stop service
|
||||
sudo systemctl stop arbiter
|
||||
|
||||
# Move corrupted database
|
||||
mv linking.db linking.db.corrupt
|
||||
|
||||
# Restore from backup
|
||||
cp /home/architect/backups/arbiter/linking_YYYYMMDD_HHMMSS.db linking.db
|
||||
|
||||
# Start service
|
||||
sudo systemctl start arbiter
|
||||
```
|
||||
|
||||
Verify restored backup:
|
||||
```bash
|
||||
sqlite3 /home/architect/backups/arbiter/linking_20260330_040000.db "SELECT count(*) FROM link_tokens;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- [DEPLOYMENT.md](DEPLOYMENT.md) - Complete deployment guide
|
||||
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Common issues and solutions
|
||||
- [API.md](API.md) - API endpoint documentation (if created)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
This is a private system for Firefrost Gaming. For internal team members:
|
||||
|
||||
1. Create feature branch
|
||||
2. Test locally
|
||||
3. Commit with detailed messages
|
||||
4. Deploy to staging first
|
||||
5. Monitor logs before production rollout
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
Private - Firefrost Gaming Internal Use Only
|
||||
|
||||
---
|
||||
|
||||
## 👥 Team
|
||||
|
||||
**Built by:**
|
||||
- Michael "Frostystyle" Krause (The Wizard) - Technical Lead
|
||||
- Claude (Chronicler #49) - Implementation Partner
|
||||
- Gemini AI - Architecture Consultant
|
||||
|
||||
**For:** Firefrost Gaming Community
|
||||
|
||||
**Date:** March 30, 2026
|
||||
|
||||
---
|
||||
|
||||
**🔥❄️ Fire + Frost + Foundation = Where Love Builds Legacy 💙**
|
||||
666
services/arbiter/TROUBLESHOOTING.md
Normal file
666
services/arbiter/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,666 @@
|
||||
# Firefrost Arbiter - Troubleshooting Guide
|
||||
|
||||
**Last Updated:** March 30, 2026
|
||||
**Prepared by:** Claude (Chronicler #49) + Gemini AI
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Quick Diagnostics
|
||||
|
||||
### Check Service Status
|
||||
```bash
|
||||
sudo systemctl status arbiter
|
||||
```
|
||||
|
||||
### View Recent Logs
|
||||
```bash
|
||||
sudo journalctl -u arbiter -n 50
|
||||
```
|
||||
|
||||
### Follow Live Logs
|
||||
```bash
|
||||
sudo journalctl -u arbiter -f
|
||||
```
|
||||
|
||||
### Check Health Endpoint
|
||||
```bash
|
||||
curl https://discord-bot.firefrostgaming.com/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Common Issues & Solutions
|
||||
|
||||
### 1. "Invalid redirect URI" in Discord OAuth
|
||||
|
||||
**Symptom:** When clicking linking URL or admin login, Discord shows "Invalid Redirect URI" error.
|
||||
|
||||
**Cause:** The redirect URI in your `.env` file doesn't exactly match what's registered in the Discord Developer Portal.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check `.env` file:
|
||||
```bash
|
||||
cat .env | grep APP_URL
|
||||
```
|
||||
|
||||
Should show: `APP_URL=https://discord-bot.firefrostgaming.com` (no trailing slash)
|
||||
|
||||
2. Go to Discord Developer Portal → OAuth2 → General
|
||||
3. Verify exact URIs are registered:
|
||||
- `https://discord-bot.firefrostgaming.com/auth/callback`
|
||||
- `https://discord-bot.firefrostgaming.com/admin/callback`
|
||||
|
||||
4. **Important:** Check for:
|
||||
- Trailing slashes (don't include them)
|
||||
- `http` vs `https` mismatch
|
||||
- `www` vs non-www
|
||||
- Typos in domain
|
||||
|
||||
5. If you changed the URI, wait 5-10 minutes for Discord to propagate
|
||||
|
||||
6. Restart the application:
|
||||
```bash
|
||||
sudo systemctl restart arbiter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. "Bot missing permissions" when assigning roles
|
||||
|
||||
**Symptom:** Logs show "Failed to assign role" or "Missing Permissions" error when trying to assign Discord roles.
|
||||
|
||||
**Cause:** Either the bot wasn't invited with the correct permissions, or the bot's role is positioned below the roles it's trying to assign.
|
||||
|
||||
**Solution:**
|
||||
|
||||
**Check 1: Bot Has "Manage Roles" Permission**
|
||||
1. Go to Discord Server → Settings → Roles
|
||||
2. Find the bot's role (usually named after the bot)
|
||||
3. Verify "Manage Roles" permission is enabled
|
||||
4. If not, enable it
|
||||
|
||||
**Check 2: Role Hierarchy (Most Common Issue)**
|
||||
1. Go to Discord Server → Settings → Roles
|
||||
2. Find the bot's role in the list
|
||||
3. **Drag it ABOVE all subscription tier roles**
|
||||
4. The bot can only assign roles that are below its own role
|
||||
|
||||
Example correct hierarchy:
|
||||
```
|
||||
1. Owner (you)
|
||||
2. Admin
|
||||
3. [Bot Role] ← MUST BE HERE
|
||||
4. Sovereign
|
||||
5. Fire Legend
|
||||
6. Frost Legend
|
||||
... (all other subscriber roles)
|
||||
```
|
||||
|
||||
**Check 3: Re-invite Bot with Correct Permissions**
|
||||
|
||||
If role hierarchy is correct but still failing:
|
||||
|
||||
1. Go to Discord Developer Portal → OAuth2 → URL Generator
|
||||
2. Select scopes: `bot`
|
||||
3. Select permissions: `Manage Roles` (minimum)
|
||||
4. Copy generated URL
|
||||
5. Visit URL and re-authorize bot (this updates permissions)
|
||||
|
||||
**Test:**
|
||||
```bash
|
||||
# Check if bot can see roles
|
||||
sudo journalctl -u arbiter -n 100 | grep "Role ID"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. "Session not persisting" across requests
|
||||
|
||||
**Symptom:** Admin panel logs you out immediately after login, or every page reload requires re-authentication.
|
||||
|
||||
**Cause:** Session cookies not being saved properly, usually due to reverse proxy configuration.
|
||||
|
||||
**Solution:**
|
||||
|
||||
**Check 1: Express Trust Proxy Setting**
|
||||
|
||||
Verify in `src/index.js`:
|
||||
```javascript
|
||||
app.set('trust proxy', 1);
|
||||
```
|
||||
|
||||
This line MUST be present before session middleware.
|
||||
|
||||
**Check 2: Nginx Proxy Headers**
|
||||
|
||||
Edit Nginx config:
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/arbiter
|
||||
```
|
||||
|
||||
Verify these headers exist in the `location /` block:
|
||||
```nginx
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
**Check 3: Cookie Settings for Development**
|
||||
|
||||
If testing on `http://localhost`, update `src/index.js`:
|
||||
```javascript
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production', // false for localhost
|
||||
httpOnly: true,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7
|
||||
}
|
||||
```
|
||||
|
||||
**Check 4: SESSION_SECRET is Set**
|
||||
```bash
|
||||
grep SESSION_SECRET .env
|
||||
```
|
||||
|
||||
Should show a 64-character hex string.
|
||||
|
||||
**Restart after changes:**
|
||||
```bash
|
||||
sudo systemctl restart arbiter
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. "Ghost API 401 error"
|
||||
|
||||
**Symptom:** Logs show "Ghost API 401 Unauthorized" when trying to search users or update members.
|
||||
|
||||
**Cause:** Invalid or incorrectly formatted Admin API key.
|
||||
|
||||
**Solution:**
|
||||
|
||||
**Check 1: API Key Format**
|
||||
```bash
|
||||
cat .env | grep CMS_ADMIN_KEY
|
||||
```
|
||||
|
||||
Should be in format: `key_id:secret` (with the colon)
|
||||
|
||||
Example:
|
||||
```
|
||||
CMS_ADMIN_KEY=65f8a1b2c3d4e5f6:a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6
|
||||
```
|
||||
|
||||
**Check 2: Integration Still Exists**
|
||||
|
||||
1. Go to Ghost Admin → Settings → Integrations
|
||||
2. Find "Firefrost Arbiter" integration
|
||||
3. Verify it's not deleted or disabled
|
||||
4. If missing, create new integration and update `.env`
|
||||
|
||||
**Check 3: Ghost URL is Correct**
|
||||
```bash
|
||||
cat .env | grep CMS_URL
|
||||
```
|
||||
|
||||
Should match your Ghost installation URL exactly (no trailing slash).
|
||||
|
||||
**Check 4: Test API Key Manually**
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Ghost <your_admin_key>" \
|
||||
"https://firefrostgaming.com/ghost/api/admin/members/"
|
||||
```
|
||||
|
||||
Should return JSON with member list. If 401, key is invalid.
|
||||
|
||||
**After fixing:**
|
||||
```bash
|
||||
sudo systemctl restart arbiter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. "Database locked" errors
|
||||
|
||||
**Symptom:** Logs show "SQLITE_BUSY: database is locked" when multiple webhooks arrive simultaneously.
|
||||
|
||||
**Cause:** SQLite locks the database during writes. If multiple webhooks arrive at exactly the same time, one may fail.
|
||||
|
||||
**Solution:**
|
||||
|
||||
**Option 1: Increase Timeout (Recommended)**
|
||||
|
||||
Edit `src/database.js`:
|
||||
```javascript
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database('linking.db', { timeout: 5000 });
|
||||
```
|
||||
|
||||
This gives SQLite 5 seconds to wait for locks to clear.
|
||||
|
||||
**Option 2: Add WAL Mode (Write-Ahead Logging)**
|
||||
|
||||
Edit `src/database.js`, add after database creation:
|
||||
```javascript
|
||||
db.pragma('journal_mode = WAL');
|
||||
```
|
||||
|
||||
WAL mode allows concurrent reads and writes.
|
||||
|
||||
**Option 3: Retry Logic (For Critical Operations)**
|
||||
|
||||
In `src/routes/webhook.js`, wrap database operations:
|
||||
```javascript
|
||||
let retries = 3;
|
||||
while (retries > 0) {
|
||||
try {
|
||||
stmt.run(token, customer_email, tier, subscription_id);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error.code === 'SQLITE_BUSY' && retries > 1) {
|
||||
retries--;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After changes:**
|
||||
```bash
|
||||
sudo systemctl restart arbiter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. "Email not sending"
|
||||
|
||||
**Symptom:** Webhook processes successfully but subscriber never receives linking email.
|
||||
|
||||
**Cause:** SMTP connection issue, firewall blocking port 587, or incorrect credentials.
|
||||
|
||||
**Solution:**
|
||||
|
||||
**Check 1: SMTP Credentials**
|
||||
```bash
|
||||
cat .env | grep SMTP
|
||||
```
|
||||
|
||||
Verify:
|
||||
- `SMTP_HOST=38.68.14.188`
|
||||
- `SMTP_USER=noreply@firefrostgaming.com`
|
||||
- `SMTP_PASS=<correct password>`
|
||||
|
||||
**Check 2: Port 587 is Open**
|
||||
|
||||
From Command Center:
|
||||
```bash
|
||||
telnet 38.68.14.188 587
|
||||
```
|
||||
|
||||
Should connect. If "Connection refused":
|
||||
```bash
|
||||
sudo ufw allow 587
|
||||
```
|
||||
|
||||
**Check 3: Test SMTP Manually**
|
||||
|
||||
```bash
|
||||
node -e "
|
||||
const nodemailer = require('nodemailer');
|
||||
const t = nodemailer.createTransport({
|
||||
host: '38.68.14.188',
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: { user: 'noreply@firefrostgaming.com', pass: 'YOUR_PASSWORD' }
|
||||
});
|
||||
t.sendMail({
|
||||
from: 'noreply@firefrostgaming.com',
|
||||
to: 'your_email@example.com',
|
||||
subject: 'Test',
|
||||
text: 'Testing SMTP'
|
||||
}).then(() => console.log('Sent!')).catch(console.error);
|
||||
"
|
||||
```
|
||||
|
||||
**Check 4: Mailcow Logs**
|
||||
|
||||
SSH to Billing VPS:
|
||||
```bash
|
||||
ssh root@38.68.14.188
|
||||
docker logs -f mailcowdockerized_postfix-mailcow_1 | grep noreply
|
||||
```
|
||||
|
||||
Look for errors or rejections.
|
||||
|
||||
**Check 5: Spam Folder**
|
||||
|
||||
Check if email landed in spam/junk folder.
|
||||
|
||||
**Check 6: DKIM/SPF Records**
|
||||
|
||||
Verify DNS records are set up correctly (should be done already, but worth checking if delivery is failing).
|
||||
|
||||
---
|
||||
|
||||
### 7. "Webhook signature verification failed"
|
||||
|
||||
**Symptom:** Paymenter sends webhook but application logs "Invalid webhook signature" and returns 401.
|
||||
|
||||
**Cause:** `WEBHOOK_SECRET` in `.env` doesn't match the secret configured in Paymenter.
|
||||
|
||||
**Solution:**
|
||||
|
||||
**Check 1: Secrets Match**
|
||||
```bash
|
||||
cat .env | grep WEBHOOK_SECRET
|
||||
```
|
||||
|
||||
Compare to Paymenter webhook configuration:
|
||||
1. Paymenter Admin → System → Webhooks
|
||||
2. Find Arbiter webhook
|
||||
3. Check secret field
|
||||
|
||||
They must match exactly.
|
||||
|
||||
**Check 2: Header Name**
|
||||
|
||||
Verify Paymenter sends signature in `x-signature` header.
|
||||
|
||||
Edit `src/middleware/verifyWebhook.js` if needed:
|
||||
```javascript
|
||||
const signature = req.headers['x-signature']; // or 'x-paymenter-signature' or whatever Paymenter uses
|
||||
```
|
||||
|
||||
**Check 3: Signature Algorithm**
|
||||
|
||||
Verify Paymenter uses HMAC SHA256. If different, update `src/middleware/verifyWebhook.js`:
|
||||
```javascript
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secret) // or 'sha1', 'md5', etc.
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
```
|
||||
|
||||
**Check 4: Payload Format**
|
||||
|
||||
Paymenter might stringify the JSON differently. Add debug logging:
|
||||
```javascript
|
||||
console.log('Received signature:', signature);
|
||||
console.log('Payload:', payload);
|
||||
console.log('Expected signature:', expectedSignature);
|
||||
```
|
||||
|
||||
**Temporary Bypass (Testing Only):**
|
||||
|
||||
To test without signature verification (NOT for production):
|
||||
```javascript
|
||||
// In src/routes/webhook.js, temporarily comment out:
|
||||
// router.post('/billing', verifyBillingWebhook, validateBillingPayload, async (req, res) => {
|
||||
router.post('/billing', validateBillingPayload, async (req, res) => {
|
||||
```
|
||||
|
||||
**After fixing:**
|
||||
```bash
|
||||
sudo systemctl restart arbiter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔥 Emergency Procedures
|
||||
|
||||
### Application Won't Start
|
||||
|
||||
**Symptom:** `systemctl status arbiter` shows "failed" status.
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
sudo journalctl -u arbiter -n 100
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Missing `.env` file
|
||||
- Syntax errors in code
|
||||
- Missing dependencies
|
||||
- Port 3500 already in use
|
||||
|
||||
**Solutions:**
|
||||
|
||||
**Port in use:**
|
||||
```bash
|
||||
sudo lsof -i :3500
|
||||
sudo kill -9 <PID>
|
||||
sudo systemctl start arbiter
|
||||
```
|
||||
|
||||
**Missing dependencies:**
|
||||
```bash
|
||||
cd /home/architect/arbiter
|
||||
npm install
|
||||
sudo systemctl restart arbiter
|
||||
```
|
||||
|
||||
**Syntax errors:**
|
||||
Fix the reported file and line number, then:
|
||||
```bash
|
||||
sudo systemctl restart arbiter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Database Corruption
|
||||
|
||||
**Symptom:** Application crashes with "database disk image is malformed" error.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Stop application
|
||||
sudo systemctl stop arbiter
|
||||
|
||||
# Check database integrity
|
||||
sqlite3 linking.db "PRAGMA integrity_check;"
|
||||
```
|
||||
|
||||
**If corrupted:**
|
||||
```bash
|
||||
# Restore from backup (see DEPLOYMENT.md Phase 5)
|
||||
mv linking.db linking.db.corrupt
|
||||
cp /home/architect/backups/arbiter/linking_YYYYMMDD_HHMMSS.db linking.db
|
||||
|
||||
# Restart application
|
||||
sudo systemctl start arbiter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### All Webhooks Suddenly Failing
|
||||
|
||||
**Symptom:** Every webhook returns 500 error, but application is running.
|
||||
|
||||
**Check 1: Disk Space**
|
||||
```bash
|
||||
df -h
|
||||
```
|
||||
|
||||
If `/` is at 100%, clear space:
|
||||
```bash
|
||||
# Clean old logs
|
||||
sudo journalctl --vacuum-time=7d
|
||||
|
||||
# Clean old backups
|
||||
find /home/architect/backups/arbiter -type f -mtime +7 -delete
|
||||
```
|
||||
|
||||
**Check 2: Memory Usage**
|
||||
```bash
|
||||
free -h
|
||||
```
|
||||
|
||||
If out of memory:
|
||||
```bash
|
||||
sudo systemctl restart arbiter
|
||||
```
|
||||
|
||||
**Check 3: Discord Bot Disconnected**
|
||||
```bash
|
||||
curl http://localhost:3500/health
|
||||
```
|
||||
|
||||
If `discord: "down"`:
|
||||
```bash
|
||||
sudo systemctl restart arbiter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance Issues
|
||||
|
||||
### Slow Response Times
|
||||
|
||||
**Check 1: Database Size**
|
||||
```bash
|
||||
ls -lh linking.db sessions.db
|
||||
```
|
||||
|
||||
If >100MB, consider cleanup:
|
||||
```bash
|
||||
sqlite3 linking.db "DELETE FROM link_tokens WHERE used = 1 AND created_at < datetime('now', '-30 days');"
|
||||
sqlite3 linking.db "VACUUM;"
|
||||
```
|
||||
|
||||
**Check 2: High CPU Usage**
|
||||
```bash
|
||||
top
|
||||
```
|
||||
|
||||
If `node` process is using >80% CPU consistently, check for:
|
||||
- Infinite loops in code
|
||||
- Too many concurrent webhooks
|
||||
- Discord API rate limiting (bot trying to reconnect repeatedly)
|
||||
|
||||
**Check 3: Rate Limiting Too Strict**
|
||||
|
||||
If users report frequent "Too many requests" errors:
|
||||
|
||||
Edit `src/index.js`:
|
||||
```javascript
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 200, // Increase from 100
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Concerns
|
||||
|
||||
### Suspicious Database Entries
|
||||
|
||||
**Check for unusual tokens:**
|
||||
```bash
|
||||
sqlite3 linking.db "SELECT email, tier, created_at FROM link_tokens WHERE used = 0 ORDER BY created_at DESC LIMIT 20;"
|
||||
```
|
||||
|
||||
**Check audit log for unauthorized actions:**
|
||||
```bash
|
||||
sqlite3 linking.db "SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT 20;"
|
||||
```
|
||||
|
||||
**If compromised:**
|
||||
1. Change all secrets in `.env`
|
||||
2. Rotate Discord bot token
|
||||
3. Regenerate Ghost Admin API key
|
||||
4. Clear all unused tokens:
|
||||
```bash
|
||||
sqlite3 linking.db "DELETE FROM link_tokens WHERE used = 0;"
|
||||
```
|
||||
5. Force all admin re-authentication:
|
||||
```bash
|
||||
rm sessions.db
|
||||
```
|
||||
6. Restart application
|
||||
|
||||
---
|
||||
|
||||
## 📞 Getting Help
|
||||
|
||||
**Before asking for help, collect:**
|
||||
|
||||
1. Service status:
|
||||
```bash
|
||||
sudo systemctl status arbiter > /tmp/arbiter-status.txt
|
||||
```
|
||||
|
||||
2. Recent logs:
|
||||
```bash
|
||||
sudo journalctl -u arbiter -n 200 > /tmp/arbiter-logs.txt
|
||||
```
|
||||
|
||||
3. Configuration (sanitized):
|
||||
```bash
|
||||
cat .env | sed 's/=.*/=REDACTED/' > /tmp/arbiter-config.txt
|
||||
```
|
||||
|
||||
4. Health check output:
|
||||
```bash
|
||||
curl https://discord-bot.firefrostgaming.com/health > /tmp/arbiter-health.txt
|
||||
```
|
||||
|
||||
5. Database stats:
|
||||
```bash
|
||||
sqlite3 linking.db "SELECT COUNT(*) FROM link_tokens;" > /tmp/arbiter-db-stats.txt
|
||||
sqlite3 linking.db "SELECT COUNT(*) FROM audit_logs;" >> /tmp/arbiter-db-stats.txt
|
||||
```
|
||||
|
||||
**Share these files (remove any actual secrets first) when requesting support.**
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tools & Commands Reference
|
||||
|
||||
### Restart Everything
|
||||
```bash
|
||||
sudo systemctl restart arbiter
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### View All Environment Variables
|
||||
```bash
|
||||
cat .env
|
||||
```
|
||||
|
||||
### Check Which Process is Using Port 3500
|
||||
```bash
|
||||
sudo lsof -i :3500
|
||||
```
|
||||
|
||||
### Test Database Connection
|
||||
```bash
|
||||
sqlite3 linking.db "SELECT 1;"
|
||||
```
|
||||
|
||||
### Force Regenerate Sessions Database
|
||||
```bash
|
||||
sudo systemctl stop arbiter
|
||||
rm sessions.db
|
||||
sudo systemctl start arbiter
|
||||
```
|
||||
|
||||
### Manually Cleanup Old Tokens
|
||||
```bash
|
||||
sqlite3 linking.db "DELETE FROM link_tokens WHERE created_at < datetime('now', '-1 day');"
|
||||
```
|
||||
|
||||
### Export Audit Logs to CSV
|
||||
```bash
|
||||
sqlite3 -header -csv linking.db "SELECT * FROM audit_logs ORDER BY timestamp DESC;" > audit_export.csv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**🔥❄️ When in doubt, check the logs first. Most issues reveal themselves there. 💙**
|
||||
1
services/arbiter/VERSION
Normal file
1
services/arbiter/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
2.0.0
|
||||
33
services/arbiter/backup.sh
Normal file
33
services/arbiter/backup.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Firefrost Arbiter - Automated Backup Script
|
||||
# Location: /home/architect/arbiter/backup.sh
|
||||
# Scheduled: Daily at 4:00 AM CST via crontab
|
||||
|
||||
APP_DIR="/home/architect/arbiter"
|
||||
BACKUP_DIR="/home/architect/backups/arbiter"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
LOG_FILE="$BACKUP_DIR/backup_log.txt"
|
||||
|
||||
# Ensure the backup directory exists
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
echo "--- Backup Started: $DATE ---" >> "$LOG_FILE"
|
||||
|
||||
# Safely backup databases using SQLite native backup
|
||||
if ! sqlite3 "$APP_DIR/linking.db" ".backup '$BACKUP_DIR/linking_$DATE.db'"; then
|
||||
echo "ERROR: Failed to backup linking.db" >> "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! sqlite3 "$APP_DIR/sessions.db" ".backup '$BACKUP_DIR/sessions_$DATE.db'"; then
|
||||
echo "ERROR: Failed to backup sessions.db" >> "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy config and environment files
|
||||
cp "$APP_DIR/.env" "$BACKUP_DIR/env_$DATE.bak"
|
||||
cp "$APP_DIR/config/roles.json" "$BACKUP_DIR/roles_$DATE.json"
|
||||
|
||||
# Delete backups older than 7 days to save disk space
|
||||
find "$BACKUP_DIR" -type f -mtime +7 -delete
|
||||
|
||||
echo "Backup completed successfully." >> "$LOG_FILE"
|
||||
12
services/arbiter/config/roles.json
Normal file
12
services/arbiter/config/roles.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"awakened": "REPLACE_WITH_DISCORD_ROLE_ID",
|
||||
"fire_elemental": "REPLACE_WITH_DISCORD_ROLE_ID",
|
||||
"frost_elemental": "REPLACE_WITH_DISCORD_ROLE_ID",
|
||||
"fire_knight": "REPLACE_WITH_DISCORD_ROLE_ID",
|
||||
"frost_knight": "REPLACE_WITH_DISCORD_ROLE_ID",
|
||||
"fire_master": "REPLACE_WITH_DISCORD_ROLE_ID",
|
||||
"frost_master": "REPLACE_WITH_DISCORD_ROLE_ID",
|
||||
"fire_legend": "REPLACE_WITH_DISCORD_ROLE_ID",
|
||||
"frost_legend": "REPLACE_WITH_DISCORD_ROLE_ID",
|
||||
"sovereign": "REPLACE_WITH_DISCORD_ROLE_ID"
|
||||
}
|
||||
24
services/arbiter/deploy/arbiter.service
Normal file
24
services/arbiter/deploy/arbiter.service
Normal file
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Firefrost Arbiter - Discord Role Management System
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=architect
|
||||
WorkingDirectory=/home/architect/arbiter
|
||||
ExecStart=/usr/bin/node src/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
EnvironmentFile=/home/architect/arbiter/.env
|
||||
|
||||
# Security
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=arbiter
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
64
services/arbiter/nginx.conf
Normal file
64
services/arbiter/nginx.conf
Normal file
@@ -0,0 +1,64 @@
|
||||
# Firefrost Arbiter - Nginx Configuration
|
||||
# Location: /etc/nginx/sites-available/arbiter
|
||||
# Enable with: sudo ln -s /etc/nginx/sites-available/arbiter /etc/nginx/sites-enabled/
|
||||
# Then: sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
# HTTP -> HTTPS Redirect
|
||||
server {
|
||||
listen 80;
|
||||
server_name discord-bot.firefrostgaming.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS Server Block
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name discord-bot.firefrostgaming.com;
|
||||
|
||||
# SSL Configuration (Let's Encrypt)
|
||||
ssl_certificate /etc/letsencrypt/live/discord-bot.firefrostgaming.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/discord-bot.firefrostgaming.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# Security Headers
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Access Logs
|
||||
access_log /var/log/nginx/arbiter-access.log;
|
||||
error_log /var/log/nginx/arbiter-error.log;
|
||||
|
||||
# Proxy Configuration
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3500;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket Support (for Discord.js if needed)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
|
||||
# Proxy Headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Cache Control
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Health Check Endpoint (optional monitoring)
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:3500/health;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
31
services/arbiter/package.json
Normal file
31
services/arbiter/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "firefrost-arbiter",
|
||||
"version": "2.0.0",
|
||||
"description": "Discord Role Management and Subscription OAuth Gateway for Firefrost Gaming",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"keywords": ["discord", "oauth", "subscriptions", "ghost-cms", "webhook"],
|
||||
"author": "Firefrost Gaming",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@tryghost/admin-api": "^1.13.8",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"connect-sqlite3": "^0.9.15",
|
||||
"discord.js": "^14.14.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-session": "^1.18.0",
|
||||
"nodemailer": "^6.9.13",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
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>
|
||||
21
services/modpack-version-checker/LICENSE
Normal file
21
services/modpack-version-checker/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Firefrost Gaming
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
183
services/modpack-version-checker/create_all_files.sh
Executable file
183
services/modpack-version-checker/create_all_files.sh
Executable file
@@ -0,0 +1,183 @@
|
||||
#!/bin/bash
|
||||
# Auto-generated script to create all modpack-version-checker files
|
||||
|
||||
BASE_DIR="/home/claude/firefrost-operations-manual/docs/tasks/modpack-version-checker/code/modpack-version-checker"
|
||||
cd "$BASE_DIR"
|
||||
|
||||
# Already created: src/modpack_checker/__init__.py, src/modpack_checker/config.py
|
||||
|
||||
# Create database.py
|
||||
cat > src/modpack_checker/database.py << 'EOF'
|
||||
"""SQLite database layer using SQLAlchemy 2.0 ORM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, create_engine, select
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
|
||||
|
||||
|
||||
class _Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class _ModpackRow(_Base):
|
||||
__tablename__ = "modpacks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
curseforge_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
current_version: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
last_checked: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
notification_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
history: Mapped[List["_CheckHistoryRow"]] = relationship(
|
||||
back_populates="modpack", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class _CheckHistoryRow(_Base):
|
||||
__tablename__ = "check_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
modpack_id: Mapped[int] = mapped_column(ForeignKey("modpacks.id"), nullable=False)
|
||||
checked_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
version_found: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
notification_sent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
modpack: Mapped["_ModpackRow"] = relationship(back_populates="history")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Modpack:
|
||||
id: int
|
||||
curseforge_id: int
|
||||
name: str
|
||||
current_version: Optional[str]
|
||||
last_checked: Optional[datetime]
|
||||
notification_enabled: bool
|
||||
|
||||
@classmethod
|
||||
def _from_row(cls, row: _ModpackRow) -> "Modpack":
|
||||
return cls(
|
||||
id=row.id,
|
||||
curseforge_id=row.curseforge_id,
|
||||
name=row.name,
|
||||
current_version=row.current_version,
|
||||
last_checked=row.last_checked,
|
||||
notification_enabled=row.notification_enabled,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckHistory:
|
||||
id: int
|
||||
modpack_id: int
|
||||
checked_at: datetime
|
||||
version_found: Optional[str]
|
||||
notification_sent: bool
|
||||
|
||||
@classmethod
|
||||
def _from_row(cls, row: _CheckHistoryRow) -> "CheckHistory":
|
||||
return cls(
|
||||
id=row.id,
|
||||
modpack_id=row.modpack_id,
|
||||
checked_at=row.checked_at,
|
||||
version_found=row.version_found,
|
||||
notification_sent=row.notification_sent,
|
||||
)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_path: str) -> None:
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self.engine = create_engine(f"sqlite:///{db_path}", echo=False)
|
||||
_Base.metadata.create_all(self.engine)
|
||||
|
||||
def add_modpack(self, curseforge_id: int, name: str) -> Modpack:
|
||||
with Session(self.engine) as session:
|
||||
existing = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError(f"Modpack ID {curseforge_id} is already being tracked.")
|
||||
row = _ModpackRow(curseforge_id=curseforge_id, name=name, notification_enabled=True)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return Modpack._from_row(row)
|
||||
|
||||
def remove_modpack(self, curseforge_id: int) -> bool:
|
||||
with Session(self.engine) as session:
|
||||
row = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
def update_version(self, curseforge_id: int, version: str, notification_sent: bool = False) -> None:
|
||||
with Session(self.engine) as session:
|
||||
row = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
if row is None:
|
||||
raise ValueError(f"Modpack {curseforge_id} not found in database.")
|
||||
row.current_version = version
|
||||
row.last_checked = datetime.utcnow()
|
||||
session.add(
|
||||
_CheckHistoryRow(
|
||||
modpack_id=row.id,
|
||||
checked_at=datetime.utcnow(),
|
||||
version_found=version,
|
||||
notification_sent=notification_sent,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def toggle_notifications(self, curseforge_id: int, enabled: bool) -> bool:
|
||||
with Session(self.engine) as session:
|
||||
row = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
row.notification_enabled = enabled
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
def get_modpack(self, curseforge_id: int) -> Optional[Modpack]:
|
||||
with Session(self.engine) as session:
|
||||
row = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
return Modpack._from_row(row) if row else None
|
||||
|
||||
def get_all_modpacks(self) -> List[Modpack]:
|
||||
with Session(self.engine) as session:
|
||||
rows = session.scalars(select(_ModpackRow)).all()
|
||||
return [Modpack._from_row(r) for r in rows]
|
||||
|
||||
def get_check_history(self, curseforge_id: int, limit: int = 10) -> List[CheckHistory]:
|
||||
with Session(self.engine) as session:
|
||||
modpack_row = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
if modpack_row is None:
|
||||
return []
|
||||
rows = session.scalars(
|
||||
select(_CheckHistoryRow)
|
||||
.where(_CheckHistoryRow.modpack_id == modpack_row.id)
|
||||
.order_by(_CheckHistoryRow.checked_at.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
return [CheckHistory._from_row(r) for r in rows]
|
||||
EOF
|
||||
|
||||
echo "Created database.py"
|
||||
|
||||
228
services/modpack-version-checker/docs/API.md
Normal file
228
services/modpack-version-checker/docs/API.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# CLI Reference
|
||||
|
||||
Full reference for all `modpack-checker` commands and flags.
|
||||
|
||||
---
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--version` | Show version and exit |
|
||||
| `-h`, `--help` | Show help for any command |
|
||||
|
||||
---
|
||||
|
||||
## config
|
||||
|
||||
Manage configuration settings stored in `~/.config/modpack-checker/config.json`.
|
||||
|
||||
### config set-key
|
||||
|
||||
```
|
||||
modpack-checker config set-key API_KEY
|
||||
```
|
||||
|
||||
Save and validate a CurseForge API key. The key is tested against the API immediately; a warning is shown if validation fails but the key is still saved.
|
||||
|
||||
**Get a free key at:** https://console.curseforge.com
|
||||
|
||||
---
|
||||
|
||||
### config set-webhook
|
||||
|
||||
```
|
||||
modpack-checker config set-webhook WEBHOOK_URL
|
||||
```
|
||||
|
||||
Save a Discord webhook URL. A test embed is sent immediately to confirm the webhook works.
|
||||
|
||||
---
|
||||
|
||||
### config set-interval
|
||||
|
||||
```
|
||||
modpack-checker config set-interval HOURS
|
||||
```
|
||||
|
||||
Set how many hours the scheduler waits between checks. Accepts values 1–168.
|
||||
|
||||
**Default:** 6
|
||||
|
||||
---
|
||||
|
||||
### config show
|
||||
|
||||
```
|
||||
modpack-checker config show
|
||||
```
|
||||
|
||||
Display the current configuration. The API key is masked (first 4 / last 4 characters shown).
|
||||
|
||||
---
|
||||
|
||||
## add
|
||||
|
||||
```
|
||||
modpack-checker add MODPACK_ID
|
||||
```
|
||||
|
||||
Add a CurseForge modpack to the watch list. The modpack name is fetched from the API and stored locally. Run `check` afterward to record the initial version.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Type | Description |
|
||||
|---|---|---|
|
||||
| `MODPACK_ID` | int | CurseForge project ID (from the modpack's URL) |
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
modpack-checker add 238222 # All The Mods 9
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## remove
|
||||
|
||||
```
|
||||
modpack-checker remove MODPACK_ID
|
||||
```
|
||||
|
||||
Remove a modpack and all its check history. Prompts for confirmation.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Type | Description |
|
||||
|---|---|---|
|
||||
| `MODPACK_ID` | int | CurseForge project ID |
|
||||
|
||||
---
|
||||
|
||||
## list
|
||||
|
||||
```
|
||||
modpack-checker list
|
||||
```
|
||||
|
||||
Display all watched modpacks in a table showing:
|
||||
- CurseForge ID
|
||||
- Name
|
||||
- Last known version
|
||||
- Last check timestamp
|
||||
- Whether Discord alerts are enabled
|
||||
|
||||
---
|
||||
|
||||
## check
|
||||
|
||||
```
|
||||
modpack-checker check [OPTIONS]
|
||||
```
|
||||
|
||||
Check watched modpacks against the CurseForge API and report on their status.
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--id INTEGER`, `-m INTEGER` | Check only this modpack ID |
|
||||
| `--notify` / `--no-notify` | Send Discord notifications (default: on) |
|
||||
|
||||
**Output:**
|
||||
- `✓` — up to date
|
||||
- `↑` — update available (version shown)
|
||||
- `→` — initial version recorded (first check)
|
||||
- `✗` — API error
|
||||
|
||||
---
|
||||
|
||||
## status
|
||||
|
||||
```
|
||||
modpack-checker status MODPACK_ID [OPTIONS]
|
||||
```
|
||||
|
||||
Show a detailed panel for one modpack plus its check history.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Type | Description |
|
||||
|---|---|---|
|
||||
| `MODPACK_ID` | int | CurseForge project ID |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `--limit INTEGER`, `-n INTEGER` | 10 | Number of history entries to display |
|
||||
|
||||
---
|
||||
|
||||
## notifications
|
||||
|
||||
```
|
||||
modpack-checker notifications MODPACK_ID [--enable | --disable]
|
||||
```
|
||||
|
||||
Toggle Discord alerts for a specific modpack without removing it from the watch list.
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--enable` | Enable notifications (default) |
|
||||
| `--disable` | Suppress notifications for this modpack |
|
||||
|
||||
---
|
||||
|
||||
## schedule
|
||||
|
||||
```
|
||||
modpack-checker schedule [OPTIONS]
|
||||
```
|
||||
|
||||
Start a blocking background process that checks for updates on a repeating interval. Requires the `[scheduler]` extra (`pip install "modpack-version-checker[scheduler]"`).
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--hours INTEGER`, `-h INTEGER` | Override the configured interval |
|
||||
|
||||
Runs a check immediately on startup, then repeats at the configured interval. Press `Ctrl-C` to stop.
|
||||
|
||||
---
|
||||
|
||||
## Configuration File
|
||||
|
||||
Location: `~/.config/modpack-checker/config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"curseforge_api_key": "your-key-here",
|
||||
"discord_webhook_url": "https://discord.com/api/webhooks/...",
|
||||
"database_path": "/home/user/.config/modpack-checker/modpacks.db",
|
||||
"check_interval_hours": 6,
|
||||
"notification_on_update": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
Location: `~/.config/modpack-checker/modpacks.db` (SQLite)
|
||||
|
||||
The database is managed automatically. To reset completely:
|
||||
```bash
|
||||
rm ~/.config/modpack-checker/modpacks.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | Success |
|
||||
| `1` | Error (API failure, modpack not found, missing config, etc.) |
|
||||
166
services/modpack-version-checker/docs/INSTALLATION.md
Normal file
166
services/modpack-version-checker/docs/INSTALLATION.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Installation Guide
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9 or newer
|
||||
- pip (comes with Python)
|
||||
- A free CurseForge API key
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Install Python
|
||||
|
||||
Verify Python is installed:
|
||||
|
||||
```bash
|
||||
python3 --version
|
||||
# Should show: Python 3.9.x or newer
|
||||
```
|
||||
|
||||
If not installed, download from [python.org](https://www.python.org/downloads/).
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Install Modpack Version Checker
|
||||
|
||||
### Option A: pip (recommended)
|
||||
|
||||
```bash
|
||||
pip install modpack-version-checker
|
||||
```
|
||||
|
||||
### Option B: Install with scheduler support
|
||||
|
||||
```bash
|
||||
pip install "modpack-version-checker[scheduler]"
|
||||
```
|
||||
|
||||
This adds APScheduler for the `modpack-checker schedule` background daemon command.
|
||||
|
||||
### Option C: Install from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/firefrostgaming/modpack-version-checker.git
|
||||
cd modpack-version-checker
|
||||
pip install -e ".[scheduler]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Get a CurseForge API Key (free)
|
||||
|
||||
1. Go to [console.curseforge.com](https://console.curseforge.com)
|
||||
2. Log in or create a free account
|
||||
3. Click **Create API Key**
|
||||
4. Copy the key
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Configure
|
||||
|
||||
```bash
|
||||
modpack-checker config set-key YOUR_API_KEY_HERE
|
||||
```
|
||||
|
||||
The key is stored in `~/.config/modpack-checker/config.json`.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 (Optional) — Set Up Discord Notifications
|
||||
|
||||
1. In your Discord server, go to **Channel Settings → Integrations → Webhooks**
|
||||
2. Click **New Webhook** and copy the URL
|
||||
3. Run:
|
||||
|
||||
```bash
|
||||
modpack-checker config set-webhook https://discord.com/api/webhooks/YOUR_WEBHOOK_URL
|
||||
```
|
||||
|
||||
A test message will be sent to confirm it works.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Add Your First Modpack
|
||||
|
||||
Find your modpack's CurseForge project ID in the URL:
|
||||
`https://www.curseforge.com/minecraft/modpacks/all-the-mods-9` → go to the page, the ID is in the sidebar.
|
||||
|
||||
```bash
|
||||
modpack-checker add 238222 # All The Mods 9
|
||||
modpack-checker add 361392 # RLCraft
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Check for Updates
|
||||
|
||||
```bash
|
||||
modpack-checker check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Background Scheduler (Optional)
|
||||
|
||||
Run continuous checks automatically:
|
||||
|
||||
```bash
|
||||
# Check every 6 hours (default)
|
||||
modpack-checker schedule
|
||||
|
||||
# Check every 12 hours
|
||||
modpack-checker schedule --hours 12
|
||||
```
|
||||
|
||||
To run as a Linux systemd service, create `/etc/systemd/system/modpack-checker.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Modpack Version Checker
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=YOUR_USERNAME
|
||||
ExecStart=/usr/local/bin/modpack-checker schedule --hours 6
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Then:
|
||||
```bash
|
||||
sudo systemctl enable --now modpack-checker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
pip uninstall modpack-version-checker
|
||||
|
||||
# Remove config and database (optional)
|
||||
rm -rf ~/.config/modpack-checker/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`modpack-checker: command not found`**
|
||||
- Make sure pip's script directory is in your PATH
|
||||
- Try: `python3 -m modpack_checker.cli`
|
||||
|
||||
**`Invalid API key`**
|
||||
- Double-check the key at [console.curseforge.com](https://console.curseforge.com)
|
||||
- Ensure there are no extra spaces when setting it
|
||||
|
||||
**`Connection failed`**
|
||||
- Check your internet connection
|
||||
- CurseForge API may be temporarily down; try again in a few minutes
|
||||
|
||||
**`Modpack shows Unknown`**
|
||||
- Verify the project ID is correct by checking the CurseForge page
|
||||
- Some older modpacks have no files listed via the API
|
||||
91
services/modpack-version-checker/docs/README.md
Normal file
91
services/modpack-version-checker/docs/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Modpack Version Checker
|
||||
|
||||
**Monitor CurseForge modpack versions and get instantly notified when updates are released.**
|
||||
|
||||
Stop manually checking CurseForge every day. Modpack Version Checker tracks your modpacks and fires a Discord alert the moment a new version drops — saving you 20+ minutes of daily maintenance.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-modpack tracking** — watch as many packs as you need in a single database
|
||||
- **Discord notifications** — rich embeds with old/new version info sent automatically
|
||||
- **Version history** — full log of every check and what version was found
|
||||
- **Per-modpack notification control** — silence specific packs without removing them
|
||||
- **Built-in scheduler** — runs in the background and checks on a configurable interval
|
||||
- **Manual override** — force a check any time with `modpack-checker check`
|
||||
- **Graceful error handling** — API downtime shows clear messages, never crashes
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Install
|
||||
pip install modpack-version-checker
|
||||
|
||||
# 2. Set your CurseForge API key (free at console.curseforge.com)
|
||||
modpack-checker config set-key YOUR_API_KEY
|
||||
|
||||
# 3. Add a modpack (use its CurseForge project ID)
|
||||
modpack-checker add 238222 # All The Mods 9
|
||||
|
||||
# 4. Check for updates
|
||||
modpack-checker check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for full setup instructions including optional Discord notifications and background scheduling.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `modpack-checker add <id>` | Add a modpack to the watch list |
|
||||
| `modpack-checker remove <id>` | Remove a modpack from the watch list |
|
||||
| `modpack-checker list` | Show all watched modpacks and versions |
|
||||
| `modpack-checker check` | Check all modpacks for updates now |
|
||||
| `modpack-checker check --id <id>` | Check a single modpack |
|
||||
| `modpack-checker status <id>` | Show detailed info + check history |
|
||||
| `modpack-checker notifications <id> --enable/--disable` | Toggle alerts per modpack |
|
||||
| `modpack-checker schedule` | Start background scheduler |
|
||||
| `modpack-checker config set-key <key>` | Save CurseForge API key |
|
||||
| `modpack-checker config set-webhook <url>` | Save Discord webhook URL |
|
||||
| `modpack-checker config set-interval <hours>` | Set check interval |
|
||||
| `modpack-checker config show` | Display current configuration |
|
||||
|
||||
See [API.md](API.md) for full command reference with all flags.
|
||||
|
||||
---
|
||||
|
||||
## Pricing
|
||||
|
||||
| Tier | Price | Features |
|
||||
|---|---|---|
|
||||
| Standard | $9.99 | All features listed above |
|
||||
|
||||
One-time purchase. No subscriptions. Available on [BuiltByBit](https://builtbybit.com).
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9 or newer
|
||||
- A free CurseForge API key ([get one here](https://console.curseforge.com))
|
||||
- Linux, macOS, or Windows
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- Discord: [Firefrost Gaming Support Server]
|
||||
- Response time: within 48 hours
|
||||
|
||||
---
|
||||
|
||||
*Built by Firefrost Gaming*
|
||||
16
services/modpack-version-checker/requirements.txt
Normal file
16
services/modpack-version-checker/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
# Core runtime dependencies
|
||||
requests>=2.28.0
|
||||
click>=8.1.0
|
||||
rich>=13.0.0
|
||||
pydantic>=2.0.0
|
||||
sqlalchemy>=2.0.0
|
||||
|
||||
# Optional: background scheduler
|
||||
# apscheduler>=3.10.0
|
||||
|
||||
# Development / testing
|
||||
# pytest>=7.0.0
|
||||
# pytest-cov>=4.0.0
|
||||
# pytest-mock>=3.10.0
|
||||
# responses>=0.23.0
|
||||
# black>=23.0.0
|
||||
15
services/modpack-version-checker/setup.cfg
Normal file
15
services/modpack-version-checker/setup.cfg
Normal file
@@ -0,0 +1,15 @@
|
||||
[tool:pytest]
|
||||
testpaths = tests
|
||||
pythonpath = src
|
||||
addopts = --tb=short -q
|
||||
|
||||
[coverage:run]
|
||||
source = modpack_checker
|
||||
|
||||
[coverage:report]
|
||||
show_missing = True
|
||||
skip_covered = False
|
||||
|
||||
[flake8]
|
||||
max-line-length = 100
|
||||
exclude = .git,__pycache__,build,dist
|
||||
61
services/modpack-version-checker/setup.py
Normal file
61
services/modpack-version-checker/setup.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Package setup for Modpack Version Checker."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
long_description = (Path(__file__).parent / "docs" / "README.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
setup(
|
||||
name="modpack-version-checker",
|
||||
version="1.0.0",
|
||||
author="Firefrost Gaming",
|
||||
author_email="support@firefrostgaming.com",
|
||||
description="Monitor CurseForge modpack versions and get notified of updates",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://firefrostgaming.com",
|
||||
license="MIT",
|
||||
packages=find_packages(where="src"),
|
||||
package_dir={"": "src"},
|
||||
python_requires=">=3.9",
|
||||
install_requires=[
|
||||
"requests>=2.28.0",
|
||||
"click>=8.1.0",
|
||||
"rich>=13.0.0",
|
||||
"pydantic>=2.0.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
],
|
||||
extras_require={
|
||||
"scheduler": ["apscheduler>=3.10.0"],
|
||||
"dev": [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"pytest-mock>=3.10.0",
|
||||
"responses>=0.23.0",
|
||||
"black>=23.0.0",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"modpack-checker=modpack_checker.cli:main",
|
||||
],
|
||||
},
|
||||
classifiers=[
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Games/Entertainment",
|
||||
"Topic :: System :: Monitoring",
|
||||
],
|
||||
keywords="minecraft modpack curseforge version checker monitor",
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Modpack Version Checker - Monitor CurseForge modpack updates."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "Firefrost Gaming"
|
||||
565
services/modpack-version-checker/src/modpack_checker/cli.py
Normal file
565
services/modpack-version-checker/src/modpack_checker/cli.py
Normal file
@@ -0,0 +1,565 @@
|
||||
"""Click-based CLI — all user-facing commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from . import __version__
|
||||
from .config import Config
|
||||
from .curseforge import (
|
||||
CurseForgeAuthError,
|
||||
CurseForgeClient,
|
||||
CurseForgeError,
|
||||
CurseForgeNotFoundError,
|
||||
)
|
||||
from .database import Database
|
||||
from .notifier import DiscordNotifier, NotificationError
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_db(cfg: Config) -> Database:
|
||||
return Database(cfg.database_path)
|
||||
|
||||
|
||||
def _require_client(cfg: Config) -> CurseForgeClient:
|
||||
"""Return a configured API client, or exit with a helpful message."""
|
||||
if not cfg.curseforge_api_key:
|
||||
console.print("[red]Error:[/red] No CurseForge API key configured.")
|
||||
console.print(
|
||||
"Set one with: [bold]modpack-checker config set-key YOUR_KEY[/bold]"
|
||||
)
|
||||
console.print(
|
||||
"Get a free key at: [dim]https://console.curseforge.com[/dim]"
|
||||
)
|
||||
sys.exit(1)
|
||||
return CurseForgeClient(cfg.curseforge_api_key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Root group
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
||||
@click.version_option(version=__version__, prog_name="modpack-checker")
|
||||
def cli() -> None:
|
||||
"""Modpack Version Checker — monitor CurseForge modpacks for updates.
|
||||
|
||||
\b
|
||||
Quick start:
|
||||
modpack-checker config set-key YOUR_CURSEFORGE_KEY
|
||||
modpack-checker add 238222
|
||||
modpack-checker check
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config sub-group
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cli.group("config")
|
||||
def config_group() -> None:
|
||||
"""Manage configuration (API key, webhook URL, etc.)."""
|
||||
|
||||
|
||||
@config_group.command("set-key")
|
||||
@click.argument("api_key")
|
||||
def config_set_key(api_key: str) -> None:
|
||||
"""Save your CurseForge API key and validate it."""
|
||||
cfg = Config.load()
|
||||
cfg.curseforge_api_key = api_key.strip()
|
||||
cfg.save()
|
||||
|
||||
client = CurseForgeClient(api_key)
|
||||
with console.status("Validating API key with CurseForge…"):
|
||||
valid = client.validate_api_key()
|
||||
|
||||
if valid:
|
||||
console.print("[green]✓[/green] API key saved and validated.")
|
||||
else:
|
||||
console.print(
|
||||
"[yellow]⚠[/yellow] API key saved, but validation failed. "
|
||||
"Double-check at [dim]https://console.curseforge.com[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@config_group.command("set-webhook")
|
||||
@click.argument("webhook_url")
|
||||
def config_set_webhook(webhook_url: str) -> None:
|
||||
"""Save a Discord webhook URL and send a test message."""
|
||||
cfg = Config.load()
|
||||
cfg.discord_webhook_url = webhook_url.strip()
|
||||
cfg.save()
|
||||
|
||||
notifier = DiscordNotifier(webhook_url)
|
||||
try:
|
||||
with console.status("Testing webhook…"):
|
||||
notifier.test()
|
||||
console.print("[green]✓[/green] Webhook saved and test message sent.")
|
||||
except NotificationError as exc:
|
||||
console.print(f"[yellow]⚠[/yellow] Webhook saved, but test failed: {exc}")
|
||||
|
||||
|
||||
@config_group.command("set-interval")
|
||||
@click.argument("hours", type=int)
|
||||
def config_set_interval(hours: int) -> None:
|
||||
"""Set how often the scheduler checks for updates (in hours, 1–168)."""
|
||||
if not 1 <= hours <= 168:
|
||||
console.print("[red]Error:[/red] Interval must be between 1 and 168 hours.")
|
||||
sys.exit(1)
|
||||
cfg = Config.load()
|
||||
cfg.check_interval_hours = hours
|
||||
cfg.save()
|
||||
console.print(f"[green]✓[/green] Check interval set to {hours} hour(s).")
|
||||
|
||||
|
||||
@config_group.command("show")
|
||||
def config_show() -> None:
|
||||
"""Display the current configuration."""
|
||||
cfg = Config.load()
|
||||
|
||||
key = cfg.curseforge_api_key
|
||||
if key and len(key) > 8:
|
||||
masked = f"{key[:4]}{'*' * (len(key) - 8)}{key[-4:]}"
|
||||
elif key:
|
||||
masked = "****"
|
||||
else:
|
||||
masked = "[red]Not configured[/red]"
|
||||
|
||||
table = Table(title="Configuration", box=box.ROUNDED, show_header=False)
|
||||
table.add_column("Setting", style="cyan", min_width=22)
|
||||
table.add_column("Value")
|
||||
|
||||
table.add_row("CurseForge API Key", masked)
|
||||
table.add_row(
|
||||
"Discord Webhook",
|
||||
cfg.discord_webhook_url or "[dim]Not configured[/dim]",
|
||||
)
|
||||
table.add_row("Database", cfg.database_path)
|
||||
table.add_row("Check Interval", f"{cfg.check_interval_hours} hour(s)")
|
||||
table.add_row(
|
||||
"Notifications",
|
||||
"[green]On[/green]" if cfg.notification_on_update else "[red]Off[/red]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("modpack_id", type=int)
|
||||
def add(modpack_id: int) -> None:
|
||||
"""Add a modpack to the watch list by its CurseForge project ID.
|
||||
|
||||
\b
|
||||
Example:
|
||||
modpack-checker add 238222 # All The Mods 9
|
||||
"""
|
||||
cfg = Config.load()
|
||||
client = _require_client(cfg)
|
||||
db = _load_db(cfg)
|
||||
|
||||
with console.status(f"Looking up modpack {modpack_id} on CurseForge…"):
|
||||
try:
|
||||
name = client.get_mod_name(modpack_id)
|
||||
except CurseForgeNotFoundError:
|
||||
console.print(
|
||||
f"[red]Error:[/red] No modpack found with ID {modpack_id} on CurseForge."
|
||||
)
|
||||
sys.exit(1)
|
||||
except CurseForgeAuthError as exc:
|
||||
console.print(f"[red]Auth error:[/red] {exc}")
|
||||
sys.exit(1)
|
||||
except CurseForgeError as exc:
|
||||
console.print(f"[red]API error:[/red] {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
db.add_modpack(modpack_id, name)
|
||||
except ValueError:
|
||||
console.print(
|
||||
f"[yellow]Already tracked:[/yellow] [bold]{name}[/bold] (ID: {modpack_id})"
|
||||
)
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Now tracking [bold]{name}[/bold] (ID: {modpack_id})"
|
||||
)
|
||||
console.print("Run [bold]modpack-checker check[/bold] to fetch the current version.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("modpack_id", type=int)
|
||||
@click.confirmation_option(prompt="Remove this modpack and all its history?")
|
||||
def remove(modpack_id: int) -> None:
|
||||
"""Remove a modpack from the watch list."""
|
||||
cfg = Config.load()
|
||||
db = _load_db(cfg)
|
||||
|
||||
modpack = db.get_modpack(modpack_id)
|
||||
if modpack is None:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
db.remove_modpack(modpack_id)
|
||||
console.print(f"[green]✓[/green] Removed [bold]{modpack.name}[/bold].")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cli.command(name="list")
|
||||
def list_modpacks() -> None:
|
||||
"""Show all watched modpacks and their last known versions."""
|
||||
cfg = Config.load()
|
||||
db = _load_db(cfg)
|
||||
modpacks = db.get_all_modpacks()
|
||||
|
||||
if not modpacks:
|
||||
console.print("[dim]Watch list is empty.[/dim]")
|
||||
console.print(
|
||||
"Add a modpack with: [bold]modpack-checker add <curseforge-id>[/bold]"
|
||||
)
|
||||
return
|
||||
|
||||
table = Table(
|
||||
title=f"Watched Modpacks ({len(modpacks)})",
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
table.add_column("CF ID", style="cyan", justify="right", no_wrap=True)
|
||||
table.add_column("Name", style="bold white")
|
||||
table.add_column("Current Version", style="green")
|
||||
table.add_column("Last Checked", style="dim")
|
||||
table.add_column("Alerts", justify="center")
|
||||
|
||||
for mp in modpacks:
|
||||
last_checked = (
|
||||
mp.last_checked.strftime("%Y-%m-%d %H:%M")
|
||||
if mp.last_checked
|
||||
else "[dim]Never[/dim]"
|
||||
)
|
||||
alerts = "[green]✓[/green]" if mp.notification_enabled else "[red]✗[/red]"
|
||||
version = mp.current_version or "[dim]—[/dim]"
|
||||
table.add_row(str(mp.curseforge_id), mp.name, version, last_checked, alerts)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--id", "-m", "modpack_id", type=int, default=None,
|
||||
help="Check a single modpack by CurseForge ID.",
|
||||
)
|
||||
@click.option(
|
||||
"--notify/--no-notify", default=True,
|
||||
help="Send Discord notifications for updates found (default: on).",
|
||||
)
|
||||
def check(modpack_id: Optional[int], notify: bool) -> None:
|
||||
"""Check all (or one) watched modpack(s) for updates."""
|
||||
cfg = Config.load()
|
||||
client = _require_client(cfg)
|
||||
db = _load_db(cfg)
|
||||
|
||||
if modpack_id is not None:
|
||||
target = db.get_modpack(modpack_id)
|
||||
if target is None:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
|
||||
)
|
||||
sys.exit(1)
|
||||
modpacks = [target]
|
||||
else:
|
||||
modpacks = db.get_all_modpacks()
|
||||
|
||||
if not modpacks:
|
||||
console.print("[dim]No modpacks to check.[/dim]")
|
||||
console.print(
|
||||
"Add one with: [bold]modpack-checker add <curseforge-id>[/bold]"
|
||||
)
|
||||
return
|
||||
|
||||
notifier: Optional[DiscordNotifier] = None
|
||||
if notify and cfg.discord_webhook_url and cfg.notification_on_update:
|
||||
notifier = DiscordNotifier(cfg.discord_webhook_url)
|
||||
|
||||
updates = 0
|
||||
errors = 0
|
||||
|
||||
for mp in modpacks:
|
||||
with console.status(f"Checking [bold]{mp.name}[/bold]…"):
|
||||
try:
|
||||
file_obj = client.get_latest_file(mp.curseforge_id)
|
||||
except CurseForgeNotFoundError:
|
||||
console.print(
|
||||
f" [red]✗[/red] {mp.name}: not found on CurseForge "
|
||||
f"(ID: {mp.curseforge_id})"
|
||||
)
|
||||
errors += 1
|
||||
continue
|
||||
except CurseForgeError as exc:
|
||||
console.print(f" [red]✗[/red] {mp.name}: API error — {exc}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if file_obj is None:
|
||||
console.print(f" [yellow]⚠[/yellow] {mp.name}: no files found on CurseForge.")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
new_version = client.extract_version(file_obj)
|
||||
notification_sent = False
|
||||
|
||||
if mp.current_version == new_version:
|
||||
line = f"[green]✓[/green] {mp.name}: up to date ([bold]{new_version}[/bold])"
|
||||
elif mp.current_version is None:
|
||||
line = (
|
||||
f"[cyan]→[/cyan] {mp.name}: "
|
||||
f"initial version recorded as [bold]{new_version}[/bold]"
|
||||
)
|
||||
else:
|
||||
updates += 1
|
||||
line = (
|
||||
f"[yellow]↑[/yellow] {mp.name}: "
|
||||
f"[dim]{mp.current_version}[/dim] → [bold green]{new_version}[/bold green]"
|
||||
)
|
||||
if notifier and mp.notification_enabled:
|
||||
try:
|
||||
notifier.send_update(
|
||||
mp.name, mp.curseforge_id, mp.current_version, new_version
|
||||
)
|
||||
notification_sent = True
|
||||
line += " [dim](notified)[/dim]"
|
||||
except NotificationError as exc:
|
||||
line += f" [red](notification failed: {exc})[/red]"
|
||||
|
||||
db.update_version(mp.curseforge_id, new_version, notification_sent)
|
||||
console.print(f" {line}")
|
||||
|
||||
# Summary line
|
||||
console.print()
|
||||
parts = []
|
||||
if updates:
|
||||
parts.append(f"[yellow]{updates} update(s) found[/yellow]")
|
||||
if errors:
|
||||
parts.append(f"[red]{errors} error(s)[/red]")
|
||||
if not updates and not errors:
|
||||
parts.append("[green]All modpacks are up to date.[/green]")
|
||||
console.print(" ".join(parts))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("modpack_id", type=int)
|
||||
@click.option("--limit", "-n", default=10, show_default=True, help="History entries to show.")
|
||||
def status(modpack_id: int, limit: int) -> None:
|
||||
"""Show detailed status and check history for a modpack."""
|
||||
cfg = Config.load()
|
||||
db = _load_db(cfg)
|
||||
|
||||
mp = db.get_modpack(modpack_id)
|
||||
if mp is None:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
last_checked = (
|
||||
mp.last_checked.strftime("%Y-%m-%d %H:%M UTC") if mp.last_checked else "Never"
|
||||
)
|
||||
notif_str = "[green]Enabled[/green]" if mp.notification_enabled else "[red]Disabled[/red]"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold white]{mp.name}[/bold white]\n"
|
||||
f"CurseForge ID : [cyan]{mp.curseforge_id}[/cyan]\n"
|
||||
f"Version : [green]{mp.current_version or 'Not checked yet'}[/green]\n"
|
||||
f"Last Checked : [dim]{last_checked}[/dim]\n"
|
||||
f"Notifications : {notif_str}",
|
||||
title="Modpack Status",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
history = db.get_check_history(modpack_id, limit=limit)
|
||||
if not history:
|
||||
console.print("[dim]No check history yet.[/dim]")
|
||||
return
|
||||
|
||||
table = Table(title=f"Check History (last {limit})", box=box.SIMPLE)
|
||||
table.add_column("Timestamp", style="dim")
|
||||
table.add_column("Version", style="green")
|
||||
table.add_column("Notified", justify="center")
|
||||
|
||||
for entry in history:
|
||||
notified = "[green]✓[/green]" if entry.notification_sent else "[dim]—[/dim]"
|
||||
table.add_row(
|
||||
entry.checked_at.strftime("%Y-%m-%d %H:%M"),
|
||||
entry.version_found or "[dim]Unknown[/dim]",
|
||||
notified,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notifications (toggle per-modpack alerts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("modpack_id", type=int)
|
||||
@click.option(
|
||||
"--enable/--disable",
|
||||
default=True,
|
||||
help="Enable or disable Discord alerts for this modpack.",
|
||||
)
|
||||
def notifications(modpack_id: int, enable: bool) -> None:
|
||||
"""Enable or disable Discord notifications for a specific modpack."""
|
||||
cfg = Config.load()
|
||||
db = _load_db(cfg)
|
||||
|
||||
mp = db.get_modpack(modpack_id)
|
||||
if mp is None:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
db.toggle_notifications(modpack_id, enable)
|
||||
state = "[green]enabled[/green]" if enable else "[red]disabled[/red]"
|
||||
console.print(
|
||||
f"[green]✓[/green] Notifications {state} for [bold]{mp.name}[/bold]."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# schedule (background daemon)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
"--hours", "-h", "hours", type=int, default=None,
|
||||
help="Override the configured check interval (hours).",
|
||||
)
|
||||
def schedule(hours: Optional[int]) -> None:
|
||||
"""Run continuous background checks on a configurable interval.
|
||||
|
||||
Requires the [scheduler] extra: pip install modpack-version-checker[scheduler]
|
||||
"""
|
||||
try:
|
||||
from apscheduler.schedulers.blocking import BlockingScheduler
|
||||
except ImportError:
|
||||
console.print("[red]Error:[/red] APScheduler is not installed.")
|
||||
console.print(
|
||||
"Install it with: [bold]pip install modpack-version-checker[scheduler][/bold]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
cfg = Config.load()
|
||||
interval = hours or cfg.check_interval_hours
|
||||
|
||||
def _run_check() -> None:
|
||||
"""Inner function executed by the scheduler."""
|
||||
client = _require_client(cfg)
|
||||
db = _load_db(cfg)
|
||||
modpacks = db.get_all_modpacks()
|
||||
|
||||
notifier: Optional[DiscordNotifier] = None
|
||||
if cfg.discord_webhook_url and cfg.notification_on_update:
|
||||
notifier = DiscordNotifier(cfg.discord_webhook_url)
|
||||
|
||||
for mp in modpacks:
|
||||
try:
|
||||
file_obj = client.get_latest_file(mp.curseforge_id)
|
||||
if file_obj is None:
|
||||
continue
|
||||
new_version = client.extract_version(file_obj)
|
||||
notification_sent = False
|
||||
|
||||
if (
|
||||
mp.current_version is not None
|
||||
and mp.current_version != new_version
|
||||
and notifier
|
||||
and mp.notification_enabled
|
||||
):
|
||||
try:
|
||||
notifier.send_update(
|
||||
mp.name, mp.curseforge_id, mp.current_version, new_version
|
||||
)
|
||||
notification_sent = True
|
||||
except NotificationError:
|
||||
pass
|
||||
|
||||
db.update_version(mp.curseforge_id, new_version, notification_sent)
|
||||
except CurseForgeError:
|
||||
pass # Log silently in daemon mode; don't crash the scheduler
|
||||
|
||||
scheduler = BlockingScheduler()
|
||||
scheduler.add_job(_run_check, "interval", hours=interval)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"Checking every [bold]{interval}[/bold] hour(s).\n"
|
||||
"Press [bold]Ctrl-C[/bold] to stop.",
|
||||
title="Modpack Checker — Scheduler Running",
|
||||
border_style="green",
|
||||
)
|
||||
)
|
||||
|
||||
# Run immediately so the user gets instant feedback
|
||||
_run_check()
|
||||
|
||||
try:
|
||||
scheduler.start()
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Scheduler stopped.[/dim]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Configuration management for Modpack Version Checker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
CONFIG_DIR = Path.home() / ".config" / "modpack-checker"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
DEFAULT_DB_PATH = str(CONFIG_DIR / "modpacks.db")
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
"""Application configuration, persisted to ~/.config/modpack-checker/config.json."""
|
||||
|
||||
curseforge_api_key: str = ""
|
||||
discord_webhook_url: Optional[str] = None
|
||||
database_path: str = DEFAULT_DB_PATH
|
||||
check_interval_hours: int = Field(default=6, ge=1, le=168)
|
||||
notification_on_update: bool = True
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "Config":
|
||||
"""Load configuration from disk, returning defaults if not present."""
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(CONFIG_FILE) as f:
|
||||
data = json.load(f)
|
||||
return cls(**data)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Corrupted config — fall back to defaults silently
|
||||
return cls()
|
||||
return cls()
|
||||
|
||||
def save(self) -> None:
|
||||
"""Persist configuration to disk."""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
json.dump(self.model_dump(), f, indent=2)
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Return True if the minimum required config (API key) is present."""
|
||||
return bool(self.curseforge_api_key)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""CurseForge API v1 client with error handling and retry logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CurseForgeError(Exception):
|
||||
"""Base exception for all CurseForge API errors."""
|
||||
|
||||
|
||||
class CurseForgeAuthError(CurseForgeError):
|
||||
"""API key is missing, invalid, or lacks required permissions."""
|
||||
|
||||
|
||||
class CurseForgeNotFoundError(CurseForgeError):
|
||||
"""The requested modpack or file does not exist."""
|
||||
|
||||
|
||||
class CurseForgeRateLimitError(CurseForgeError):
|
||||
"""The API rate limit has been exceeded (429)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CurseForgeClient:
|
||||
"""Thin wrapper around the CurseForge REST API v1.
|
||||
|
||||
Handles authentication, retries on transient errors, and translates
|
||||
HTTP status codes into typed exceptions so callers never see raw
|
||||
requests exceptions.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://api.curseforge.com"
|
||||
_MINECRAFT_GAME_ID = 432 # used for API key validation
|
||||
|
||||
def __init__(self, api_key: str, timeout: int = 15) -> None:
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(
|
||||
{
|
||||
"x-api-key": api_key,
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "ModpackVersionChecker/1.0 (Firefrost Gaming)",
|
||||
}
|
||||
)
|
||||
|
||||
# Retry on connection errors and server-side 5xx — never on 4xx
|
||||
retry = Retry(
|
||||
total=3,
|
||||
backoff_factor=1.0,
|
||||
status_forcelist=[500, 502, 503, 504],
|
||||
allowed_methods=["GET"],
|
||||
raise_on_status=False,
|
||||
)
|
||||
self._session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
||||
"""Perform a GET request and return parsed JSON.
|
||||
|
||||
Raises:
|
||||
CurseForgeAuthError: on 401 / 403
|
||||
CurseForgeNotFoundError: on 404
|
||||
CurseForgeRateLimitError: on 429
|
||||
CurseForgeError: on connection failure, timeout, or other HTTP error
|
||||
"""
|
||||
url = f"{self.BASE_URL}{path}"
|
||||
try:
|
||||
response = self._session.get(url, params=params, timeout=self.timeout)
|
||||
except requests.ConnectionError as exc:
|
||||
raise CurseForgeError(
|
||||
f"Could not connect to CurseForge API. Check your internet connection."
|
||||
) from exc
|
||||
except requests.Timeout:
|
||||
raise CurseForgeError(
|
||||
f"CurseForge API timed out after {self.timeout}s. Try again later."
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise CurseForgeAuthError(
|
||||
"Invalid API key. Verify your key at https://console.curseforge.com"
|
||||
)
|
||||
if response.status_code == 403:
|
||||
raise CurseForgeAuthError(
|
||||
"API key lacks permission for this resource. "
|
||||
"Ensure your key has 'Mods' read access."
|
||||
)
|
||||
if response.status_code == 404:
|
||||
raise CurseForgeNotFoundError(f"Resource not found: {path}")
|
||||
if response.status_code == 429:
|
||||
raise CurseForgeRateLimitError(
|
||||
"CurseForge rate limit exceeded (100 req/min). "
|
||||
"Wait a moment and try again."
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
raise CurseForgeError(
|
||||
f"Unexpected API error ({response.status_code}): {response.text[:200]}"
|
||||
) from exc
|
||||
|
||||
return response.json()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def validate_api_key(self) -> bool:
|
||||
"""Return True if the current API key is valid."""
|
||||
try:
|
||||
self._get(f"/v1/games/{self._MINECRAFT_GAME_ID}")
|
||||
return True
|
||||
except (CurseForgeAuthError, CurseForgeError):
|
||||
return False
|
||||
|
||||
def get_mod(self, mod_id: int) -> Dict[str, Any]:
|
||||
"""Fetch full modpack metadata by CurseForge project ID.
|
||||
|
||||
Raises:
|
||||
CurseForgeNotFoundError: if the project ID does not exist.
|
||||
"""
|
||||
data = self._get(f"/v1/mods/{mod_id}")
|
||||
return data["data"]
|
||||
|
||||
def get_mod_name(self, mod_id: int) -> str:
|
||||
"""Return the display name for a modpack."""
|
||||
mod = self.get_mod(mod_id)
|
||||
return mod.get("name", f"Modpack {mod_id}")
|
||||
|
||||
def get_latest_file(self, mod_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Return the most recent file object for a modpack.
|
||||
|
||||
Prefers the ``latestFiles`` field on the mod object (single request).
|
||||
Falls back to querying the files endpoint if that is empty.
|
||||
|
||||
Returns None if no files are found.
|
||||
"""
|
||||
mod = self.get_mod(mod_id)
|
||||
|
||||
# Fast path: latestFiles is populated by CurseForge automatically
|
||||
latest_files = mod.get("latestFiles", [])
|
||||
if latest_files:
|
||||
# Sort by fileDate descending to get the newest release
|
||||
latest_files_sorted = sorted(
|
||||
latest_files,
|
||||
key=lambda f: f.get("fileDate", ""),
|
||||
reverse=True,
|
||||
)
|
||||
return latest_files_sorted[0]
|
||||
|
||||
# Slow path: query the files endpoint
|
||||
data = self._get(
|
||||
f"/v1/mods/{mod_id}/files",
|
||||
params={"pageSize": 1, "sortOrder": "desc"},
|
||||
)
|
||||
files = data.get("data", [])
|
||||
return files[0] if files else None
|
||||
|
||||
def extract_version(self, file_obj: Dict[str, Any]) -> str:
|
||||
"""Extract a human-readable version string from a CurseForge file object.
|
||||
|
||||
Priority: displayName → fileName → file ID fallback.
|
||||
"""
|
||||
display_name = (file_obj.get("displayName") or "").strip()
|
||||
if display_name:
|
||||
return display_name
|
||||
|
||||
file_name = (file_obj.get("fileName") or "").strip()
|
||||
if file_name:
|
||||
return file_name
|
||||
|
||||
return f"File ID {file_obj.get('id', 'unknown')}"
|
||||
225
services/modpack-version-checker/src/modpack_checker/database.py
Normal file
225
services/modpack-version-checker/src/modpack_checker/database.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""SQLite database layer using SQLAlchemy 2.0 ORM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, create_engine, select
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORM models (internal — not exposed outside this module)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class _ModpackRow(_Base):
|
||||
__tablename__ = "modpacks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
curseforge_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
current_version: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
last_checked: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
notification_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
history: Mapped[List["_CheckHistoryRow"]] = relationship(
|
||||
back_populates="modpack", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class _CheckHistoryRow(_Base):
|
||||
__tablename__ = "check_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
modpack_id: Mapped[int] = mapped_column(ForeignKey("modpacks.id"), nullable=False)
|
||||
checked_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
version_found: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
notification_sent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
modpack: Mapped["_ModpackRow"] = relationship(back_populates="history")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public dataclasses (detached, safe to use outside a session)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Modpack:
|
||||
"""Read-only snapshot of a tracked modpack."""
|
||||
|
||||
id: int
|
||||
curseforge_id: int
|
||||
name: str
|
||||
current_version: Optional[str]
|
||||
last_checked: Optional[datetime]
|
||||
notification_enabled: bool
|
||||
|
||||
@classmethod
|
||||
def _from_row(cls, row: _ModpackRow) -> "Modpack":
|
||||
return cls(
|
||||
id=row.id,
|
||||
curseforge_id=row.curseforge_id,
|
||||
name=row.name,
|
||||
current_version=row.current_version,
|
||||
last_checked=row.last_checked,
|
||||
notification_enabled=row.notification_enabled,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckHistory:
|
||||
"""Read-only snapshot of a single version-check record."""
|
||||
|
||||
id: int
|
||||
modpack_id: int
|
||||
checked_at: datetime
|
||||
version_found: Optional[str]
|
||||
notification_sent: bool
|
||||
|
||||
@classmethod
|
||||
def _from_row(cls, row: _CheckHistoryRow) -> "CheckHistory":
|
||||
return cls(
|
||||
id=row.id,
|
||||
modpack_id=row.modpack_id,
|
||||
checked_at=row.checked_at,
|
||||
version_found=row.version_found,
|
||||
notification_sent=row.notification_sent,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database façade
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Database:
|
||||
"""All database operations for the modpack tracker."""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self.engine = create_engine(f"sqlite:///{db_path}", echo=False)
|
||||
_Base.metadata.create_all(self.engine)
|
||||
|
||||
# --- write operations ---------------------------------------------------
|
||||
|
||||
def add_modpack(self, curseforge_id: int, name: str) -> Modpack:
|
||||
"""Add a new modpack to the watch list.
|
||||
|
||||
Raises:
|
||||
ValueError: if the modpack is already being tracked.
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
existing = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError(
|
||||
f"Modpack ID {curseforge_id} is already being tracked."
|
||||
)
|
||||
row = _ModpackRow(
|
||||
curseforge_id=curseforge_id,
|
||||
name=name,
|
||||
notification_enabled=True,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return Modpack._from_row(row)
|
||||
|
||||
def remove_modpack(self, curseforge_id: int) -> bool:
|
||||
"""Remove a modpack and its history. Returns False if not found."""
|
||||
with Session(self.engine) as session:
|
||||
row = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
def update_version(
|
||||
self,
|
||||
curseforge_id: int,
|
||||
version: str,
|
||||
notification_sent: bool = False,
|
||||
) -> None:
|
||||
"""Record a new version check result, updating the cached version.
|
||||
|
||||
Raises:
|
||||
ValueError: if the modpack is not in the database.
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
row = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
if row is None:
|
||||
raise ValueError(f"Modpack {curseforge_id} not found in database.")
|
||||
row.current_version = version
|
||||
row.last_checked = datetime.utcnow()
|
||||
session.add(
|
||||
_CheckHistoryRow(
|
||||
modpack_id=row.id,
|
||||
checked_at=datetime.utcnow(),
|
||||
version_found=version,
|
||||
notification_sent=notification_sent,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def toggle_notifications(self, curseforge_id: int, enabled: bool) -> bool:
|
||||
"""Enable or disable Discord notifications for a modpack.
|
||||
|
||||
Returns False if modpack not found.
|
||||
"""
|
||||
with Session(self.engine) as session:
|
||||
row = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
row.notification_enabled = enabled
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
# --- read operations ----------------------------------------------------
|
||||
|
||||
def get_modpack(self, curseforge_id: int) -> Optional[Modpack]:
|
||||
"""Return a single modpack, or None if not tracked."""
|
||||
with Session(self.engine) as session:
|
||||
row = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
return Modpack._from_row(row) if row else None
|
||||
|
||||
def get_all_modpacks(self) -> List[Modpack]:
|
||||
"""Return all tracked modpacks."""
|
||||
with Session(self.engine) as session:
|
||||
rows = session.scalars(select(_ModpackRow)).all()
|
||||
return [Modpack._from_row(r) for r in rows]
|
||||
|
||||
def get_check_history(
|
||||
self, curseforge_id: int, limit: int = 10
|
||||
) -> List[CheckHistory]:
|
||||
"""Return recent check history for a modpack, newest first."""
|
||||
with Session(self.engine) as session:
|
||||
modpack_row = session.scalar(
|
||||
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
|
||||
)
|
||||
if modpack_row is None:
|
||||
return []
|
||||
rows = session.scalars(
|
||||
select(_CheckHistoryRow)
|
||||
.where(_CheckHistoryRow.modpack_id == modpack_row.id)
|
||||
.order_by(_CheckHistoryRow.checked_at.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
return [CheckHistory._from_row(r) for r in rows]
|
||||
122
services/modpack-version-checker/src/modpack_checker/notifier.py
Normal file
122
services/modpack-version-checker/src/modpack_checker/notifier.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Notification delivery — Discord webhooks and generic HTTP webhooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class NotificationError(Exception):
|
||||
"""Raised when a notification cannot be delivered."""
|
||||
|
||||
|
||||
class DiscordNotifier:
|
||||
"""Send modpack update alerts to a Discord channel via webhook.
|
||||
|
||||
Embeds are used so the messages look polished without additional
|
||||
configuration on the user's side.
|
||||
"""
|
||||
|
||||
_EMBED_COLOR_UPDATE = 0xF5A623 # amber — update available
|
||||
_EMBED_COLOR_TEST = 0x43B581 # green — test / OK
|
||||
|
||||
def __init__(self, webhook_url: str, timeout: int = 10) -> None:
|
||||
self.webhook_url = webhook_url
|
||||
self.timeout = timeout
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def send_update(
|
||||
self,
|
||||
modpack_name: str,
|
||||
curseforge_id: int,
|
||||
old_version: Optional[str],
|
||||
new_version: str,
|
||||
) -> None:
|
||||
"""Post an update-available embed to Discord.
|
||||
|
||||
Raises:
|
||||
NotificationError: if the webhook request fails.
|
||||
"""
|
||||
embed = {
|
||||
"title": "Modpack Update Available",
|
||||
"color": self._EMBED_COLOR_UPDATE,
|
||||
"url": f"https://www.curseforge.com/minecraft/modpacks/{curseforge_id}",
|
||||
"fields": [
|
||||
{
|
||||
"name": "Modpack",
|
||||
"value": modpack_name,
|
||||
"inline": True,
|
||||
},
|
||||
{
|
||||
"name": "CurseForge ID",
|
||||
"value": str(curseforge_id),
|
||||
"inline": True,
|
||||
},
|
||||
{
|
||||
"name": "Previous Version",
|
||||
"value": old_version or "Unknown",
|
||||
"inline": False,
|
||||
},
|
||||
{
|
||||
"name": "New Version",
|
||||
"value": new_version,
|
||||
"inline": False,
|
||||
},
|
||||
],
|
||||
"footer": {
|
||||
"text": "Modpack Version Checker \u2022 Firefrost Gaming",
|
||||
},
|
||||
}
|
||||
self._post({"embeds": [embed]})
|
||||
|
||||
def test(self) -> None:
|
||||
"""Send a simple test message to verify the webhook URL works.
|
||||
|
||||
Raises:
|
||||
NotificationError: if the request fails.
|
||||
"""
|
||||
self._post(
|
||||
{
|
||||
"embeds": [
|
||||
{
|
||||
"title": "Webhook Connected",
|
||||
"description": (
|
||||
"Modpack Version Checker notifications are working correctly."
|
||||
),
|
||||
"color": self._EMBED_COLOR_TEST,
|
||||
"footer": {"text": "Modpack Version Checker \u2022 Firefrost Gaming"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _post(self, payload: dict) -> None:
|
||||
try:
|
||||
response = requests.post(
|
||||
self.webhook_url,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
# Discord returns 204 No Content on success
|
||||
if response.status_code not in (200, 204):
|
||||
raise NotificationError(
|
||||
f"Discord returned HTTP {response.status_code}: {response.text[:200]}"
|
||||
)
|
||||
except requests.ConnectionError as exc:
|
||||
raise NotificationError(
|
||||
"Could not reach Discord. Check your internet connection."
|
||||
) from exc
|
||||
except requests.Timeout:
|
||||
raise NotificationError(
|
||||
f"Discord webhook timed out after {self.timeout}s."
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise NotificationError(f"Webhook request failed: {exc}") from exc
|
||||
0
services/modpack-version-checker/tests/__init__.py
Normal file
0
services/modpack-version-checker/tests/__init__.py
Normal file
11
services/modpack-version-checker/tests/conftest.py
Normal file
11
services/modpack-version-checker/tests/conftest.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Shared pytest fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
from modpack_checker.database import Database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
"""In-memory-equivalent SQLite database backed by a temp directory."""
|
||||
return Database(str(tmp_path / "test.db"))
|
||||
339
services/modpack-version-checker/tests/test_cli.py
Normal file
339
services/modpack-version-checker/tests/test_cli.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""Tests for cli.py using Click's test runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import responses as responses_lib
|
||||
from click.testing import CliRunner
|
||||
|
||||
from modpack_checker.cli import cli
|
||||
from modpack_checker.curseforge import CurseForgeNotFoundError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cfg(tmp_path):
|
||||
"""Return a Config-like mock backed by a real temp database."""
|
||||
cfg = MagicMock()
|
||||
cfg.curseforge_api_key = "test-api-key"
|
||||
cfg.database_path = str(tmp_path / "test.db")
|
||||
cfg.discord_webhook_url = None
|
||||
cfg.notification_on_update = True
|
||||
cfg.check_interval_hours = 6
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Root / help
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_help(runner):
|
||||
result = runner.invoke(cli, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "Modpack Version Checker" in result.output
|
||||
|
||||
|
||||
def test_version(runner):
|
||||
result = runner.invoke(cli, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert "1.0.0" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config show
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config_show(runner, mock_cfg):
|
||||
mock_cfg.curseforge_api_key = "abcd1234efgh5678"
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["config", "show"])
|
||||
assert result.exit_code == 0
|
||||
assert "abcd" in result.output # first 4 chars
|
||||
assert "5678" in result.output # last 4 chars
|
||||
|
||||
|
||||
def test_config_show_no_key(runner, mock_cfg):
|
||||
mock_cfg.curseforge_api_key = ""
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["config", "show"])
|
||||
assert result.exit_code == 0
|
||||
assert "Not configured" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_empty(runner, mock_cfg):
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["list"])
|
||||
assert result.exit_code == 0
|
||||
assert "empty" in result.output.lower()
|
||||
|
||||
|
||||
def test_list_with_modpacks(runner, mock_cfg, tmp_path):
|
||||
from modpack_checker.database import Database
|
||||
db = Database(str(tmp_path / "test.db"))
|
||||
db.add_modpack(111, "Pack Alpha")
|
||||
db.add_modpack(222, "Pack Beta")
|
||||
|
||||
mock_cfg.database_path = str(tmp_path / "test.db")
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Pack Alpha" in result.output
|
||||
assert "Pack Beta" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_requires_api_key(runner, mock_cfg):
|
||||
mock_cfg.curseforge_api_key = ""
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["add", "12345"])
|
||||
assert result.exit_code == 1
|
||||
assert "API key" in result.output
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_add_success(runner, mock_cfg):
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
"https://api.curseforge.com/v1/mods/12345",
|
||||
json={"data": {"id": 12345, "name": "Test Modpack", "latestFiles": []}},
|
||||
status=200,
|
||||
)
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["add", "12345"])
|
||||
assert result.exit_code == 0
|
||||
assert "Test Modpack" in result.output
|
||||
assert "tracking" in result.output.lower()
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_add_not_found(runner, mock_cfg):
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
"https://api.curseforge.com/v1/mods/99999",
|
||||
status=404,
|
||||
)
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["add", "99999"])
|
||||
assert result.exit_code == 1
|
||||
assert "99999" in result.output
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_add_duplicate_shows_warning(runner, mock_cfg, tmp_path):
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
"https://api.curseforge.com/v1/mods/12345",
|
||||
json={"data": {"id": 12345, "name": "Test Modpack", "latestFiles": []}},
|
||||
status=200,
|
||||
)
|
||||
from modpack_checker.database import Database
|
||||
db = Database(str(tmp_path / "test.db"))
|
||||
db.add_modpack(12345, "Test Modpack")
|
||||
|
||||
mock_cfg.database_path = str(tmp_path / "test.db")
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["add", "12345"])
|
||||
assert "already tracked" in result.output.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_not_in_list(runner, mock_cfg):
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["remove", "99999"], input="y\n")
|
||||
assert result.exit_code == 1
|
||||
assert "not in your watch list" in result.output
|
||||
|
||||
|
||||
def test_remove_success(runner, mock_cfg, tmp_path):
|
||||
from modpack_checker.database import Database
|
||||
db = Database(str(tmp_path / "test.db"))
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
|
||||
mock_cfg.database_path = str(tmp_path / "test.db")
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["remove", "12345"], input="y\n")
|
||||
assert result.exit_code == 0
|
||||
assert "Removed" in result.output
|
||||
|
||||
|
||||
def test_remove_aborted(runner, mock_cfg, tmp_path):
|
||||
from modpack_checker.database import Database
|
||||
db = Database(str(tmp_path / "test.db"))
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
|
||||
mock_cfg.database_path = str(tmp_path / "test.db")
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["remove", "12345"], input="n\n")
|
||||
# Aborted — pack should still exist
|
||||
assert db.get_modpack(12345) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_empty_list(runner, mock_cfg):
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["check"])
|
||||
assert result.exit_code == 0
|
||||
assert "No modpacks" in result.output
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_check_up_to_date(runner, mock_cfg, tmp_path):
|
||||
from modpack_checker.database import Database
|
||||
db = Database(str(tmp_path / "test.db"))
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
db.update_version(12345, "1.0.0")
|
||||
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
"https://api.curseforge.com/v1/mods/12345",
|
||||
json={
|
||||
"data": {
|
||||
"id": 12345,
|
||||
"name": "Test Pack",
|
||||
"latestFiles": [
|
||||
{
|
||||
"id": 1,
|
||||
"displayName": "1.0.0",
|
||||
"fileName": "pack-1.0.0.zip",
|
||||
"fileDate": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
mock_cfg.database_path = str(tmp_path / "test.db")
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["check", "--no-notify"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "up to date" in result.output.lower()
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_check_update_available(runner, mock_cfg, tmp_path):
|
||||
from modpack_checker.database import Database
|
||||
db = Database(str(tmp_path / "test.db"))
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
db.update_version(12345, "1.0.0")
|
||||
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
"https://api.curseforge.com/v1/mods/12345",
|
||||
json={
|
||||
"data": {
|
||||
"id": 12345,
|
||||
"name": "Test Pack",
|
||||
"latestFiles": [
|
||||
{
|
||||
"id": 2,
|
||||
"displayName": "1.1.0",
|
||||
"fileName": "pack-1.1.0.zip",
|
||||
"fileDate": "2026-02-01T00:00:00Z",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
mock_cfg.database_path = str(tmp_path / "test.db")
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["check", "--no-notify"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "1.1.0" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_status_not_found(runner, mock_cfg):
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["status", "99999"])
|
||||
assert result.exit_code == 1
|
||||
assert "not in your watch list" in result.output
|
||||
|
||||
|
||||
def test_status_shows_info(runner, mock_cfg, tmp_path):
|
||||
from modpack_checker.database import Database
|
||||
db = Database(str(tmp_path / "test.db"))
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
db.update_version(12345, "2.0.0")
|
||||
|
||||
mock_cfg.database_path = str(tmp_path / "test.db")
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["status", "12345"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Test Pack" in result.output
|
||||
assert "2.0.0" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notifications command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_notifications_disable(runner, mock_cfg, tmp_path):
|
||||
from modpack_checker.database import Database
|
||||
db = Database(str(tmp_path / "test.db"))
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
|
||||
mock_cfg.database_path = str(tmp_path / "test.db")
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["notifications", "12345", "--disable"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "disabled" in result.output
|
||||
assert db.get_modpack(12345).notification_enabled is False
|
||||
|
||||
|
||||
def test_notifications_enable(runner, mock_cfg, tmp_path):
|
||||
from modpack_checker.database import Database
|
||||
db = Database(str(tmp_path / "test.db"))
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
db.toggle_notifications(12345, False)
|
||||
|
||||
mock_cfg.database_path = str(tmp_path / "test.db")
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["notifications", "12345", "--enable"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "enabled" in result.output
|
||||
assert db.get_modpack(12345).notification_enabled is True
|
||||
|
||||
|
||||
def test_notifications_missing_modpack(runner, mock_cfg):
|
||||
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
|
||||
result = runner.invoke(cli, ["notifications", "99999", "--enable"])
|
||||
assert result.exit_code == 1
|
||||
72
services/modpack-version-checker/tests/test_config.py
Normal file
72
services/modpack-version-checker/tests/test_config.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Tests for config.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from modpack_checker.config import Config
|
||||
|
||||
|
||||
def test_default_values():
|
||||
cfg = Config()
|
||||
assert cfg.curseforge_api_key == ""
|
||||
assert cfg.discord_webhook_url is None
|
||||
assert cfg.check_interval_hours == 6
|
||||
assert cfg.notification_on_update is True
|
||||
|
||||
|
||||
def test_is_configured_without_key():
|
||||
assert Config().is_configured() is False
|
||||
|
||||
|
||||
def test_is_configured_with_key():
|
||||
assert Config(curseforge_api_key="abc123").is_configured() is True
|
||||
|
||||
|
||||
def test_save_and_load_round_trip(tmp_path):
|
||||
config_dir = tmp_path / ".config" / "modpack-checker"
|
||||
config_file = config_dir / "config.json"
|
||||
|
||||
with patch("modpack_checker.config.CONFIG_DIR", config_dir), \
|
||||
patch("modpack_checker.config.CONFIG_FILE", config_file):
|
||||
original = Config(
|
||||
curseforge_api_key="test-key-xyz",
|
||||
check_interval_hours=12,
|
||||
notification_on_update=False,
|
||||
)
|
||||
original.save()
|
||||
|
||||
loaded = Config.load()
|
||||
|
||||
assert loaded.curseforge_api_key == "test-key-xyz"
|
||||
assert loaded.check_interval_hours == 12
|
||||
assert loaded.notification_on_update is False
|
||||
|
||||
|
||||
def test_load_returns_defaults_when_file_missing(tmp_path):
|
||||
config_file = tmp_path / "nonexistent.json"
|
||||
with patch("modpack_checker.config.CONFIG_FILE", config_file):
|
||||
cfg = Config.load()
|
||||
assert cfg.curseforge_api_key == ""
|
||||
|
||||
|
||||
def test_load_returns_defaults_on_corrupted_file(tmp_path):
|
||||
config_dir = tmp_path / ".config" / "modpack-checker"
|
||||
config_dir.mkdir(parents=True)
|
||||
config_file = config_dir / "config.json"
|
||||
config_file.write_text("{ this is not valid json }")
|
||||
|
||||
with patch("modpack_checker.config.CONFIG_DIR", config_dir), \
|
||||
patch("modpack_checker.config.CONFIG_FILE", config_file):
|
||||
cfg = Config.load()
|
||||
|
||||
assert cfg.curseforge_api_key == ""
|
||||
|
||||
|
||||
def test_interval_bounds():
|
||||
with pytest.raises(Exception):
|
||||
Config(check_interval_hours=0)
|
||||
with pytest.raises(Exception):
|
||||
Config(check_interval_hours=169)
|
||||
227
services/modpack-version-checker/tests/test_curseforge.py
Normal file
227
services/modpack-version-checker/tests/test_curseforge.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Tests for curseforge.py."""
|
||||
|
||||
import pytest
|
||||
import responses as responses_lib
|
||||
|
||||
from modpack_checker.curseforge import (
|
||||
CurseForgeAuthError,
|
||||
CurseForgeClient,
|
||||
CurseForgeError,
|
||||
CurseForgeNotFoundError,
|
||||
CurseForgeRateLimitError,
|
||||
)
|
||||
|
||||
BASE = "https://api.curseforge.com"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return CurseForgeClient("test-api-key", timeout=5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_mod
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_get_mod_success(client):
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
f"{BASE}/v1/mods/123456",
|
||||
json={"data": {"id": 123456, "name": "Test Pack", "latestFiles": []}},
|
||||
status=200,
|
||||
)
|
||||
mod = client.get_mod(123456)
|
||||
assert mod["name"] == "Test Pack"
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_get_mod_not_found(client):
|
||||
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/999", status=404)
|
||||
with pytest.raises(CurseForgeNotFoundError):
|
||||
client.get_mod(999)
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_get_mod_invalid_key_401(client):
|
||||
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=401)
|
||||
with pytest.raises(CurseForgeAuthError, match="Invalid API key"):
|
||||
client.get_mod(123)
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_get_mod_forbidden_403(client):
|
||||
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=403)
|
||||
with pytest.raises(CurseForgeAuthError, match="permission"):
|
||||
client.get_mod(123)
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_get_mod_rate_limit_429(client):
|
||||
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=429)
|
||||
with pytest.raises(CurseForgeRateLimitError):
|
||||
client.get_mod(123)
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_get_mod_server_error(client):
|
||||
# responses library doesn't retry by default in tests; just test the exception
|
||||
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
|
||||
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
|
||||
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
|
||||
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
|
||||
with pytest.raises(CurseForgeError):
|
||||
client.get_mod(123)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_mod_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_get_mod_name(client):
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
f"{BASE}/v1/mods/100",
|
||||
json={"data": {"id": 100, "name": "All The Mods 9", "latestFiles": []}},
|
||||
status=200,
|
||||
)
|
||||
assert client.get_mod_name(100) == "All The Mods 9"
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_get_mod_name_fallback(client):
|
||||
"""If 'name' key is missing, return generic fallback."""
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
f"{BASE}/v1/mods/100",
|
||||
json={"data": {"id": 100, "latestFiles": []}},
|
||||
status=200,
|
||||
)
|
||||
assert client.get_mod_name(100) == "Modpack 100"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_latest_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_get_latest_file_uses_latest_files(client):
|
||||
"""get_latest_file should prefer the latestFiles field (fast path)."""
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
f"{BASE}/v1/mods/200",
|
||||
json={
|
||||
"data": {
|
||||
"id": 200,
|
||||
"name": "Pack",
|
||||
"latestFiles": [
|
||||
{
|
||||
"id": 9001,
|
||||
"displayName": "Pack 2.0.0",
|
||||
"fileName": "pack-2.0.0.zip",
|
||||
"fileDate": "2026-01-15T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": 9000,
|
||||
"displayName": "Pack 1.0.0",
|
||||
"fileName": "pack-1.0.0.zip",
|
||||
"fileDate": "2025-12-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
file_obj = client.get_latest_file(200)
|
||||
assert file_obj["displayName"] == "Pack 2.0.0"
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_get_latest_file_fallback_files_endpoint(client):
|
||||
"""Falls back to the /files endpoint when latestFiles is empty."""
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
f"{BASE}/v1/mods/300",
|
||||
json={"data": {"id": 300, "name": "Pack", "latestFiles": []}},
|
||||
status=200,
|
||||
)
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
f"{BASE}/v1/mods/300/files",
|
||||
json={
|
||||
"data": [
|
||||
{"id": 8000, "displayName": "Pack 3.0.0", "fileName": "pack-3.0.0.zip"}
|
||||
]
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
file_obj = client.get_latest_file(300)
|
||||
assert file_obj["displayName"] == "Pack 3.0.0"
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_get_latest_file_no_files_returns_none(client):
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
f"{BASE}/v1/mods/400",
|
||||
json={"data": {"id": 400, "name": "Pack", "latestFiles": []}},
|
||||
status=200,
|
||||
)
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
f"{BASE}/v1/mods/400/files",
|
||||
json={"data": []},
|
||||
status=200,
|
||||
)
|
||||
assert client.get_latest_file(400) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_version_prefers_display_name(client):
|
||||
file_obj = {"displayName": "ATM9 1.2.3", "fileName": "atm9-1.2.3.zip", "id": 1}
|
||||
assert client.extract_version(file_obj) == "ATM9 1.2.3"
|
||||
|
||||
|
||||
def test_extract_version_falls_back_to_filename(client):
|
||||
file_obj = {"displayName": "", "fileName": "pack-1.0.0.zip", "id": 1}
|
||||
assert client.extract_version(file_obj) == "pack-1.0.0.zip"
|
||||
|
||||
|
||||
def test_extract_version_last_resort_file_id(client):
|
||||
file_obj = {"displayName": "", "fileName": "", "id": 9999}
|
||||
assert client.extract_version(file_obj) == "File ID 9999"
|
||||
|
||||
|
||||
def test_extract_version_strips_whitespace(client):
|
||||
file_obj = {"displayName": " Pack 1.0 ", "fileName": "pack.zip", "id": 1}
|
||||
assert client.extract_version(file_obj) == "Pack 1.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_api_key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_validate_api_key_success(client):
|
||||
responses_lib.add(
|
||||
responses_lib.GET,
|
||||
f"{BASE}/v1/games/432",
|
||||
json={"data": {"id": 432, "name": "Minecraft"}},
|
||||
status=200,
|
||||
)
|
||||
assert client.validate_api_key() is True
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_validate_api_key_failure(client):
|
||||
responses_lib.add(responses_lib.GET, f"{BASE}/v1/games/432", status=401)
|
||||
assert client.validate_api_key() is False
|
||||
174
services/modpack-version-checker/tests/test_database.py
Normal file
174
services/modpack-version-checker/tests/test_database.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Tests for database.py."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from modpack_checker.database import Database
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_modpack
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_modpack_returns_correct_fields(db):
|
||||
mp = db.add_modpack(12345, "Test Pack")
|
||||
assert mp.curseforge_id == 12345
|
||||
assert mp.name == "Test Pack"
|
||||
assert mp.current_version is None
|
||||
assert mp.last_checked is None
|
||||
assert mp.notification_enabled is True
|
||||
|
||||
|
||||
def test_add_modpack_duplicate_raises(db):
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
with pytest.raises(ValueError, match="already being tracked"):
|
||||
db.add_modpack(12345, "Test Pack Again")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove_modpack
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_modpack_returns_true(db):
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
assert db.remove_modpack(12345) is True
|
||||
|
||||
|
||||
def test_remove_modpack_missing_returns_false(db):
|
||||
assert db.remove_modpack(99999) is False
|
||||
|
||||
|
||||
def test_remove_modpack_deletes_record(db):
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
db.remove_modpack(12345)
|
||||
assert db.get_modpack(12345) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_modpack
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_modpack_found(db):
|
||||
db.add_modpack(111, "Pack A")
|
||||
mp = db.get_modpack(111)
|
||||
assert mp is not None
|
||||
assert mp.name == "Pack A"
|
||||
|
||||
|
||||
def test_get_modpack_not_found(db):
|
||||
assert db.get_modpack(999) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_all_modpacks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_all_modpacks_empty(db):
|
||||
assert db.get_all_modpacks() == []
|
||||
|
||||
|
||||
def test_get_all_modpacks_multiple(db):
|
||||
db.add_modpack(1, "Pack A")
|
||||
db.add_modpack(2, "Pack B")
|
||||
db.add_modpack(3, "Pack C")
|
||||
modpacks = db.get_all_modpacks()
|
||||
assert len(modpacks) == 3
|
||||
ids = {mp.curseforge_id for mp in modpacks}
|
||||
assert ids == {1, 2, 3}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_version_sets_fields(db):
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
db.update_version(12345, "1.2.3", notification_sent=True)
|
||||
mp = db.get_modpack(12345)
|
||||
assert mp.current_version == "1.2.3"
|
||||
assert mp.last_checked is not None
|
||||
|
||||
|
||||
def test_update_version_nonexistent_raises(db):
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
db.update_version(99999, "1.0.0")
|
||||
|
||||
|
||||
def test_update_version_multiple_times(db):
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
db.update_version(12345, "1.0.0")
|
||||
db.update_version(12345, "1.1.0")
|
||||
mp = db.get_modpack(12345)
|
||||
assert mp.current_version == "1.1.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_check_history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_history_newest_first(db):
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
db.update_version(12345, "1.0.0", notification_sent=False)
|
||||
db.update_version(12345, "1.1.0", notification_sent=True)
|
||||
history = db.get_check_history(12345, limit=10)
|
||||
assert len(history) == 2
|
||||
assert history[0].version_found == "1.1.0"
|
||||
assert history[0].notification_sent is True
|
||||
assert history[1].version_found == "1.0.0"
|
||||
|
||||
|
||||
def test_check_history_limit(db):
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
for i in range(5):
|
||||
db.update_version(12345, f"1.{i}.0")
|
||||
history = db.get_check_history(12345, limit=3)
|
||||
assert len(history) == 3
|
||||
|
||||
|
||||
def test_check_history_missing_modpack(db):
|
||||
assert db.get_check_history(99999) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# toggle_notifications
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_toggle_notifications_disable(db):
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
result = db.toggle_notifications(12345, False)
|
||||
assert result is True
|
||||
mp = db.get_modpack(12345)
|
||||
assert mp.notification_enabled is False
|
||||
|
||||
|
||||
def test_toggle_notifications_re_enable(db):
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
db.toggle_notifications(12345, False)
|
||||
db.toggle_notifications(12345, True)
|
||||
assert db.get_modpack(12345).notification_enabled is True
|
||||
|
||||
|
||||
def test_toggle_notifications_missing(db):
|
||||
assert db.toggle_notifications(99999, True) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cascade delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remove_modpack_also_removes_history(db):
|
||||
db.add_modpack(12345, "Test Pack")
|
||||
db.update_version(12345, "1.0.0")
|
||||
db.update_version(12345, "1.1.0")
|
||||
db.remove_modpack(12345)
|
||||
# History should be gone (cascade delete)
|
||||
assert db.get_check_history(12345) == []
|
||||
83
services/modpack-version-checker/tests/test_notifier.py
Normal file
83
services/modpack-version-checker/tests/test_notifier.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Tests for notifier.py."""
|
||||
|
||||
import pytest
|
||||
import responses as responses_lib
|
||||
|
||||
from modpack_checker.notifier import DiscordNotifier, NotificationError
|
||||
|
||||
WEBHOOK_URL = "https://discord.com/api/webhooks/123456/abcdef"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier():
|
||||
return DiscordNotifier(WEBHOOK_URL, timeout=5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_send_update_success(notifier):
|
||||
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
|
||||
# Should not raise
|
||||
notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0")
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_send_update_initial_version(notifier):
|
||||
"""old_version=None should be handled gracefully."""
|
||||
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
|
||||
notifier.send_update("Test Pack", 12345, None, "1.0.0")
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_send_update_bad_response_raises(notifier):
|
||||
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=400, body="Bad Request")
|
||||
with pytest.raises(NotificationError, match="HTTP 400"):
|
||||
notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0")
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_send_update_unauthorized_raises(notifier):
|
||||
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=401)
|
||||
with pytest.raises(NotificationError):
|
||||
notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_test_webhook_success(notifier):
|
||||
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
|
||||
notifier.test() # Should not raise
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_test_webhook_failure_raises(notifier):
|
||||
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=404)
|
||||
with pytest.raises(NotificationError):
|
||||
notifier.test()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# embed structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@responses_lib.activate
|
||||
def test_send_update_embed_contains_modpack_name(notifier):
|
||||
"""Verify the correct embed payload is sent to Discord."""
|
||||
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
|
||||
notifier.send_update("All The Mods 9", 238222, "0.2.0", "0.3.0")
|
||||
|
||||
assert len(responses_lib.calls) == 1
|
||||
raw_body = responses_lib.calls[0].request.body
|
||||
payload = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
|
||||
assert "All The Mods 9" in payload
|
||||
assert "238222" in payload
|
||||
assert "0.3.0" in payload
|
||||
Reference in New Issue
Block a user