Initial setup, cleanup, VPS setup
All checks were successful
Deploy to VPS / deploy (push) Successful in 52s

This commit is contained in:
Tihon
2026-03-07 23:10:41 +02:00
commit 15498c8337
305 changed files with 27812 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,58 @@
# Deploy honey-be to VPS on push to main.
# Required secret: DEPLOY_SSH_PRIVATE_KEY.
# Optional: DEPLOY_VPS_HOST (default 188.116.23.7), DEPLOY_VPS_USER (default root).
name: Deploy to VPS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
env:
VPS_HOST: ${{ secrets.DEPLOY_VPS_HOST }}
VPS_USER: ${{ secrets.DEPLOY_VPS_USER }}
steps:
# Manual checkout: job container cannot resolve 'server', so clone via host IP (gateway).
# Set secret GITEA_HOST_IP to your runner host's gateway (from job: ip route show default | awk '{print $3}').
- name: Checkout
run: |
GITEA_HOST="${GITEA_HOST_IP:-172.20.0.1}"
git clone --depth 1 "http://oauth2:${GITHUB_TOKEN}@${GITEA_HOST}:3000/admin/honey-be.git" .
git fetch --depth 1 origin "${{ github.sha }}"
git checkout -q "${{ github.sha }}"
env:
GITHUB_TOKEN: ${{ github.token }}
GITEA_HOST_IP: ${{ secrets.GITEA_HOST_IP }}
- name: Install SSH and Rsync
run: |
apt-get update -qq
apt-get install -y -qq openssh-client rsync
- name: Setup SSH
env:
SSH_HOST: ${{ secrets.DEPLOY_VPS_HOST }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/deploy_key
HOST="${SSH_HOST:-188.116.23.7}"
ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Sync code to VPS
run: |
HOST="${VPS_HOST:-188.116.23.7}"
USER="${VPS_USER:-root}"
rsync -avz --delete -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new" \
--exclude '.git' \
--exclude 'target' \
./ "$USER@$HOST:/opt/app/backend/honey-be/"
- name: Run rolling update on VPS
run: |
HOST="${VPS_HOST:-188.116.23.7}"
USER="${VPS_USER:-root}"
ssh -i ~/.ssh/deploy_key "$USER@$HOST" "cd /opt/app/backend/honey-be && chmod +x scripts/rolling-update.staged.sh && sudo ./scripts/rolling-update.staged.sh"

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Environment files ###
.env
.env.local
.env.*.local
### Logs ###
*.log

114
ADMIN_SETUP.md Normal file
View File

@@ -0,0 +1,114 @@
# Admin User Setup Guide
This guide explains how to create an admin user in the database.
## Prerequisites
- Access to the MySQL database
- Spring Boot application running (to generate password hash)
## Method 1: Using Spring Boot Application
1. Create a simple test class or use the Spring Boot shell to generate a password hash:
```java
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String hashedPassword = encoder.encode("your-secure-password");
System.out.println(hashedPassword);
```
2. Connect to your MySQL database and run:
```sql
-- Insert a new admin user into the admins table
INSERT INTO admins (
username,
password_hash,
role
) VALUES (
'admin',
'$2a$10$YourGeneratedHashHere',
'ROLE_ADMIN'
);
```
## Method 2: Using Online BCrypt Generator
1. Use an online BCrypt generator (e.g., https://bcrypt-generator.com/)
2. Enter your desired password
3. Copy the generated hash
4. Use it in the SQL UPDATE/INSERT statement above
## Method 3: Using Command Line (if bcrypt-cli is installed)
```bash
bcrypt-cli hash "your-password" 10
```
## Security Best Practices
1. **Use Strong Passwords**: Minimum 12 characters with mix of letters, numbers, and symbols
2. **Change Default Credentials**: Never use default usernames/passwords in production
3. **Limit Admin Users**: Only create admin accounts for trusted personnel
4. **Regular Audits**: Periodically review admin users and their activity
5. **JWT Secret**: Ensure `APP_ADMIN_JWT_SECRET` in application.yml is set to a secure random string (minimum 32 characters)
## Generate JWT Secret
You can generate a secure JWT secret using:
```bash
# Using OpenSSL
openssl rand -base64 32
# Or using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```
Then set it in your environment variable or application.yml:
```yaml
app:
admin:
jwt:
secret: ${APP_ADMIN_JWT_SECRET:your-generated-secret-here}
```
## Testing Admin Login
After setting up an admin user, test the login:
```bash
curl -X POST https://win-spin.live/api/admin/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"your-password"}'
```
You should receive a response with a JWT token:
```json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"username": "admin"
}
```
## Troubleshooting
### "Invalid credentials" error
- Verify the password hash was generated correctly
- Check that the `username` in the `admins` table matches exactly (case-sensitive)
- Ensure the admin has `role = 'ROLE_ADMIN'` in the `admins` table
### "Access Denied" after login
- Verify the JWT token is being sent in the Authorization header: `Bearer <token>`
- Check backend logs for authentication errors
- Verify CORS configuration includes your admin domain
### Password hash format
- BCrypt hashes should start with `$2a$`, `$2b$`, or `$2y$`
- The hash should be 60 characters long
- Example format: `$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy`

1024
APPLICATION_OVERVIEW_old.md Normal file

File diff suppressed because it is too large Load Diff

327
BACKUP_SETUP.md Normal file
View File

@@ -0,0 +1,327 @@
# Database Backup Setup Guide
This guide explains how to set up automated database backups from your main VPS to your backup VPS.
## Overview
- **Main VPS**: 37.1.206.220 (production server)
- **Backup VPS**: 5.45.77.77 (backup storage)
- **Backup Location**: `/raid/backup/acc_260182/` on backup VPS
- **Database**: MySQL 8.0 in Docker container `lottery-mysql`
- **Database Name**: `lottery_db`
## Prerequisites
1. SSH access to both VPS servers
2. Root or sudo access on main VPS
3. Write access to `/raid/backup/acc_260182/` on backup VPS
## Step 1: Set Up SSH Key Authentication
To enable passwordless transfers, set up SSH key authentication between your main VPS and backup VPS.
### On Main VPS (37.1.206.220):
```bash
# Generate SSH key pair (if you don't have one)
ssh-keygen -t ed25519 -C "backup@lottery-main-vps" -f ~/.ssh/backup_key
# Copy public key to backup VPS
ssh-copy-id -i ~/.ssh/backup_key.pub root@5.45.77.77
# Test connection
ssh -i ~/.ssh/backup_key root@5.45.77.77 "echo 'SSH connection successful'"
```
**Note**: If you already have an SSH key, you can use it instead. The script uses the default SSH key (`~/.ssh/id_rsa` or `~/.ssh/id_ed25519`).
### Alternative: Use Existing SSH Key
If you want to use an existing SSH key, you can either:
1. Use the default key (no changes needed)
2. Configure SSH to use a specific key by editing `~/.ssh/config`:
```bash
cat >> ~/.ssh/config << EOF
Host backup-vps
HostName 5.45.77.77
User root
IdentityFile ~/.ssh/backup_key
EOF
```
Then update the backup script to use `backup-vps` as the hostname.
## Step 2: Configure Backup Script
The backup script is located at `scripts/backup-database.sh`. It's already configured with:
- Backup VPS: `5.45.77.77`
- Backup path: `/raid/backup/acc_260182`
- MySQL container: `lottery-mysql`
- Database: `lottery_db`
If you need to change the backup VPS user (default: `root`), edit the script:
```bash
nano scripts/backup-database.sh
# Change: BACKUP_VPS_USER="root" to your user
```
## Step 3: Make Scripts Executable
```bash
cd /opt/app/backend/lottery-be
chmod +x scripts/backup-database.sh
chmod +x scripts/restore-database.sh
```
## Step 4: Test Manual Backup
Run a test backup to ensure everything works:
```bash
# Test backup (keeps local copy for verification)
./scripts/backup-database.sh --keep-local
# Check backup on remote VPS
ssh root@5.45.77.77 "ls -lh /raid/backup/acc_260182/ | tail -5"
```
## Step 5: Set Up Automated Backups (Cron)
Set up a cron job to run backups automatically. Recommended schedule: **daily at 2 AM**.
### Option A: Edit Crontab Directly
```bash
# Edit root's crontab
sudo crontab -e
# Add this line (daily at 2 AM):
0 2 * * * /opt/app/backend/lottery-be/scripts/backup-database.sh >> /opt/app/logs/backup.log 2>&1
```
### Option B: Create Cron Script
Create a wrapper script for better logging:
```bash
cat > /opt/app/backend/lottery-be/scripts/run-backup.sh << 'EOF'
#!/bin/bash
# Wrapper script for automated backups
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_FILE="/opt/app/logs/backup.log"
# Ensure log directory exists
mkdir -p "$(dirname "$LOG_FILE")"
# Run backup and log output
"${SCRIPT_DIR}/backup-database.sh" >> "$LOG_FILE" 2>&1
# Send email notification on failure (optional, requires mail setup)
if [ $? -ne 0 ]; then
echo "Backup failed at $(date)" | mail -s "Lottery DB Backup Failed" your-email@example.com
fi
EOF
chmod +x /opt/app/backend/lottery-be/scripts/run-backup.sh
```
Then add to crontab:
```bash
sudo crontab -e
# Add:
0 2 * * * /opt/app/backend/lottery-be/scripts/run-backup.sh
```
### Recommended Backup Schedules
- **Daily at 2 AM**: `0 2 * * *` (recommended)
- **Twice daily (2 AM and 2 PM)**: `0 2,14 * * *`
- **Every 6 hours**: `0 */6 * * *`
- **Weekly (Sunday 2 AM)**: `0 2 * * 0`
## Step 6: Verify Automated Backups
After setting up cron, verify it's working:
```bash
# Check cron job is scheduled
sudo crontab -l
# Check backup logs
tail -f /opt/app/logs/backup.log
# List recent backups on remote VPS
ssh root@5.45.77.77 "ls -lht /raid/backup/acc_260182/ | head -10"
```
## Backup Retention
The backup script automatically:
- **Keeps last 30 days** of backups on remote VPS
- **Deletes local backups** after successful transfer (unless `--keep-local` is used)
To change retention period, edit `scripts/backup-database.sh`:
```bash
# Change this line:
ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "find ${BACKUP_VPS_PATH} -name 'lottery_db_backup_*.sql*' -type f -mtime +30 -delete"
# To keep 60 days, change +30 to +60
```
## Restoring from Backup
### Restore from Remote Backup
```bash
# Restore from backup VPS
./scripts/restore-database.sh 5.45.77.77:/raid/backup/acc_260182/lottery_db_backup_20240101_020000.sql.gz
```
### Restore from Local Backup
```bash
# If you kept a local backup
./scripts/restore-database.sh /opt/app/backups/lottery_db_backup_20240101_020000.sql.gz
```
**⚠️ WARNING**: Restore will **DROP and RECREATE** the database. All existing data will be lost!
## Backup Script Options
```bash
# Standard backup (compressed, no local copy)
./scripts/backup-database.sh
# Keep local copy after transfer
./scripts/backup-database.sh --keep-local
# Don't compress backup (faster, but larger files)
./scripts/backup-database.sh --no-compress
```
## Monitoring Backups
### Check Backup Status
```bash
# View recent backup logs
tail -50 /opt/app/logs/backup.log
# Count backups on remote VPS
ssh root@5.45.77.77 "ls -1 /raid/backup/acc_260182/lottery_db_backup_*.sql* | wc -l"
# List all backups with sizes
ssh root@5.45.77.77 "ls -lh /raid/backup/acc_260182/lottery_db_backup_*.sql*"
```
### Backup Health Check Script
Create a simple health check:
```bash
cat > /opt/app/backend/lottery-be/scripts/check-backup-health.sh << 'EOF'
#!/bin/bash
# Check if backups are running successfully
BACKUP_VPS="5.45.77.77"
BACKUP_PATH="/raid/backup/acc_260182"
DAYS_THRESHOLD=2 # Alert if no backup in last 2 days
LAST_BACKUP=$(ssh root@${BACKUP_VPS} "ls -t ${BACKUP_PATH}/lottery_db_backup_*.sql* 2>/dev/null | head -1")
if [ -z "$LAST_BACKUP" ]; then
echo "❌ ERROR: No backups found on backup VPS!"
exit 1
fi
LAST_BACKUP_DATE=$(ssh root@${BACKUP_VPS} "stat -c %Y ${LAST_BACKUP}")
CURRENT_DATE=$(date +%s)
DAYS_SINCE_BACKUP=$(( (CURRENT_DATE - LAST_BACKUP_DATE) / 86400 ))
if [ $DAYS_SINCE_BACKUP -gt $DAYS_THRESHOLD ]; then
echo "⚠️ WARNING: Last backup is $DAYS_SINCE_BACKUP days old!"
exit 1
else
echo "✅ Backup health OK: Last backup $DAYS_SINCE_BACKUP day(s) ago"
exit 0
fi
EOF
chmod +x /opt/app/backend/lottery-be/scripts/check-backup-health.sh
```
## Troubleshooting
### SSH Connection Issues
```bash
# Test SSH connection
ssh -v root@5.45.77.77 "echo 'test'"
# Check SSH key permissions
chmod 600 ~/.ssh/id_rsa
chmod 644 ~/.ssh/id_rsa.pub
```
### Permission Denied on Backup VPS
```bash
# Verify write access
ssh root@5.45.77.77 "touch /raid/backup/acc_260182/test && rm /raid/backup/acc_260182/test && echo 'Write access OK'"
```
### MySQL Container Not Running
```bash
# Check container status
docker ps | grep lottery-mysql
# Start container if needed
cd /opt/app/backend/lottery-be
docker-compose -f docker-compose.prod.yml up -d db
```
### Backup Script Permission Denied
```bash
# Make script executable
chmod +x scripts/backup-database.sh
```
## Backup File Naming
Backups are named with timestamp: `lottery_db_backup_YYYYMMDD_HHMMSS.sql.gz`
Example: `lottery_db_backup_20240115_020000.sql.gz`
## Disk Space Considerations
- **Compressed backups**: Typically 10-50% of database size
- **Uncompressed backups**: Same size as database
- **30-day retention**: Plan for ~30x daily backup size
Monitor disk space on backup VPS:
```bash
ssh root@5.45.77.77 "df -h /raid/backup/acc_260182"
```
## Security Notes
1. **SSH Keys**: Use SSH key authentication (no passwords)
2. **Secret File**: Database password is read from `/run/secrets/lottery-config.properties` (secure)
3. **Backup Files**: Contain sensitive data - ensure backup VPS is secure
4. **Permissions**: Backup script requires root access to read secrets
## Next Steps
1. ✅ Set up SSH key authentication
2. ✅ Test manual backup
3. ✅ Set up cron job for automated backups
4. ✅ Monitor backup logs for first few days
5. ✅ Test restore procedure (on test environment first!)

492
BACKUP_TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,492 @@
# Backup Script Permission Denied - Troubleshooting Guide
## Error Message
```
/bin/sh: 1: /opt/app/backend/lottery-be/scripts/backup-database.sh: Permission denied
```
This error occurs when the system cannot execute the script, even if you've already run `chmod +x`. Here's a systematic approach to find the root cause.
---
## Step 1: Verify File Permissions
### Check Current Permissions
```bash
ls -la /opt/app/backend/lottery-be/scripts/backup-database.sh
```
**Expected output:**
```
-rwxr-xr-x 1 root root 5678 Jan 15 10:00 /opt/app/backend/lottery-be/scripts/backup-database.sh
```
**What to look for:**
- The `x` (execute) permission should be present for owner, group, or others
- If you see `-rw-r--r--` (no `x`), the file is not executable
**Fix if needed:**
```bash
chmod +x /opt/app/backend/lottery-be/scripts/backup-database.sh
```
---
## Step 2: Check File System Mount Options
The file system might be mounted with `noexec` flag, which prevents executing scripts.
### Check Mount Options
```bash
mount | grep -E "(/opt|/app|/backend)"
```
**What to look for:**
- If you see `noexec` in the mount options, that's the problem
- Example of problematic mount: `/dev/sda1 on /opt type ext4 (rw,noexec,relatime)`
**Fix:**
1. Check `/etc/fstab`:
```bash
cat /etc/fstab | grep -E "(/opt|/app)"
```
2. If `noexec` is present, remove it and remount:
```bash
# Edit fstab (remove noexec)
sudo nano /etc/fstab
# Remount (if /opt is a separate partition)
sudo mount -o remount /opt
```
3. **Note:** If `/opt` is part of the root filesystem, you may need to reboot
---
## Step 3: Check Line Endings (CRLF vs LF)
Windows line endings (CRLF) can cause "Permission denied" errors on Linux.
### Check Line Endings
```bash
file /opt/app/backend/lottery-be/scripts/backup-database.sh
```
**Expected output:**
```
/opt/app/backend/lottery-be/scripts/backup-database.sh: Bourne-Again shell script, ASCII text executable
```
**If you see:**
```
/opt/app/backend/lottery-be/scripts/backup-database.sh: ASCII text, with CRLF line terminators
```
**Fix:**
```bash
# Convert CRLF to LF
dos2unix /opt/app/backend/lottery-be/scripts/backup-database.sh
# Or using sed
sed -i 's/\r$//' /opt/app/backend/lottery-be/scripts/backup-database.sh
# Or using tr
tr -d '\r' < /opt/app/backend/lottery-be/scripts/backup-database.sh > /tmp/backup-database.sh
mv /tmp/backup-database.sh /opt/app/backend/lottery-be/scripts/backup-database.sh
chmod +x /opt/app/backend/lottery-be/scripts/backup-database.sh
```
---
## Step 4: Verify Shebang Line
The shebang line must point to a valid interpreter.
### Check Shebang
```bash
head -1 /opt/app/backend/lottery-be/scripts/backup-database.sh
```
**Expected:**
```bash
#!/bin/bash
```
**Verify bash exists:**
```bash
which bash
ls -la /bin/bash
```
**If bash doesn't exist or path is wrong:**
```bash
# Find bash location
which bash
# or
whereis bash
# Update shebang if needed (bash is usually at /bin/bash or /usr/bin/bash)
```
---
## Step 5: Check SELinux (if enabled)
SELinux can block script execution even with correct permissions.
### Check if SELinux is Enabled
```bash
getenforce
```
**Outputs:**
- `Enforcing` - SELinux is active and blocking
- `Permissive` - SELinux is active but only logging
- `Disabled` - SELinux is off
### Check SELinux Context
```bash
ls -Z /opt/app/backend/lottery-be/scripts/backup-database.sh
```
**Fix if SELinux is blocking:**
```bash
# Set correct context for shell scripts
chcon -t bin_t /opt/app/backend/lottery-be/scripts/backup-database.sh
# Or restore default context
restorecon -v /opt/app/backend/lottery-be/scripts/backup-database.sh
# Or temporarily set to permissive (for testing only)
setenforce 0
```
---
## Step 6: Check AppArmor (if enabled)
AppArmor can also block script execution.
### Check AppArmor Status
```bash
aa-status
```
**If AppArmor is active and blocking:**
```bash
# Check AppArmor logs
sudo dmesg | grep -i apparmor
sudo journalctl -u apparmor | tail -20
# Temporarily disable for testing (not recommended for production)
sudo systemctl stop apparmor
```
---
## Step 7: Verify Cron Job User
The cron job might be running as a different user than expected.
### Check Cron Job
```bash
# Check root's crontab
sudo crontab -l
# Check if cron job specifies a user
# Example: 0 2 * * * root /opt/app/backend/lottery-be/scripts/backup-database.sh
```
### Check Which User Runs Cron
```bash
# Check cron service logs
sudo journalctl -u cron | tail -20
# Or check syslog
sudo grep CRON /var/log/syslog | tail -10
```
**Important:** The script requires root access (line 71-74 checks for EUID=0). Make sure cron runs as root:
```bash
# Edit root's crontab (correct way)
sudo crontab -e
# NOT user's crontab
# crontab -e # This runs as current user, not root
```
---
## Step 8: Test Script Execution Manually
Test the script with the same user that cron uses.
### Test as Root
```bash
# Test directly
sudo /opt/app/backend/lottery-be/scripts/backup-database.sh
# Test with bash explicitly
sudo bash /opt/app/backend/lottery-be/scripts/backup-database.sh
# Test with sh (if bash is not available)
sudo sh /opt/app/backend/lottery-be/scripts/backup-database.sh
```
**If manual execution works but cron doesn't:**
- The issue is likely with cron's environment or user context
- See Step 9 for cron environment issues
---
## Step 9: Check Cron Environment
Cron has a minimal environment. The script might need specific environment variables or paths.
### Check Script Dependencies
The script uses:
- `docker` command
- `ssh` command
- `gzip` command
- `/run/secrets/lottery-config.properties` file
### Verify Commands are in PATH
```bash
# Check if commands are accessible
which docker
which ssh
which gzip
which bash
# If commands are not in standard PATH, update cron job:
# Add PATH to cron job:
0 2 * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /opt/app/backend/lottery-be/scripts/backup-database.sh >> /opt/app/logs/backup.log 2>&1
```
### Test Cron Environment
Create a test cron job to see the environment:
```bash
# Add to crontab
* * * * * env > /tmp/cron-env.txt
# Wait 1 minute, then check
cat /tmp/cron-env.txt
```
---
## Step 10: Check Directory Permissions
The directory containing the script must be executable.
### Check Directory Permissions
```bash
ls -ld /opt/app/backend/lottery-be/scripts/
```
**Expected:**
```
drwxr-xr-x 2 root root 4096 Jan 15 10:00 /opt/app/backend/lottery-be/scripts/
```
**If directory is not executable:**
```bash
chmod +x /opt/app/backend/lottery-be/scripts/
```
---
## Step 11: Check for Hidden Characters
Hidden characters or encoding issues can break the shebang.
### View File in Hex
```bash
head -c 20 /opt/app/backend/lottery-be/scripts/backup-database.sh | od -c
```
**Expected:**
```
0000000 # ! / b i n / b a s h \n
```
**If you see strange characters:**
```bash
# Recreate the shebang line
sed -i '1s/.*/#!\/bin\/bash/' /opt/app/backend/lottery-be/scripts/backup-database.sh
```
---
## Step 12: Comprehensive Diagnostic Script
Run this diagnostic script to check all common issues:
```bash
cat > /tmp/check-backup-script.sh << 'EOF'
#!/bin/bash
echo "=== Backup Script Diagnostic ==="
echo ""
SCRIPT="/opt/app/backend/lottery-be/scripts/backup-database.sh"
echo "1. File exists?"
[ -f "$SCRIPT" ] && echo " ✅ Yes" || echo " ❌ No"
echo "2. File permissions:"
ls -la "$SCRIPT"
echo "3. File is executable?"
[ -x "$SCRIPT" ] && echo " ✅ Yes" || echo " ❌ No"
echo "4. Shebang line:"
head -1 "$SCRIPT"
echo "5. Bash exists?"
[ -f /bin/bash ] && echo " ✅ Yes: /bin/bash" || [ -f /usr/bin/bash ] && echo " ✅ Yes: /usr/bin/bash" || echo " ❌ No"
echo "6. Line endings:"
file "$SCRIPT"
echo "7. Mount options for /opt:"
mount | grep -E "(/opt|/app)" || echo " (Not a separate mount)"
echo "8. SELinux status:"
getenforce 2>/dev/null || echo " (Not installed)"
echo "9. Directory permissions:"
ls -ld "$(dirname "$SCRIPT")"
echo "10. Test execution:"
bash -n "$SCRIPT" && echo " ✅ Syntax OK" || echo " ❌ Syntax error"
echo ""
echo "=== End Diagnostic ==="
EOF
chmod +x /tmp/check-backup-script.sh
/tmp/check-backup-script.sh
```
---
## Step 13: Alternative Solutions
If the issue persists, try these workarounds:
### Solution A: Use bash Explicitly in Cron
```bash
# Instead of:
0 2 * * * /opt/app/backend/lottery-be/scripts/backup-database.sh
# Use:
0 2 * * * /bin/bash /opt/app/backend/lottery-be/scripts/backup-database.sh
```
### Solution B: Create Wrapper Script
```bash
cat > /opt/app/backend/lottery-be/scripts/run-backup-wrapper.sh << 'EOF'
#!/bin/bash
cd /opt/app/backend/lottery-be
exec /opt/app/backend/lottery-be/scripts/backup-database.sh "$@"
EOF
chmod +x /opt/app/backend/lottery-be/scripts/run-backup-wrapper.sh
# Update cron to use wrapper
0 2 * * * /opt/app/backend/lottery-be/scripts/run-backup-wrapper.sh >> /opt/app/logs/backup.log 2>&1
```
### Solution C: Use systemd Timer Instead of Cron
```bash
# Create systemd service
cat > /etc/systemd/system/lottery-backup.service << 'EOF'
[Unit]
Description=Lottery Database Backup
After=network.target
[Service]
Type=oneshot
ExecStart=/opt/app/backend/lottery-be/scripts/backup-database.sh
User=root
StandardOutput=append:/opt/app/logs/backup.log
StandardError=append:/opt/app/logs/backup.log
EOF
# Create systemd timer
cat > /etc/systemd/system/lottery-backup.timer << 'EOF'
[Unit]
Description=Run Lottery Database Backup Daily
Requires=lottery-backup.service
[Timer]
OnCalendar=02:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
# Enable and start
systemctl daemon-reload
systemctl enable lottery-backup.timer
systemctl start lottery-backup.timer
```
---
## Most Common Causes (Quick Reference)
1. **Line endings (CRLF)** - Most common if file was edited on Windows
2. **File system mounted with `noexec`** - Check mount options
3. **Cron running as wrong user** - Must run as root (use `sudo crontab -e`)
4. **SELinux/AppArmor blocking** - Check security contexts
5. **Missing execute permission** - Run `chmod +x` again
6. **Directory not executable** - Check parent directory permissions
---
## Quick Fix Checklist
Run these commands in order:
```bash
# 1. Fix line endings
dos2unix /opt/app/backend/lottery-be/scripts/backup-database.sh
# OR if dos2unix not available:
sed -i 's/\r$//' /opt/app/backend/lottery-be/scripts/backup-database.sh
# 2. Ensure execute permission
chmod +x /opt/app/backend/lottery-be/scripts/backup-database.sh
# 3. Ensure directory is executable
chmod +x /opt/app/backend/lottery-be/scripts/
# 4. Test execution
sudo /opt/app/backend/lottery-be/scripts/backup-database.sh --keep-local
# 5. Verify cron job uses bash explicitly
sudo crontab -e
# Change to: 0 2 * * * /bin/bash /opt/app/backend/lottery-be/scripts/backup-database.sh >> /opt/app/logs/backup.log 2>&1
```
---
## Still Not Working?
If none of the above fixes work, provide the output of:
```bash
# Run diagnostic
/tmp/check-backup-script.sh
# Check cron logs
sudo journalctl -u cron | tail -50
# Check system logs
sudo dmesg | tail -20
```
This will help identify the exact issue.

765
DEPLOYMENT_GUIDE.md Normal file
View File

@@ -0,0 +1,765 @@
# VPS Deployment Guide for Lottery Application
This guide will help you deploy the Lottery application to a VPS (Ubuntu) using Docker, Docker Compose, and Nginx.
## Prerequisites
- Ubuntu VPS (tested on Ubuntu 20.04+)
- Root or sudo access
- Domain name pointing to your VPS IP (for HTTPS)
- Basic knowledge of Linux commands
## Architecture Overview
```
Internet
Nginx (HTTPS, Port 443)
├─→ Frontend (Static files from /opt/app/frontend/dist)
├─→ Backend API (/api/* → Docker container on port 8080)
├─→ WebSocket (/ws → Docker container)
└─→ Avatars (/avatars/* → /opt/app/data/avatars)
```
## Step 1: Initial VPS Setup
### 1.1 Update System
```bash
sudo apt update
sudo apt upgrade -y
```
### 1.2 Install Required Software
```bash
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
# Docker Compose v2+ is included with Docker (as a plugin)
# Verify it's installed:
docker compose version
# If not installed, install Docker Compose plugin:
# For Ubuntu/Debian:
sudo apt-get update
sudo apt-get install docker-compose-plugin
# Or if you need the standalone version (older method):
# sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
# sudo chmod +x /usr/local/bin/docker-compose
# Install Nginx
sudo apt install nginx -y
# Install Certbot for SSL certificates
sudo apt install certbot python3-certbot-nginx -y
# Log out and log back in for Docker group to take effect
exit
```
## Step 2: Create Directory Structure
```bash
# Create main application directory
sudo mkdir -p /opt/app
sudo chown $USER:$USER /opt/app
# Create subdirectories
mkdir -p /opt/app/backend
mkdir -p /opt/app/frontend
mkdir -p /opt/app/nginx
mkdir -p /opt/app/data/avatars
mkdir -p /opt/app/mysql/data
# Set proper permissions
sudo chmod -R 755 /opt/app
sudo chown -R $USER:$USER /opt/app/data
```
## Step 3: Deploy Backend
### 3.1 Copy Backend Files
From your local machine, copy the backend repository to the VPS:
```bash
# On your local machine, use scp or rsync
scp -r lottery-be/* user@your-vps-ip:/opt/app/backend/
# Or use git (recommended)
# On VPS:
cd /opt/app/backend
git clone <your-backend-repo-url> .
```
### 3.2 Plan Database Configuration
**Important:** MySQL runs as a Docker container (no separate MySQL installation needed). Before creating the secret file, you need to decide on your database credentials:
1. **Database Name**: `lottery_db` (default, can be changed)
2. **Database Username**: `root` (default, can be changed)
3. **Database Password**: Choose a strong, secure password
4. **Database URL**: `jdbc:mysql://db:3306/lottery_db`
**Understanding the Database URL (`SPRING_DATASOURCE_URL`):**
The URL format is: `jdbc:mysql://<hostname>:<port>/<database-name>`
**For this deployment, use: `jdbc:mysql://db:3306/lottery_db`**
Breaking it down:
- `jdbc:mysql://` - JDBC protocol for MySQL
- `db` - This is the **service name** in `docker-compose.prod.yml` (acts as hostname in Docker network)
- `3306` - Default MySQL port (internal to Docker network)
- `lottery_db` - Database name (must match `MYSQL_DATABASE` in docker-compose)
**Why `db` as hostname?**
- In Docker Compose, services communicate using their **service names** as hostnames
- The MySQL service is named `db` in `docker-compose.prod.yml` (line 4: `services: db:`)
- Both containers are on the same Docker network (`lottery-network`)
- The backend container connects to MySQL using `db:3306` (not `localhost` or the VPS IP)
- This is an **internal Docker network connection** - MySQL is not exposed to the host
**Quick Reference:**
- ✅ Correct: `jdbc:mysql://db:3306/lottery_db` (uses service name)
- ❌ Wrong: `jdbc:mysql://localhost:3306/lottery_db` (won't work - localhost refers to the container itself)
- ❌ Wrong: `jdbc:mysql://127.0.0.1:3306/lottery_db` (won't work - same reason)
**Example credentials (use your own secure password!):**
- Database URL: `jdbc:mysql://db:3306/lottery_db`
- Database Name: `lottery_db`
- Username: `root`
- Password: `MySecurePassword123!`
**Note:** These credentials will be used in:
- The secret file (`SPRING_DATASOURCE_URL`, `SPRING_DATASOURCE_USERNAME`, `SPRING_DATASOURCE_PASSWORD`)
- MySQL container environment variables (`DB_PASSWORD`, `DB_ROOT_PASSWORD`)
The MySQL container will be created automatically when you run `docker-compose`, and the database will be initialized with these credentials.
### 3.3 Create Secret Configuration File
The application uses a mounted secret file instead of environment variables for security. Create the secret file:
**Option 1: Copy from template (if template file exists)**
```bash
# Create the secrets directory (if it doesn't exist)
sudo mkdir -p /run/secrets
# Navigate to backend directory
cd /opt/app/backend
# Check if template file exists
ls -la honey-config.properties.template
# If it exists, copy it
sudo cp honey-config.properties.template /run/secrets/lottery-config.properties
```
**Option 2: Create the file directly (if template wasn't copied)**
If the template file doesn't exist in `/opt/app/backend/`, create the secret file directly:
```bash
# Create the secrets directory (if it doesn't exist)
sudo mkdir -p /run/secrets
# Create the secret file
sudo nano /run/secrets/lottery-config.properties
```
Then paste the following content (replace placeholder values):
```properties
# Lottery Application Configuration
# Replace all placeholder values with your actual configuration
# ============================================
# Database Configuration
# ============================================
# SPRING_DATASOURCE_URL format: jdbc:mysql://<hostname>:<port>/<database-name>
#
# How to determine the URL:
# - Hostname: 'db' (this is the MySQL service name in docker-compose.prod.yml)
# * In Docker Compose, services communicate using their service names
# * The MySQL service is named 'db', so use 'db' as the hostname
# * Both containers are on the same Docker network, so 'db' resolves to the MySQL container
# - Port: '3306' (default MySQL port, internal to Docker network)
# - Database name: 'lottery_db' (must match MYSQL_DATABASE in docker-compose.prod.yml)
#
# Example: jdbc:mysql://db:3306/lottery_db
# └─┬─┘ └┬┘ └─┬──┘ └───┬────┘
# │ │ │ └─ Database name
# │ │ └─ Port (3306 is MySQL default)
# │ └─ Service name in docker-compose (acts as hostname)
# └─ JDBC protocol for MySQL
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db
SPRING_DATASOURCE_USERNAME=root
SPRING_DATASOURCE_PASSWORD=your_secure_database_password_here
# ============================================
# Telegram Bot Configuration
# ============================================
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN=your_channel_checker_bot_token_here
TELEGRAM_FOLLOW_TASK_CHANNEL_ID=@your_channel_name
# ============================================
# Frontend Configuration
# ============================================
FRONTEND_URL=https://yourdomain.com
# ============================================
# Avatar Storage Configuration
# ============================================
APP_AVATAR_STORAGE_PATH=/app/data/avatars
APP_AVATAR_PUBLIC_BASE_URL=
APP_AVATAR_MAX_SIZE_BYTES=2097152
APP_AVATAR_MAX_DIMENSION=512
# ============================================
# Session Configuration (Optional - defaults shown)
# ============================================
APP_SESSION_MAX_ACTIVE_PER_USER=5
APP_SESSION_CLEANUP_BATCH_SIZE=5000
APP_SESSION_CLEANUP_MAX_BATCHES=20
# ============================================
# GeoIP Configuration (Optional)
# ============================================
GEOIP_DB_PATH=
```
**Edit the secret file with your actual values:**
```bash
sudo nano /run/secrets/lottery-config.properties
```
**Important:** Replace all placeholder values with your actual configuration:
```properties
# Database Configuration
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db
SPRING_DATASOURCE_USERNAME=root
SPRING_DATASOURCE_PASSWORD=your_secure_database_password_here
# Telegram Bot Configuration
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN=your_channel_checker_bot_token_here
TELEGRAM_FOLLOW_TASK_CHANNEL_ID=@your_channel_name
# Frontend Configuration
FRONTEND_URL=https://yourdomain.com
# Avatar Storage Configuration
APP_AVATAR_STORAGE_PATH=/app/data/avatars
APP_AVATAR_PUBLIC_BASE_URL=
# Optional: Session Configuration (defaults shown)
APP_SESSION_MAX_ACTIVE_PER_USER=5
APP_SESSION_CLEANUP_BATCH_SIZE=5000
APP_SESSION_CLEANUP_MAX_BATCHES=20
# Optional: GeoIP Configuration
GEOIP_DB_PATH=
```
**Set secure permissions:**
```bash
# Make the file readable only by root and the docker group
sudo chmod 640 /run/secrets/lottery-config.properties
sudo chown root:docker /run/secrets/lottery-config.properties
```
**Important Notes:**
- The database credentials you set here (`SPRING_DATASOURCE_*`) must match the MySQL container environment variables (see Step 3.4)
- The MySQL container will be created automatically when you run `docker-compose`
- The database `lottery_db` will be created automatically on first startup
- Data will persist in a Docker volume (`mysql_data`)
### 3.4 Set MySQL Container Environment Variables
The MySQL container needs the database password as environment variables. These must match the credentials in your secret file.
**Option 1: Read from secret file automatically (recommended - more secure and consistent)**
Use the provided script that reads the password from the secret file:
```bash
cd /opt/app/backend
# Make sure the script is executable
chmod +x scripts/load-db-password.sh
# Source the script to load the password (this exports DB_PASSWORD and DB_ROOT_PASSWORD)
source scripts/load-db-password.sh
```
**What this does:**
- Reads `SPRING_DATASOURCE_PASSWORD` from `/run/secrets/lottery-config.properties`
- Exports `DB_PASSWORD` and `DB_ROOT_PASSWORD` with the same value
- Ensures MySQL container credentials match the backend credentials automatically
**Verify it worked:**
```bash
# Check that the variables are set
echo "DB_PASSWORD is set: $([ -n "$DB_PASSWORD" ] && echo "yes" || echo "no")"
```
**Option 2: Set environment variables manually (simpler but less secure)**
If you prefer to set them manually (not recommended):
```bash
# Export the password (must match SPRING_DATASOURCE_PASSWORD from your secret file)
# Replace with the actual password you set in the secret file
export DB_PASSWORD=your_secure_database_password_here
export DB_ROOT_PASSWORD=your_secure_database_password_here
# Verify it's set
echo $DB_PASSWORD
```
**Important Notes:**
- The `DB_PASSWORD` and `DB_ROOT_PASSWORD` must match `SPRING_DATASOURCE_PASSWORD` from your secret file
- These environment variables are only used by the MySQL container
- The backend application reads credentials from the secret file, not from environment variables
- **Option 1 is recommended** because it ensures consistency and reduces the chance of mismatched passwords
### 3.5 Build and Start Backend
**Before starting:** Make sure you have:
- ✅ Secret file created at `/run/secrets/lottery-config.properties` with database credentials
- ✅ Environment variables `DB_PASSWORD` and `DB_ROOT_PASSWORD` set (use `source scripts/load-db-password.sh` from Step 3.4)
```bash
cd /opt/app/backend
# Make sure DB_PASSWORD and DB_ROOT_PASSWORD are set (if not already done in Step 3.4)
# If you haven't sourced the script yet, do it now:
source scripts/load-db-password.sh
# Build and start services
docker compose -f docker-compose.prod.yml up -d --build
# Check logs (press Ctrl+C to exit)
docker compose -f docker-compose.prod.yml logs -f
```
**What happens when you start:**
1. **MySQL container starts first** (`lottery-mysql`)
- Creates the database `lottery_db` automatically (if it doesn't exist)
- Sets up the root user with your password from `DB_PASSWORD`
- Waits until healthy before backend starts
2. **Backend container starts** (`lottery-backend`)
- Loads configuration from `/run/secrets/lottery-config.properties`
- Connects to MySQL using credentials from secret file
- Runs Flyway migrations to create all database tables
- Starts the Spring Boot application
**Wait for the database to be ready and migrations to complete.** You should see:
- `lottery-mysql` container running
- `lottery-backend` container running
- Log message: "📁 Loading configuration from mounted secret file: /run/secrets/lottery-config.properties"
- Database migration messages (Flyway creating tables)
- No errors in logs
**Verify everything is working:**
```bash
# Check that both containers are running
docker ps | grep lottery
# Check backend logs for secret file loading
docker compose -f docker-compose.prod.yml logs backend | grep "Loading configuration"
# Check backend logs for database connection
docker compose -f docker-compose.prod.yml logs backend | grep -i "database\|mysql\|flyway"
# Check for any errors
docker compose -f docker-compose.prod.yml logs backend | grep -i error
```
You should see:
- `📁 Loading configuration from mounted secret file: /run/secrets/lottery-config.properties`
- `Flyway migration` messages showing tables being created
- No connection errors
**If you see connection errors:**
- Verify `SPRING_DATASOURCE_PASSWORD` in secret file matches `DB_PASSWORD` environment variable
- Re-run the password loading script: `source scripts/load-db-password.sh`
- Check that MySQL container is healthy: `docker ps | grep mysql`
- Check MySQL logs: `docker compose -f docker-compose.prod.yml logs db`
## Step 4: Build and Deploy Frontend
### 4.1 Build Frontend Locally (Recommended)
On your local machine:
```bash
cd lottery-fe
# Build for production (uses relative API URLs by default)
npm install
npm run build
# The dist/ folder will be created
```
### 4.2 Copy Frontend Build to VPS
```bash
# On your local machine
scp -r lottery-fe/dist/* user@your-vps-ip:/opt/app/frontend/dist/
# Or use rsync
rsync -avz lottery-fe/dist/ user@your-vps-ip:/opt/app/frontend/dist/
```
### 4.3 Alternative: Build on VPS
If you prefer to build on the VPS:
```bash
# Install Node.js on VPS
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# Copy frontend source
scp -r lottery-fe/* user@your-vps-ip:/opt/app/frontend-source/
# Build
cd /opt/app/frontend-source
npm install
npm run build
# Copy dist to frontend directory
cp -r dist/* /opt/app/frontend/dist/
```
## Step 5: Configure Nginx
### 5.1 Copy Nginx Configuration
```bash
# Copy the template
cp /opt/app/backend/nginx.conf.template /opt/app/nginx/nginx.conf
# Edit the configuration
nano /opt/app/nginx/nginx.conf
```
**Update the following:**
1. Replace `server_name _;` with your domain name (e.g., `server_name yourdomain.com;`)
2. Update SSL certificate paths if using Let's Encrypt (see Step 6)
3. Verify paths match your directory structure
### 5.2 Link Nginx Configuration
```bash
# Remove default Nginx config
sudo rm /etc/nginx/sites-enabled/default
# Create symlink to your config
sudo ln -s /opt/app/nginx/nginx.conf /etc/nginx/sites-available/lottery
sudo ln -s /etc/nginx/sites-available/lottery /etc/nginx/sites-enabled/
# Test Nginx configuration
sudo nginx -t
# If test passes, reload Nginx
sudo systemctl reload nginx
```
## Step 6: Setup SSL Certificate (HTTPS)
### 6.1 Obtain SSL Certificate
```bash
# Stop Nginx temporarily
sudo systemctl stop nginx
# Obtain certificate (replace with your domain and email)
sudo certbot certonly --standalone -d yourdomain.com -d www.yourdomain.com --email your-email@example.com --agree-tos
# Start Nginx
sudo systemctl start nginx
```
### 6.2 Update Nginx Config with Certificate Paths
Edit `/opt/app/nginx/nginx.conf` and update:
```nginx
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
```
### 6.3 Setup Auto-Renewal
```bash
# Test renewal
sudo certbot renew --dry-run
# Certbot will automatically renew certificates
```
## Step 7: Configure Telegram Webhook
Update your Telegram bot webhook to point to your VPS:
```bash
# Replace with your bot token, domain, and the same webhook token you set in APP_TELEGRAM_WEBHOOK_TOKEN
curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
-d "url=https://yourdomain.com/api/telegram/webhook/<YOUR_WEBHOOK_TOKEN>"
```
Verify webhook:
```bash
curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getWebhookInfo"
```
## Step 8: Final Verification
### 8.1 Check Services
```bash
# Check Docker containers
docker ps
# Should show:
# - lottery-mysql
# - lottery-backend
# Check Nginx
sudo systemctl status nginx
```
### 8.2 Test Endpoints
```bash
# Test backend health
curl http://localhost:8080/actuator/health
# Test frontend (should return HTML)
curl https://yourdomain.com/
# Test API (should return JSON or error with auth)
curl https://yourdomain.com/api/health
```
### 8.3 Check Logs
```bash
# Backend logs
cd /opt/app/backend
docker-compose -f docker-compose.prod.yml logs -f
# Nginx logs
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.log
```
### 8.4 Browser Testing
1. Open `https://yourdomain.com` in a browser
2. Test the Telegram Mini App
3. Verify API calls work (check browser console)
4. Test WebSocket connection (game updates)
## Step 9: Maintenance Commands
### 9.1 Restart Services
```bash
# Restart backend
cd /opt/app/backend
docker compose -f docker-compose.prod.yml restart
# Restart Nginx
sudo systemctl restart nginx
```
### 9.2 Update Application
```bash
# Backend update
cd /opt/app/backend
git pull # or copy new files
docker compose -f docker-compose.prod.yml up -d --build
# Frontend update
# Rebuild and copy dist/ folder
```
### 9.3 Backup Database
```bash
# Create backup
docker exec lottery-mysql mysqldump -u root -p${DB_PASSWORD} lottery_db > backup_$(date +%Y%m%d).sql
# Restore backup
docker exec -i lottery-mysql mysql -u root -p${DB_PASSWORD} lottery_db < backup_20240101.sql
```
### 9.4 View Logs
```bash
# Backend logs
cd /opt/app/backend
docker-compose -f docker-compose.prod.yml logs -f backend
# Database logs
docker-compose -f docker-compose.prod.yml logs -f db
# Nginx logs
sudo tail -f /var/log/nginx/error.log
```
## Troubleshooting
### Backend Not Starting
```bash
# Check logs
docker compose -f docker-compose.prod.yml logs backend
# Common issues:
# - Database not ready: wait for health check
# - Missing configuration: check secret file at /run/secrets/lottery-config.properties
# - Secret file not found: ensure file exists and is mounted correctly
# - Port conflict: ensure port 8080 is not exposed to host
```
### Frontend Not Loading
```bash
# Check Nginx error log
sudo tail -f /var/log/nginx/error.log
# Verify files exist
ls -la /opt/app/frontend/dist/
# Check Nginx config
sudo nginx -t
```
### Database Connection Issues
```bash
# Check database container
docker ps | grep mysql
# Check database logs
docker compose -f docker-compose.prod.yml logs db
# Test connection
docker exec -it lottery-mysql mysql -u root -p
```
### SSL Certificate Issues
```bash
# Check certificate
sudo certbot certificates
# Renew certificate
sudo certbot renew
# Check Nginx SSL config
sudo nginx -t
```
### WebSocket Not Working
```bash
# Check backend logs for WebSocket errors
docker compose -f docker-compose.prod.yml logs backend | grep -i websocket
# Verify Nginx WebSocket configuration
grep -A 10 "/ws" /opt/app/nginx/nginx.conf
```
## Security Checklist
- [ ] Strong database passwords set
- [ ] Secret file has restricted permissions (`chmod 640`, owned by `root:docker`)
- [ ] Secret file contains all required configuration values
- [ ] SSL certificate installed and auto-renewal configured
- [ ] Firewall configured (UFW recommended)
- [ ] Backend port 8080 not exposed to host
- [ ] MySQL port 3306 not exposed to host
- [ ] Regular backups scheduled
- [ ] Logs monitored for suspicious activity
## Firewall Setup (Optional but Recommended)
```bash
# Install UFW
sudo apt install ufw -y
# Allow SSH (IMPORTANT - do this first!)
sudo ufw allow 22/tcp
# Allow HTTP and HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Enable firewall
sudo ufw enable
# Check status
sudo ufw status
```
## Directory Structure Summary
```
/opt/app/
├── backend/
│ ├── Dockerfile
│ ├── docker-compose.prod.yml
│ ├── lottery-config.properties.template
│ ├── pom.xml
│ └── src/
├── frontend/
│ └── dist/ (Vite production build)
├── nginx/
│ └── nginx.conf
├── data/
│ └── avatars/ (persistent uploads)
└── mysql/
└── data/ (persistent DB storage)
/run/secrets/
└── lottery-config.properties (mounted secret configuration file)
```
## Support
If you encounter issues:
1. Check logs first (backend, Nginx, Docker)
2. Verify secret file exists at `/run/secrets/lottery-config.properties` and has correct values
3. Verify secret file permissions (`chmod 640`, owned by `root:docker`)
4. Check backend logs for "Loading configuration from mounted secret file" message
5. Ensure all directories exist and have proper permissions
6. Verify network connectivity between containers
7. Check SSL certificate validity
---
**Last Updated:** 2026-01-24
**Version:** 1.0

202
DOCKER_LOGGING_SETUP.md Normal file
View File

@@ -0,0 +1,202 @@
# Docker Logging Setup - Automatic Configuration
## Overview
The Docker setup is **automatically configured** to use external `logback-spring.xml` for runtime log level changes. No manual configuration needed!
## How It Works
### 1. Dockerfile Configuration
Both `Dockerfile` and `Dockerfile.inferno` automatically:
- Copy `logback-spring.xml` to `/app/config/logback-spring.xml` in the container
- Create `/app/logs` directory for log files
- Set default environment variables:
- `LOGGING_CONFIG=/app/config/logback-spring.xml`
- `LOG_DIR=/app/logs`
- Configure Java to use external config via `-Dlogging.config` and `-DLOG_DIR`
### 2. Docker Compose Configuration
Both `docker-compose.inferno.yml` and `docker-compose.prod.yml` automatically:
- **Mount external config**: `/opt/app/backend/config/logback-spring.xml``/app/config/logback-spring.xml` (read-write, editable on VPS)
- **Mount logs directory**: `/opt/app/logs``/app/logs` (persistent storage)
- **Set environment variables**: `LOGGING_CONFIG` and `LOG_DIR`
## Initial Setup (One-Time)
### Option 1: Use Setup Script (Recommended)
```bash
cd /opt/app/backend
# Make script executable (if not already)
chmod +x scripts/setup-logging.sh
# Run the script
./scripts/setup-logging.sh
```
This script will:
1. Create `/opt/app/backend/config` directory
2. Create `/opt/app/logs` directory
3. Extract `logback-spring.xml` from JAR (if available)
4. Set proper permissions
### Option 2: Manual Setup
```bash
# Create directories
mkdir -p /opt/app/backend/config
mkdir -p /opt/app/logs
# Extract logback-spring.xml from JAR
cd /opt/app/backend
unzip -p target/lottery-be-*.jar BOOT-INF/classes/logback-spring.xml > /opt/app/backend/config/logback-spring.xml
# Or copy from source (if building from source on VPS)
cp src/main/resources/logback-spring.xml /opt/app/backend/config/logback-spring.xml
# Set permissions
chmod 644 /opt/app/backend/config/logback-spring.xml
chmod 755 /opt/app/logs
```
## Usage
### Start Application
Just start Docker Compose as usual:
```bash
cd /opt/app/backend
docker compose -f docker-compose.inferno.yml up -d
```
The external logging configuration is **automatically active** - no additional steps needed!
### Change Log Level at Runtime
1. **Edit the mounted config file**:
```bash
nano /opt/app/backend/config/logback-spring.xml
```
2. **Change log level** (example: enable DEBUG):
```xml
<logger name="com.lottery" level="DEBUG"/>
```
3. **Save the file**. Logback will automatically reload within 30 seconds.
4. **Verify**:
```bash
# View logs from VPS
tail -f /opt/app/logs/lottery-be.log
# Or from inside container
docker exec lottery-backend tail -f /app/logs/lottery-be.log
```
### View Logs
```bash
# Real-time monitoring
tail -f /opt/app/logs/lottery-be.log
# Search for errors
grep -i "error" /opt/app/logs/lottery-be.log
# View last 100 lines
tail -n 100 /opt/app/logs/lottery-be.log
# From inside container
docker exec lottery-backend tail -f /app/logs/lottery-be.log
```
## File Locations
### On VPS (Host)
- **Config file**: `/opt/app/backend/config/logback-spring.xml` (editable)
- **Log files**: `/opt/app/logs/lottery-be.log` and rolled files
### Inside Container
- **Config file**: `/app/config/logback-spring.xml` (mounted from host)
- **Log files**: `/app/logs/lottery-be.log` (mounted to host)
## Verification
### Check Configuration is Active
```bash
# Check container logs for logback initialization
docker logs lottery-backend | grep -i "logback\|logging"
# Check mounted file exists
ls -la /opt/app/backend/config/logback-spring.xml
# Check log directory
ls -la /opt/app/logs/
# Check environment variables in container
docker exec lottery-backend env | grep LOG
```
### Expected Output
You should see:
- `LOGGING_CONFIG=/app/config/logback-spring.xml`
- `LOG_DIR=/app/logs`
- Log files appearing in `/opt/app/logs/`
## Benefits
✅ **No manual configuration needed** - Works automatically with Docker
✅ **Runtime log level changes** - Edit file, changes take effect in 30 seconds
✅ **No container restart required** - Changes apply without restarting
✅ **Persistent logs** - Logs survive container restarts
✅ **Editable config** - Edit logback-spring.xml directly on VPS
## Troubleshooting
### Config file not found
```bash
# Check if file exists
ls -la /opt/app/backend/config/logback-spring.xml
# If missing, extract from JAR or copy from source
./scripts/setup-logging.sh
```
### Logs not appearing
```bash
# Check log directory permissions
ls -ld /opt/app/logs
# Check container can write
docker exec lottery-backend ls -la /app/logs
# Check disk space
df -h /opt/app/logs
```
### Log level changes not working
1. Verify `scan="true" scanPeriod="30 seconds"` in logback-spring.xml
2. Check for XML syntax errors
3. Wait 30 seconds after saving
4. Check container logs for Logback errors:
```bash
docker logs lottery-backend | grep -i "logback\|error"
```
## Summary
**You don't need to do anything manually!** The Docker setup automatically:
- Uses external logback-spring.xml
- Mounts it as a volume (editable on VPS)
- Sets all required environment variables
- Configures log directory
Just run `docker compose up` and you're ready to go! 🚀

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# ====== Build stage ======
FROM maven:3.9.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -q -e -B dependency:go-offline
COPY src ./src
RUN mvn -q -e -B clean package -DskipTests
# ====== Runtime stage ======
FROM eclipse-temurin:17-jre
WORKDIR /app
# Copy fat jar from build stage
COPY --from=build /app/target/*.jar app.jar
# Copy startup script (optional - only used if secret file doesn't exist)
COPY scripts/create-secret-file.sh /app/create-secret-file.sh
RUN chmod +x /app/create-secret-file.sh
# Copy logback-spring.xml to config directory (can be overridden by volume mount)
COPY src/main/resources/logback-spring.xml /app/config/logback-spring.xml
# Create log directory
RUN mkdir -p /app/logs && chmod 755 /app/logs
# Expose port (for local/docker-compose/documentation)
EXPOSE 8080
# Default environment variables (can be overridden in docker-compose)
ENV JAVA_OPTS=""
ENV LOGGING_CONFIG="/app/config/logback-spring.xml"
ENV LOG_DIR="/app/logs"
# Start app
# If /run/secrets/lottery-config.properties exists (mounted), ConfigLoader will use it
# Otherwise, create-secret-file.sh will create it from env vars (for development/testing)
# Uses external logback-spring.xml for runtime log level changes
# Ensure logback-spring.xml exists and is a file (not a directory)
ENTRYPOINT ["sh", "-c", "if [ ! -f /run/secrets/lottery-config.properties ]; then /app/create-secret-file.sh; fi && if [ ! -f \"${LOGGING_CONFIG}\" ] || [ -d \"${LOGGING_CONFIG}\" ]; then echo 'Warning: ${LOGGING_CONFIG} not found or is a directory, using default from JAR'; LOGGING_CONFIG=''; fi && java $JAVA_OPTS ${LOGGING_CONFIG:+-Dlogging.config=${LOGGING_CONFIG}} -DLOG_DIR=${LOG_DIR} -jar app.jar"]

39
Dockerfile.inferno Normal file
View File

@@ -0,0 +1,39 @@
# ====== Build stage ======
FROM maven:3.9.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -q -e -B dependency:go-offline
COPY src ./src
RUN mvn -q -e -B clean package -DskipTests
# ====== Runtime stage ======
FROM eclipse-temurin:17-jre
WORKDIR /app
# Copy fat jar from build stage
COPY --from=build /app/target/*.jar app.jar
# Copy logback-spring.xml to config directory (can be overridden by volume mount)
COPY --from=build /app/src/main/resources/logback-spring.xml /app/config/logback-spring.xml
# Create log directory
RUN mkdir -p /app/logs && chmod 755 /app/logs
# Expose port (for internal communication with nginx)
EXPOSE 8080
# Default environment variables (can be overridden in docker-compose)
ENV JAVA_OPTS=""
ENV LOGGING_CONFIG="/app/config/logback-spring.xml"
ENV LOG_DIR="/app/logs"
# Start app with external logback config
# Ensure logback-spring.xml exists and is a file (not a directory)
ENTRYPOINT ["sh", "-c", "if [ ! -f \"${LOGGING_CONFIG}\" ] || [ -d \"${LOGGING_CONFIG}\" ]; then echo 'Warning: ${LOGGING_CONFIG} not found or is a directory, using default from JAR'; LOGGING_CONFIG=''; fi && java $JAVA_OPTS ${LOGGING_CONFIG:+-Dlogging.config=${LOGGING_CONFIG}} -DLOG_DIR=${LOG_DIR} -jar app.jar"]

88
EXTERNAL_API_old.md Normal file
View File

@@ -0,0 +1,88 @@
# Внешние API (токен в пути, без сессионной авторизации)
Описание трёх эндпоинтов для внешних систем. Токены задаются через переменные окружения на VPS.
---
## 1. GET /api/remotebet/{token}
Регистрация пользователя в текущий раунд комнаты с указанной ставкой (удалённая ставка).
**Параметры пути**
| Параметр | Тип | Описание |
|----------|--------|----------|
| token | string | Секретный токен (должен совпадать с `APP_REMOTE_BET_TOKEN`) |
**Query-параметры**
| Параметр | Тип | Обязательный | Описание |
|----------|--------|--------------|----------|
| user_id | integer| да | Внутренний ID пользователя (db_users_a.id) |
| room | integer| да | Номер комнаты: 1, 2 или 3 |
| amount | integer| да | Ставка в билетах (например, 5 = 5 билетов) |
**Ответ 200**
| Поле | Тип | Описание |
|--------------|--------|----------|
| success | boolean| Успешность операции |
| roundId | integer| ID раунда (или null) |
| room | integer| Номер комнаты |
| betTickets | integer| Размер ставки в билетах |
| error | string | Сообщение об ошибке (при success = false) |
**Коды ответа:** 200, 400, 403, 503
---
## 2. GET /api/check_user/{token}/{telegramId}
Получение информации о пользователе по Telegram ID.
**Параметры пути**
| Параметр | Тип | Описание |
|------------|--------|----------|
| token | string | Секретный токен (должен совпадать с `APP_CHECK_USER_TOKEN`) |
| telegramId | long | Telegram ID пользователя |
**Тело запроса:** отсутствует
**Ответ 200**
При успешном вызове всегда возвращается 200. По полю `found` можно определить, найден ли пользователь.
| Поле | Тип | Описание |
|-------------|--------|----------|
| found | boolean| true — пользователь найден, остальные поля заполнены; false — пользователь не найден, остальные поля null |
| dateReg | integer| Дата регистрации (при found=true) |
| tickets | number | Баланс в билетах (balance_a / 1_000_000) (при found=true) |
| depositTotal| integer| Сумма stars_amount по завершённым платежам (Stars) (при found=true) |
| refererId | integer| referer_id_1 из db_users_d (0 если нет) (при found=true) |
| roundsPlayed| integer| Количество сыгранных раундов (при found=true) |
**Коды ответа:** 200, 403, 500
---
## 3. POST /api/deposit_webhook/{token}
Уведомление об успешном пополнении пользователя (криптоплатёж). Создаётся платёж в статусе COMPLETED, начисляются билеты, обновляются баланс и статистика депозитов, создаётся транзакция типа DEPOSIT.
**Параметры пути**
| Параметр | Тип | Описание |
|----------|--------|----------|
| token | string | Секретный токен (должен совпадать с `APP_DEPOSIT_WEBHOOK_TOKEN`) |
**Тело запроса (application/json)**
| Поле | Тип | Обязательный | Описание |
|-----------|--------|--------------|----------|
| user_id | integer| да | Внутренний ID пользователя (db_users_a.id) |
| usd_amount| number | да | Сумма в USD в виде числа (например, 1.45 или 50) |
**Тело ответа:** пустое при успехе
**Коды ответа:** 200, 400, 403, 500

341
LOGGING_GUIDE.md Normal file
View File

@@ -0,0 +1,341 @@
# Logging Configuration Guide
## Overview
The application uses Logback for logging with the following features:
- **Runtime log level changes** (scan every 30 seconds)
- **Asynchronous file logging** (non-blocking I/O for high concurrency)
- **Automatic log rotation** (50MB per file, 14 days retention, 10GB total cap)
- **External configuration file** (editable on VPS without rebuilding)
## Log File Location
### Default Location
Logs are stored in: `./logs/` directory (relative to where the JAR is executed)
### Custom Location
Set the `LOG_DIR` environment variable or system property:
```bash
export LOG_DIR=/var/log/lottery-be
java -jar lottery-be.jar
```
Or:
```bash
java -DLOG_DIR=/var/log/lottery-be -jar lottery-be.jar
```
## Log File Naming
- **Current log**: `logs/lottery-be.log`
- **Rolled logs**: `logs/lottery-be-2024-01-15.0.log`, `logs/lottery-be-2024-01-15.1.log`, etc.
- **Max file size**: 50MB per file
- **Retention**: 14 days
- **Total size cap**: 10GB
## Using External logback-spring.xml on VPS
By default, `logback-spring.xml` is packaged inside the JAR. To use an external file on your VPS:
### Step 1: Copy logback-spring.xml to VPS
```bash
# Copy from JAR (if needed) or from your source code
# Place it in a location like: /opt/lottery-be/config/logback-spring.xml
# Or next to your JAR: /opt/lottery-be/logback-spring.xml
```
### Step 2: Start application with external config
```bash
# Option 1: System property
java -Dlogging.config=/opt/lottery-be/logback-spring.xml -jar lottery-be.jar
# Option 2: Environment variable
export LOGGING_CONFIG=/opt/lottery-be/logback-spring.xml
java -jar lottery-be.jar
# Option 3: In systemd service file
[Service]
Environment="LOGGING_CONFIG=/opt/lottery-be/logback-spring.xml"
ExecStart=/usr/bin/java -jar /opt/lottery-be/lottery-be.jar
```
### Step 3: Edit logback-spring.xml on VPS
```bash
# Edit the file
nano /opt/lottery-be/logback-spring.xml
# Change log level (example: change com.lottery from INFO to DEBUG)
# Find: <logger name="com.lottery" level="INFO"/>
# Change to: <logger name="com.lottery" level="DEBUG"/>
# Save and exit
# Logback will automatically reload within 30 seconds (scanPeriod="30 seconds")
```
## Linux Commands for Log Management
### Find Log Files
```bash
# Find all log files
find /opt/lottery-be -name "*.log" -type f
# Find logs in default location
ls -lh ./logs/
# Find logs with custom LOG_DIR
ls -lh /var/log/lottery-be/
```
### View Log Files
```bash
# View current log file (real-time)
tail -f logs/lottery-be.log
# View last 100 lines
tail -n 100 logs/lottery-be.log
# View with line numbers
cat -n logs/lottery-be.log | less
# View specific date's log
cat logs/lottery-be-2024-01-15.0.log
```
### Search Logs
```bash
# Search for errors
grep -i "error" logs/lottery-be.log
# Search for specific user ID
grep "userId=123" logs/lottery-be.log
# Search across all log files
grep -r "ERROR" logs/
# Search with context (5 lines before/after)
grep -C 5 "ERROR" logs/lottery-be.log
# Search and highlight
grep --color=always "ERROR\|WARN" logs/lottery-be.log | less -R
```
### Monitor Logs in Real-Time
```bash
# Follow current log
tail -f logs/lottery-be.log
# Follow and filter for errors only
tail -f logs/lottery-be.log | grep -i error
# Follow multiple log files
tail -f logs/lottery-be*.log
# Follow with timestamps
tail -f logs/lottery-be.log | while read line; do echo "[$(date '+%Y-%m-%d %H:%M:%S')] $line"; done
```
### Check Log File Sizes
```bash
# Check size of all log files
du -sh logs/*
# Check total size of logs directory
du -sh logs/
# List files sorted by size
ls -lhS logs/
# Check disk space
df -h
```
### Clean Old Logs
```bash
# Logback automatically deletes logs older than 14 days
# But you can manually clean if needed:
# Remove logs older than 7 days
find logs/ -name "*.log" -mtime +7 -delete
# Remove logs older than 14 days (matching logback retention)
find logs/ -name "*.log" -mtime +14 -delete
```
## Changing Log Level at Runtime
### Method 1: Edit logback-spring.xml (Recommended)
1. **Edit the external logback-spring.xml file**:
```bash
nano /opt/lottery-be/logback-spring.xml
```
2. **Change the logger level** (example: enable DEBUG for entire app):
```xml
<!-- Change from: -->
<logger name="com.lottery" level="INFO"/>
<!-- To: -->
<logger name="com.lottery" level="DEBUG"/>
```
3. **Save the file**. Logback will automatically reload within 30 seconds.
4. **Verify the change**:
```bash
tail -f logs/lottery-be.log
# You should see DEBUG logs appearing after ~30 seconds
```
### Method 2: Change Specific Logger
To change only a specific service (e.g., GameRoomService):
```xml
<!-- In logback-spring.xml, change: -->
<logger name="com.lottery.lottery.service.GameRoomService" level="WARN"/>
<!-- To: -->
<logger name="com.lottery.lottery.service.GameRoomService" level="DEBUG"/>
```
### Method 3: Change Root Level
To change the root level for all loggers:
```xml
<!-- In logback-spring.xml, change: -->
<root level="INFO">
<!-- To: -->
<root level="DEBUG">
```
**Note**: This will generate A LOT of logs. Use with caution in production.
## Log Levels Explained
- **ERROR**: Critical errors that need immediate attention
- **WARN**: Warnings that might indicate problems
- **INFO**: Important application events (round completion, payments, etc.)
- **DEBUG**: Detailed debugging information (very verbose, use only for troubleshooting)
## Default Configuration
- **Root level**: INFO
- **Application (com.lottery)**: INFO
- **High-traffic services**: WARN (GameRoomService, GameWebSocketController)
- **Infrastructure packages**: WARN (Spring, Hibernate, WebSocket, etc.)
## Performance Considerations
- **Asynchronous logging**: Logs are written asynchronously to prevent blocking main threads
- **Queue size**: 256 log entries (good for 1000+ concurrent users)
- **Never block**: If queue is full, lower-level logs (DEBUG/INFO) may be dropped, but WARN/ERROR are always kept
- **File I/O**: All file writes are non-blocking
## Troubleshooting
### Logs not appearing
1. Check log file location:
```bash
ls -la logs/
```
2. Check file permissions:
```bash
ls -l logs/lottery-be.log
# Ensure the application user has write permissions
```
3. Check disk space:
```bash
df -h
```
### Log level changes not taking effect
1. Verify scan is enabled in logback-spring.xml:
```xml
<configuration scan="true" scanPeriod="30 seconds">
```
2. Check for syntax errors in logback-spring.xml:
```bash
# Logback will log errors to console if config is invalid
```
3. Restart application if needed (shouldn't be necessary with scan enabled)
### Too many logs / Out of memory
1. Increase log level to WARN:
```xml
<root level="WARN">
```
2. Check log file sizes:
```bash
du -sh logs/*
```
3. Clean old logs manually if needed
## Example: Enabling DEBUG for Troubleshooting
1. **Edit logback-spring.xml**:
```bash
nano /opt/lottery-be/logback-spring.xml
```
2. **Change specific logger to DEBUG**:
```xml
<logger name="com.lottery.lottery.service.GameRoomService" level="DEBUG"/>
```
3. **Save and wait 30 seconds**
4. **Monitor logs**:
```bash
tail -f logs/lottery-be.log | grep "GameRoomService"
```
5. **After troubleshooting, change back to WARN**:
```xml
<logger name="com.lottery.lottery.service.GameRoomService" level="WARN"/>
```
## Systemd Service Example
If using systemd, here's an example service file:
```ini
[Unit]
Description=Lottery Backend Application
After=network.target
[Service]
Type=simple
User=lottery
WorkingDirectory=/opt/lottery-be
Environment="LOGGING_CONFIG=/opt/lottery-be/logback-spring.xml"
Environment="LOG_DIR=/var/log/lottery-be"
ExecStart=/usr/bin/java -jar /opt/lottery-be/lottery-be.jar
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```

94
PHPMYADMIN_QUICK_START.md Normal file
View File

@@ -0,0 +1,94 @@
# phpMyAdmin Quick Start Guide
## Quick Setup (Copy & Paste)
```bash
# 1. Navigate to project directory
cd /opt/app/backend/lottery-be
# 2. Load database password
source scripts/load-db-password.sh
# 3. Start phpMyAdmin
docker-compose -f docker-compose.prod.yml up -d phpmyadmin
# 4. Verify it's running
docker ps | grep phpmyadmin
# 5. Open firewall port
sudo ufw allow 8081/tcp
sudo ufw reload
# 6. Get your VPS IP (if you don't know it)
hostname -I | awk '{print $1}'
```
## Access phpMyAdmin
**URL**: `http://YOUR_VPS_IP:8081`
**Login Credentials**:
- **Server**: `db` (or leave default)
- **Username**: `root`
- **Password**: Get it with: `grep SPRING_DATASOURCE_PASSWORD /run/secrets/lottery-config.properties`
## Security: Restrict Access to Your IP Only
```bash
# Get your current IP
curl ifconfig.me
# Remove open access
sudo ufw delete allow 8081/tcp
# Allow only your IP (replace YOUR_IP with your actual IP)
sudo ufw allow from YOUR_IP to any port 8081
# Reload firewall
sudo ufw reload
```
## Verify Everything Works
```bash
# Check container is running
docker ps | grep phpmyadmin
# Check logs
docker logs lottery-phpmyadmin
# Test connection from browser
# Open: http://YOUR_VPS_IP:8081
```
## Common Issues
**Container won't start?**
```bash
# Make sure password is loaded
source scripts/load-db-password.sh
echo $DB_ROOT_PASSWORD
# Restart
docker-compose -f docker-compose.prod.yml restart phpmyadmin
```
**Can't access from browser?**
```bash
# Check firewall
sudo ufw status | grep 8081
# Check if port is listening
sudo netstat -tlnp | grep 8081
```
**Wrong password?**
```bash
# Get the correct password
grep SPRING_DATASOURCE_PASSWORD /run/secrets/lottery-config.properties
```
## Full Documentation
See `PHPMYADMIN_SETUP.md` for detailed instructions and troubleshooting.

355
PHPMYADMIN_SETUP.md Normal file
View File

@@ -0,0 +1,355 @@
# phpMyAdmin Setup Guide
This guide explains how to set up phpMyAdmin for managing your MySQL database on your VPS.
## Overview
- **phpMyAdmin Port**: 8081 (mapped to container port 80)
- **MySQL Service Name**: `db` (internal Docker network)
- **Database Name**: `lottery_db`
- **Network**: `lottery-network` (shared with MySQL and backend)
## Security Features
**MySQL port 3306 is NOT exposed** - Only accessible within Docker network
**phpMyAdmin accessible on port 8081** - Can be restricted via firewall
**Upload limit set to 64M** - Prevents large file uploads
**Uses same root password** - From your existing secret file
## Prerequisites
- Docker and Docker Compose installed on VPS
- Existing MySQL database running in Docker
- `DB_ROOT_PASSWORD` environment variable set (from secret file)
## Step-by-Step Deployment
### Step 1: Verify Current Setup
First, check that your MySQL container is running and the database password is accessible:
```bash
cd /opt/app/backend/lottery-be
# Check if MySQL container is running
docker ps | grep lottery-mysql
# Load database password (if not already set)
source scripts/load-db-password.sh
# Verify password is set
echo $DB_ROOT_PASSWORD
```
### Step 2: Update Docker Compose
The `docker-compose.prod.yml` file has already been updated with the phpMyAdmin service. Verify the changes:
```bash
# View the phpMyAdmin service configuration
grep -A 20 "phpmyadmin:" docker-compose.prod.yml
```
You should see:
- Service name: `phpmyadmin`
- Port mapping: `8081:80`
- PMA_HOST: `db`
- UPLOAD_LIMIT: `64M`
### Step 3: Start phpMyAdmin Service
```bash
cd /opt/app/backend/lottery-be
# Make sure DB_ROOT_PASSWORD is set
source scripts/load-db-password.sh
# Start only the phpMyAdmin service (MySQL should already be running)
docker-compose -f docker-compose.prod.yml up -d phpmyadmin
```
Or if you want to restart all services:
```bash
# Stop all services
docker-compose -f docker-compose.prod.yml down
# Start all services (including phpMyAdmin)
source scripts/load-db-password.sh
docker-compose -f docker-compose.prod.yml up -d
```
### Step 4: Verify phpMyAdmin is Running
```bash
# Check container status
docker ps | grep phpmyadmin
# Check logs for any errors
docker logs lottery-phpmyadmin
# Test if port 8081 is listening
netstat -tlnp | grep 8081
# or
ss -tlnp | grep 8081
```
### Step 5: Configure Firewall (UFW)
On Inferno Solutions VPS (Ubuntu), you need to allow port 8081:
```bash
# Check current UFW status
sudo ufw status
# Allow port 8081 (replace with your VPS IP if you want to restrict access)
sudo ufw allow 8081/tcp
# If you want to restrict to specific IP only (recommended for production):
# sudo ufw allow from YOUR_IP_ADDRESS to any port 8081
# Reload UFW
sudo ufw reload
# Verify the rule was added
sudo ufw status numbered
```
**Security Recommendation**: If you have a static IP, restrict access to that IP only:
```bash
# Replace YOUR_IP_ADDRESS with your actual IP
sudo ufw allow from YOUR_IP_ADDRESS to any port 8081
```
### Step 6: Access phpMyAdmin
Open your web browser and navigate to:
```
http://YOUR_VPS_IP:8081
```
**Example**: If your VPS IP is `37.1.206.220`, use:
```
http://37.1.206.220:8081
```
### Step 7: Login to phpMyAdmin
Use these credentials:
- **Server**: `db` (or leave as default - phpMyAdmin will auto-detect)
- **Username**: `root`
- **Password**: The value from `SPRING_DATASOURCE_PASSWORD` in your secret file
To get the password:
```bash
# On your VPS
grep SPRING_DATASOURCE_PASSWORD /run/secrets/lottery-config.properties
```
## Verification Checklist
After setup, verify:
- [ ] phpMyAdmin container is running: `docker ps | grep phpmyadmin`
- [ ] Port 8081 is accessible: `curl http://localhost:8081` (should return HTML)
- [ ] Firewall allows port 8081: `sudo ufw status | grep 8081`
- [ ] Can login to phpMyAdmin with root credentials
- [ ] Can see `lottery_db` database in phpMyAdmin
- [ ] MySQL port 3306 is NOT exposed: `netstat -tlnp | grep 3306` (should show nothing or only 127.0.0.1)
## Security Best Practices
### 1. Restrict Access by IP (Recommended)
Only allow your IP address to access phpMyAdmin:
```bash
# Find your current IP
curl ifconfig.me
# Allow only your IP
sudo ufw delete allow 8081/tcp
sudo ufw allow from YOUR_IP_ADDRESS to any port 8081
```
### 2. Use HTTPS (Optional but Recommended)
If you have a domain and SSL certificate, you can set up Nginx as a reverse proxy:
```nginx
# /etc/nginx/sites-available/phpmyadmin
server {
listen 443 ssl;
server_name phpmyadmin.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:8081;
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;
}
}
```
### 3. Change Default phpMyAdmin Behavior
You can add additional security settings to the phpMyAdmin service in `docker-compose.prod.yml`:
```yaml
environment:
# ... existing settings ...
# Disable certain features for security
PMA_CONTROLUSER: ''
PMA_CONTROLPASS: ''
# Enable HTTPS only (if using reverse proxy)
# PMA_ABSOLUTE_URI: https://phpmyadmin.yourdomain.com
```
### 4. Regular Updates
Keep phpMyAdmin updated:
```bash
# Pull latest image
docker-compose -f docker-compose.prod.yml pull phpmyadmin
# Restart service
docker-compose -f docker-compose.prod.yml up -d phpmyadmin
```
## Troubleshooting
### phpMyAdmin Container Won't Start
```bash
# Check logs
docker logs lottery-phpmyadmin
# Common issues:
# 1. DB_ROOT_PASSWORD not set
source scripts/load-db-password.sh
docker-compose -f docker-compose.prod.yml up -d phpmyadmin
# 2. MySQL container not running
docker-compose -f docker-compose.prod.yml up -d db
```
### Cannot Connect to Database
```bash
# Verify MySQL is accessible from phpMyAdmin container
docker exec lottery-phpmyadmin ping -c 3 db
# Check if MySQL is healthy
docker ps | grep lottery-mysql
docker logs lottery-mysql | tail -20
```
### Port 8081 Not Accessible
```bash
# Check if port is listening
sudo netstat -tlnp | grep 8081
# Check firewall
sudo ufw status
# Check if container is running
docker ps | grep phpmyadmin
# Restart phpMyAdmin
docker-compose -f docker-compose.prod.yml restart phpmyadmin
```
### "Access Denied" When Logging In
1. Verify password is correct:
```bash
grep SPRING_DATASOURCE_PASSWORD /run/secrets/lottery-config.properties
```
2. Verify `DB_ROOT_PASSWORD` matches:
```bash
source scripts/load-db-password.sh
echo $DB_ROOT_PASSWORD
```
3. Test MySQL connection directly:
```bash
docker exec -it lottery-mysql mysql -u root -p
# Enter the password when prompted
```
## Spring Boot Configuration Verification
Your Spring Boot application should be using the Docker service name for the database connection. Verify:
1. **Secret file** (`/run/secrets/lottery-config.properties`) should contain:
```
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db
```
2. **NOT using localhost**:
- ❌ Wrong: `jdbc:mysql://localhost:3306/lottery_db`
- ✅ Correct: `jdbc:mysql://db:3306/lottery_db`
To verify:
```bash
grep SPRING_DATASOURCE_URL /run/secrets/lottery-config.properties
```
## Maintenance Commands
```bash
# View phpMyAdmin logs
docker logs lottery-phpmyadmin
# Restart phpMyAdmin
docker-compose -f docker-compose.prod.yml restart phpmyadmin
# Stop phpMyAdmin
docker-compose -f docker-compose.prod.yml stop phpmyadmin
# Start phpMyAdmin
docker-compose -f docker-compose.prod.yml start phpmyadmin
# Remove phpMyAdmin (keeps data)
docker-compose -f docker-compose.prod.yml rm -f phpmyadmin
# Update phpMyAdmin to latest version
docker-compose -f docker-compose.prod.yml pull phpmyadmin
docker-compose -f docker-compose.prod.yml up -d phpmyadmin
```
## Quick Reference
| Item | Value |
|------|-------|
| **URL** | `http://YOUR_VPS_IP:8081` |
| **Username** | `root` |
| **Password** | From `SPRING_DATASOURCE_PASSWORD` in secret file |
| **Server** | `db` (auto-detected) |
| **Database** | `lottery_db` |
| **Container** | `lottery-phpmyadmin` |
| **Port** | `8081` (host) → `80` (container) |
| **Network** | `lottery-network` |
## Next Steps
After phpMyAdmin is set up:
1. ✅ Test login and database access
2. ✅ Verify you can see all tables in `lottery_db`
3. ✅ Set up IP restrictions for better security
4. ✅ Consider setting up HTTPS via Nginx reverse proxy
5. ✅ Document your access credentials securely

217
QUICK_REFERENCE.md Normal file
View File

@@ -0,0 +1,217 @@
# Quick Reference - VPS Deployment
## Common Commands
### Docker Compose (Backend)
```bash
cd /opt/app/backend
# Start services
docker compose -f docker-compose.prod.yml up -d
# Stop services
docker compose -f docker-compose.prod.yml down
# Restart services
docker compose -f docker-compose.prod.yml restart
# View logs
docker compose -f docker-compose.prod.yml logs -f
# Rebuild and restart
docker compose -f docker-compose.prod.yml up -d --build
# Check status
docker compose -f docker-compose.prod.yml ps
```
### Nginx
```bash
# Test configuration
sudo nginx -t
# Reload configuration
sudo systemctl reload nginx
# Restart Nginx
sudo systemctl restart nginx
# Check status
sudo systemctl status nginx
# View logs
sudo tail -f /var/log/nginx/error.log
sudo tail -f /var/log/nginx/access.log
```
### SSL Certificate
```bash
# Renew certificate
sudo certbot renew
# Test renewal
sudo certbot renew --dry-run
# Check certificates
sudo certbot certificates
```
### Database
```bash
# Load database password from secret file (if not already loaded)
cd /opt/app/backend
source scripts/load-db-password.sh
# Backup
docker exec lottery-mysql mysqldump -u root -p${DB_PASSWORD} lottery_db > backup_$(date +%Y%m%d).sql
# Restore
docker exec -i lottery-mysql mysql -u root -p${DB_PASSWORD} lottery_db < backup.sql
# Access MySQL shell
docker exec -it lottery-mysql mysql -u root -p
```
### Health Checks
```bash
# Backend health
curl http://localhost:8080/actuator/health
# Frontend
curl https://yourdomain.com/
# API endpoint
curl https://yourdomain.com/api/health
```
### Logs
```bash
# Backend logs
cd /opt/app/backend
docker compose -f docker-compose.prod.yml logs -f backend
# Database logs
docker compose -f docker-compose.prod.yml logs -f db
# All logs
docker compose -f docker-compose.prod.yml logs -f
# Nginx error log
sudo tail -f /var/log/nginx/error.log
```
### File Permissions
```bash
# Fix avatar directory permissions
sudo chown -R $USER:$USER /opt/app/data/avatars
sudo chmod -R 755 /opt/app/data/avatars
# Secure secret file
sudo chmod 640 /run/secrets/lottery-config.properties
sudo chown root:docker /run/secrets/lottery-config.properties
```
### Update Application
```bash
# Backend update
cd /opt/app/backend
git pull # or copy new files
docker compose -f docker-compose.prod.yml up -d --build
# Frontend update
# 1. Build locally: npm run build
# 2. Copy dist/ to /opt/app/frontend/dist/
scp -r dist/* user@vps:/opt/app/frontend/dist/
```
## Troubleshooting
### Backend won't start
```bash
# Check logs
docker compose -f docker-compose.prod.yml logs backend
# Check secret file exists and is readable
sudo ls -la /run/secrets/lottery-config.properties
# Verify secret file is loaded (check logs for "Loading configuration from mounted secret file")
docker compose -f docker-compose.prod.yml logs backend | grep "Loading configuration"
# Verify database is ready
docker compose -f docker-compose.prod.yml ps db
```
### Frontend not loading
```bash
# Check Nginx config
sudo nginx -t
# Verify files exist
ls -la /opt/app/frontend/dist/
# Check Nginx error log
sudo tail -f /var/log/nginx/error.log
```
### WebSocket issues
```bash
# Check backend logs
docker compose -f docker-compose.prod.yml logs backend | grep -i websocket
# Verify Nginx WebSocket config
grep -A 10 "/ws" /opt/app/nginx/nginx.conf
```
### Database connection failed
```bash
# Check database container
docker ps | grep mysql
# Check database logs
docker compose -f docker-compose.prod.yml logs db
# Test connection
docker exec -it lottery-mysql mysql -u root -p
```
## File Locations
```
Backend source: /opt/app/backend/
Frontend build: /opt/app/frontend/dist/
Nginx config: /opt/app/nginx/nginx.conf
Avatar storage: /opt/app/data/avatars/
Database data: /opt/app/mysql/data/ (via Docker volume)
Secret file: /run/secrets/lottery-config.properties
```
## Configuration Variables
Required in `/run/secrets/lottery-config.properties`:
- `SPRING_DATASOURCE_URL`
- `SPRING_DATASOURCE_USERNAME`
- `SPRING_DATASOURCE_PASSWORD`
- `TELEGRAM_BOT_TOKEN`
- `TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN`
- `TELEGRAM_FOLLOW_TASK_CHANNEL_ID`
- `FRONTEND_URL`
Optional:
- `APP_AVATAR_STORAGE_PATH`
- `APP_AVATAR_PUBLIC_BASE_URL`
- `APP_SESSION_MAX_ACTIVE_PER_USER`
- `APP_SESSION_CLEANUP_BATCH_SIZE`
- `APP_SESSION_CLEANUP_MAX_BATCHES`
- `GEOIP_DB_PATH`
**Note:** The MySQL container also needs `DB_PASSWORD` and `DB_ROOT_PASSWORD` as environment variables (should match `SPRING_DATASOURCE_PASSWORD`).

543
README.md Normal file
View File

@@ -0,0 +1,543 @@
# Lottery Backend
Spring Boot backend application for Lottery project.
## Technology Stack
- **Java 17**
- **Spring Boot 3.2.0**
- **MySQL 8.0** (using INT/BIGINT only, no floating point numbers)
- **Flyway** (database migrations)
- **Docker** (containerization)
- **Maven** (build tool)
## Local Development
### Prerequisites
- Java 17 JDK
- Maven 3.9+
- Docker and Docker Compose (for local MySQL)
### Setup
1. **Clone the repository**
2. **Start MySQL using Docker Compose**:
```bash
docker-compose up -d db
```
3. **Create `.env` file** (for local development):
```env
DB_NAME=lottery_db
DB_USERNAME=root
DB_PASSWORD=password
DB_ROOT_PASSWORD=password
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
FRONTEND_URL=http://localhost:5173
```
4. **Run the application**:
```bash
mvn spring-boot:run
```
Or build and run with Docker:
```bash
docker-compose up --build
```
### Database Migrations
Flyway automatically runs migrations on startup. Migrations are located in `src/main/resources/db/migration/`.
## Deployment Guides
### Railway Deployment (Staging Environment)
Railway is the primary deployment platform for staging. It provides built-in logging and easy environment variable management.
#### Step 1: Create Railway Project
1. Go to [Railway](https://railway.app) and sign in
2. Click **"New Project"**
3. Select **"Empty Project"**
#### Step 2: Create MySQL Database Service
1. In your Railway project, click **"+ New"** → **"Database"** → **"Add MySQL"**
2. Railway will automatically create a MySQL database
3. Note the connection details (you'll need them for the backend service)
#### Step 3: Create Backend Service
1. In your Railway project, click **"+ New"** → **"GitHub Repo"** (or **"Empty Service"**)
2. If using GitHub:
- Connect your GitHub account
- Select the `honey-be` repository
- Railway will automatically detect it's a Java/Maven project
3. If using Empty Service:
- Click **"Empty Service"**
- Connect to your repository or upload files
#### Step 4: Configure Environment Variables
In your backend service settings, go to **"Variables"** and add:
```env
SPRING_DATASOURCE_URL=${MYSQL_URL}
SPRING_DATASOURCE_USERNAME=${MYSQLUSER}
SPRING_DATASOURCE_PASSWORD=${MYSQLPASSWORD}
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
FRONTEND_URL=https://your-frontend-url.railway.app
PORT=8080
```
**Note**: Railway automatically provides `MYSQL_URL`, `MYSQLUSER`, and `MYSQLPASSWORD` when you add a MySQL database. You can reference them using `${MYSQL_URL}` syntax.
#### Step 5: Link Database to Backend
1. In your backend service, go to **"Settings"** → **"Connect"**
2. Find your MySQL database service
3. Click **"Connect"** - Railway will automatically add the MySQL connection variables
#### Step 6: Configure Health Check
1. In your backend service, go to **"Settings"** → **"Healthcheck"**
2. Set **Healthcheck Path** to:
```
/actuator/health/readiness
```
3. Railway will poll this endpoint and wait for HTTP 200 before marking deployment as active
#### Step 7: Deploy
1. Railway will automatically deploy when you push to the connected branch
2. Or manually trigger deployment from the Railway dashboard
3. Check the **"Deployments"** tab to monitor the deployment
#### Step 8: Get Backend URL
1. In your backend service, go to **"Settings"** → **"Networking"**
2. Click **"Generate Domain"** to get a public URL
3. Or use the default Railway domain
4. Copy the URL (e.g., `https://lottery-be-production.up.railway.app`)
#### Step 9: Create Frontend Service (Optional - if deploying frontend to Railway)
1. In your Railway project, click **"+ New"** → **"GitHub Repo"**
2. Select your `lottery-fe` repository
3. Railway will detect it's a Node.js project
4. Add environment variable:
```env
VITE_API_BASE_URL=https://your-backend-url.railway.app
```
5. Railway will automatically build and deploy
#### Step 10: Create Volume (Optional - for persistent data)
If you need persistent storage:
1. In your Railway project, click **"+ New"** → **"Volume"**
2. Name it (e.g., `lottery-data`)
3. Mount it to your service if needed
### Inferno Deployment (Production Environment)
Inferno Solution provides the production environment. It requires manual server setup and uses Docker Compose with nginx.
#### Prerequisites
- Access to Inferno Solution server (via SSH)
- Docker and Docker Compose installed on the server
- JDK 17 installed on the server (for building, though Docker handles runtime)
- Domain name configured (optional, for HTTPS)
#### Step 1: Prepare Server
1. **SSH into your Inferno server**:
```bash
ssh user@your-server-ip
```
2. **Install Docker** (if not installed):
```bash
# For Ubuntu/Debian
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
sudo usermod -aG docker $USER
```
3. **Install Docker Compose** (if not installed):
```bash
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```
4. **Install JDK 17** (for building):
```bash
# For Ubuntu/Debian
sudo apt update
sudo apt install openjdk-17-jdk -y
```
5. **Create project directory**:
```bash
mkdir -p /opt/lottery
cd /opt/lottery
```
#### Step 2: Clone Repository
```bash
cd /opt/lottery
git clone https://github.com/your-username/lottery-be.git
cd lottery-be
```
#### Step 3: Create Secret Configuration File
Create a tmpfs mount for secrets (more secure than .env files):
```bash
# Create tmpfs mount point
sudo mkdir -p /run/secrets
sudo chmod 700 /run/secrets
# Create secret file
sudo nano /run/secrets/lottery-config.properties
```
Add the following content (replace with your actual values):
```properties
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/lottery_db
SPRING_DATASOURCE_USERNAME=lottery_user
SPRING_DATASOURCE_PASSWORD=your_secure_mysql_password
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
FRONTEND_URL=https://your-frontend-domain.com
MYSQL_PASSWORD=your_secure_mysql_password
MYSQL_ROOT_PASSWORD=your_secure_mysql_root_password
```
**Important**:
- Use strong, unique passwords
- Keep this file secure (it's in tmpfs, so it's in-memory only)
- The file will be lost on reboot - you'll need to recreate it or use a startup script
#### Step 4: Configure Docker Compose for Inferno
The `docker-compose.inferno.yml` file is already configured. Make sure it's present in your repository.
#### Step 5: Build and Start Services
```bash
cd /opt/lottery/lottery-be
# Build and start all services
docker-compose -f docker-compose.inferno.yml up -d --build
```
This will:
- Build the backend application
- Start MySQL database
- Start backend service
- Start nginx reverse proxy
#### Step 6: Configure Nginx (if using custom domain)
1. **Edit nginx configuration**:
```bash
nano nginx/conf.d/lottery.conf
```
2. **Update server_name** (if using HTTPS):
```nginx
server {
listen 80;
server_name your-domain.com;
# ... rest of config
}
```
3. **Reload nginx**:
```bash
docker-compose -f docker-compose.inferno.yml restart nginx
```
#### Step 7: Set Up SSL (Optional - Recommended for Production)
1. **Install Certbot** (Let's Encrypt):
```bash
sudo apt install certbot python3-certbot-nginx -y
```
2. **Obtain SSL certificate**:
```bash
sudo certbot --nginx -d your-domain.com
```
3. **Update nginx config** to use HTTPS (uncomment HTTPS server block in `nginx/conf.d/lottery.conf`)
4. **Reload nginx**:
```bash
docker-compose -f docker-compose.inferno.yml restart nginx
```
#### Step 8: Configure Firewall
```bash
# Allow HTTP and HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Allow SSH (if not already allowed)
sudo ufw allow 22/tcp
# Enable firewall
sudo ufw enable
```
#### Step 9: Set Up Auto-Start on Boot
Create a systemd service to ensure services start on boot:
```bash
sudo nano /etc/systemd/system/lottery.service
```
Add:
```ini
[Unit]
Description=Lottery Application
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/lottery/lottery-be
ExecStart=/usr/local/bin/docker-compose -f docker-compose.inferno.yml up -d
ExecStop=/usr/local/bin/docker-compose -f docker-compose.inferno.yml down
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
```
Enable the service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable lottery.service
sudo systemctl start lottery.service
```
#### Step 10: Set Up Grafana Integration (Production Logging)
1. **Install Grafana and Loki** (on a separate server or same server):
```bash
# Follow Grafana/Loki installation guide
# https://grafana.com/docs/loki/latest/installation/
```
2. **Configure Promtail** to collect logs from Docker containers:
```yaml
# promtail-config.yml
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: lottery-backend
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: [__meta_docker_container_name]
regex: lottery-backend
action: keep
```
3. **Update docker-compose.inferno.yml** to add logging driver:
```yaml
app:
# ... existing config
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
4. **Configure Grafana datasource** to connect to Loki
#### Step 11: Monitor and Maintain
**Check service status**:
```bash
docker-compose -f docker-compose.inferno.yml ps
```
**View logs**:
```bash
# All services
docker-compose -f docker-compose.inferno.yml logs -f
# Specific service
docker-compose -f docker-compose.inferno.yml logs -f app
```
**Update application**:
```bash
cd /opt/lottery/lottery-be
git pull
docker-compose -f docker-compose.inferno.yml up -d --build
```
**Backup database**:
```bash
docker-compose -f docker-compose.inferno.yml exec db mysqldump -u lottery_user -p lottery_db > backup_$(date +%Y%m%d).sql
```
## Configuration
### Environment Variables
The application supports two configuration strategies:
1. **Environment Variables** (Railway): Set variables in Railway dashboard
2. **Secret File** (Inferno): Mount file at `/run/secrets/lottery-config.properties`
Priority: Secret file → Environment variables
### Required Variables
- `SPRING_DATASOURCE_URL` - MySQL connection URL
- `SPRING_DATASOURCE_USERNAME` - MySQL username
- `SPRING_DATASOURCE_PASSWORD` - MySQL password
- `TELEGRAM_BOT_TOKEN` - Telegram bot token for authentication
- `FRONTEND_URL` - Frontend URL for CORS configuration
### Database Schema
The database uses **INT** and **BIGINT** only - no floating point numbers.
Current tables:
- `users` - User information (id, telegram_id, username, created_at)
## API Endpoints
### Public Endpoints
- `GET /ping` - Health check (no auth required)
- `GET /actuator/health` - Application health
- `GET /actuator/health/readiness` - Readiness probe (checks database)
- `GET /actuator/health/liveness` - Liveness probe
### Protected Endpoints (require Telegram auth)
- `GET /api/users/current` - Get current user information
## Authorization
The application uses Telegram Mini App authentication:
1. Frontend sends `Authorization: tma <initData>` header
2. Backend validates Telegram signature
3. Backend creates/updates user in database
4. User is stored in thread-local context for the request
## Health Checks
- **Readiness**: `/actuator/health/readiness` - Checks database connectivity
- **Liveness**: `/actuator/health/liveness` - Checks if application is running
Configure Railway to use `/actuator/health/readiness` as the health check path.
## Logging
- **Railway**: Built-in logging available in Railway dashboard
- **Inferno**: Configure Grafana/Loki for log aggregation (see Grafana setup above)
## Troubleshooting
### Database Connection Issues
- Verify MySQL is running: `docker-compose ps`
- Check connection credentials in environment variables
- Review application logs for connection errors
### Authorization Failures
- Verify `TELEGRAM_BOT_TOKEN` is correct
- Check that frontend is sending `Authorization: tma <initData>` header
- Review backend logs for validation errors
### Deployment Issues
- Check health check endpoint: `curl http://your-url/actuator/health/readiness`
- Review Railway deployment logs
- Verify all environment variables are set correctly
## Development
### Running Tests
```bash
mvn test
```
### Building JAR
```bash
mvn clean package
```
### Running Locally with Docker
```bash
docker-compose up --build
```
## Project Structure
```
lottery-be/
├── src/
│ ├── main/
│ │ ├── java/com/lottery/lottery/
│ │ │ ├── config/ # Configuration classes
│ │ │ ├── controller/ # REST controllers
│ │ │ ├── dto/ # Data transfer objects
│ │ │ ├── exception/ # Exception handlers
│ │ │ ├── health/ # Health indicators
│ │ │ ├── logging/ # Grafana logging config
│ │ │ ├── model/ # JPA entities
│ │ │ ├── repository/ # JPA repositories
│ │ │ ├── security/ # Auth interceptor, UserContext
│ │ │ └── service/ # Business logic
│ │ └── resources/
│ │ ├── db/migration/ # Flyway migrations
│ │ └── application.yml # Application config
├── nginx/ # Nginx config (for Inferno)
├── Dockerfile # Dockerfile for Railway
├── Dockerfile.inferno # Dockerfile for Inferno
├── docker-compose.yml # Docker Compose for local/Railway
├── docker-compose.inferno.yml # Docker Compose for Inferno
└── pom.xml # Maven configuration
```
## License
[Your License Here]

356
ROLLING_UPDATE_GUIDE.md Normal file
View File

@@ -0,0 +1,356 @@
# Rolling Update Deployment Guide
This guide explains how to perform zero-downtime deployments using the rolling update strategy.
## Overview
The rolling update approach allows you to deploy new backend code without any downtime for users. Here's how it works:
1. **Build** new backend image while old container is still running
2. **Start** new container on port 8082 (old one stays on 8080)
3. **Health check** new container to ensure it's ready
4. **Switch** Nginx to point to new container (zero downtime)
5. **Stop** old container after grace period
## Architecture
```
┌─────────────┐
│ Nginx │ (Port 80/443)
│ (Host) │
└──────┬──────┘
├───> Backend (Port 8080) - Primary
└───> Backend-New (Port 8082) - Standby (during deployment)
```
## Prerequisites
1. **Nginx running on host** (not in Docker)
2. **Backend containers** managed by Docker Compose
3. **Health check endpoint** available at `/actuator/health/readiness`
4. **Sufficient memory** for two backend containers during deployment (~24GB)
## Quick Start
### 1. Make Script Executable
```bash
cd /opt/app/backend/lottery-be
chmod +x scripts/rolling-update.sh
```
### 2. Run Deployment
```bash
# Load database password (if not already set)
source scripts/load-db-password.sh
# Run rolling update
sudo ./scripts/rolling-update.sh
```
That's it! The script handles everything automatically.
## What the Script Does
1. **Checks prerequisites**:
- Verifies Docker and Nginx are available
- Ensures primary backend is running
- Loads database password
2. **Builds new image**:
- Builds backend-new service
- Uses Docker Compose build cache for speed
3. **Starts new container**:
- Starts `lottery-backend-new` on port 8082
- Waits for container initialization
4. **Health checks**:
- Checks `/actuator/health/readiness` endpoint
- Retries up to 30 times (60 seconds total)
- Fails deployment if health check doesn't pass
5. **Updates Nginx**:
- Backs up current Nginx config
- Updates upstream to point to port 8082
- Sets old backend (8080) as backup
- Tests Nginx configuration
6. **Reloads Nginx**:
- Uses `systemctl reload nginx` (zero downtime)
- Traffic immediately switches to new backend
7. **Stops old container**:
- Waits 10 seconds grace period
- Stops old backend container
- Old container can be removed or kept for rollback
## Manual Steps (If Needed)
If you prefer to do it manually or need to troubleshoot:
### Step 1: Build New Image
```bash
cd /opt/app/backend/lottery-be
source scripts/load-db-password.sh
docker-compose -f docker-compose.prod.yml --profile rolling-update build backend-new
```
### Step 2: Start New Container
```bash
docker-compose -f docker-compose.prod.yml --profile rolling-update up -d backend-new
```
### Step 3: Health Check
```bash
# Wait for container to be ready
sleep 10
# Check health
curl http://127.0.0.1:8082/actuator/health/readiness
# Check logs
docker logs lottery-backend-new
```
### Step 4: Update Nginx
```bash
# Backup config
sudo cp /etc/nginx/conf.d/lottery.conf /etc/nginx/conf.d/lottery.conf.backup
# Edit config
sudo nano /etc/nginx/conf.d/lottery.conf
```
Change upstream from:
```nginx
upstream lottery_backend {
server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
}
```
To:
```nginx
upstream lottery_backend {
server 127.0.0.1:8082 max_fails=3 fail_timeout=30s;
server 127.0.0.1:8080 backup;
}
```
### Step 5: Reload Nginx
```bash
# Test config
sudo nginx -t
# Reload (zero downtime)
sudo systemctl reload nginx
```
### Step 6: Stop Old Container
```bash
# Wait for active connections to finish
sleep 10
# Stop old container
docker-compose -f docker-compose.prod.yml stop backend
```
## Rollback Procedure
If something goes wrong, you can quickly rollback:
### Automatic Rollback
The script automatically rolls back if:
- Health check fails
- Nginx config test fails
- Nginx reload fails
### Manual Rollback
```bash
# 1. Restore Nginx config
sudo cp /etc/nginx/conf.d/lottery.conf.backup /etc/nginx/conf.d/lottery.conf
sudo systemctl reload nginx
# 2. Start old backend (if stopped)
cd /opt/app/backend/lottery-be
docker-compose -f docker-compose.prod.yml start backend
# 3. Stop new backend
docker-compose -f docker-compose.prod.yml --profile rolling-update stop backend-new
docker-compose -f docker-compose.prod.yml --profile rolling-update rm -f backend-new
```
## Configuration
### Health Check Settings
Edit `scripts/rolling-update.sh` to adjust:
```bash
HEALTH_CHECK_RETRIES=30 # Number of retries
HEALTH_CHECK_INTERVAL=2 # Seconds between retries
GRACE_PERIOD=10 # Seconds to wait before stopping old container
```
### Nginx Upstream Settings
Edit `/etc/nginx/conf.d/lottery.conf`:
```nginx
upstream lottery_backend {
server 127.0.0.1:8082 max_fails=3 fail_timeout=30s;
server 127.0.0.1:8080 backup; # Old backend as backup
keepalive 32;
}
```
## Monitoring
### During Deployment
```bash
# Watch container status
watch -n 1 'docker ps | grep lottery-backend'
# Monitor new backend logs
docker logs -f lottery-backend-new
# Check Nginx access logs
sudo tail -f /var/log/nginx/access.log
# Monitor memory usage
free -h
docker stats --no-stream
```
### After Deployment
```bash
# Verify new backend is serving traffic
curl http://localhost/api/health
# Check container status
docker ps | grep lottery-backend
# Verify Nginx upstream
curl http://localhost/actuator/health
```
## Troubleshooting
### Health Check Fails
```bash
# Check new container logs
docker logs lottery-backend-new
# Check if container is running
docker ps | grep lottery-backend-new
# Test health endpoint directly
curl -v http://127.0.0.1:8082/actuator/health/readiness
# Check database connection
docker exec lottery-backend-new wget -q -O- http://localhost:8080/actuator/health
```
### Nginx Reload Fails
```bash
# Test Nginx config
sudo nginx -t
# Check Nginx error logs
sudo tail -f /var/log/nginx/error.log
# Verify upstream syntax
sudo nginx -T | grep -A 5 upstream
```
### Memory Issues
If you run out of memory during deployment:
```bash
# Check memory usage
free -h
docker stats --no-stream
# Option 1: Reduce heap size temporarily
# Edit docker-compose.prod.yml, change JAVA_OPTS to use 8GB heap
# Option 2: Stop other services temporarily
docker stop lottery-phpmyadmin # If not needed
```
### Old Container Won't Stop
```bash
# Force stop
docker stop lottery-backend
# If still running, kill it
docker kill lottery-backend
# Remove container
docker rm lottery-backend
```
## Best Practices
1. **Test in staging first** - Always test the deployment process in a staging environment
2. **Monitor during deployment** - Watch logs and metrics during the first few deployments
3. **Keep backups** - The script automatically backs up Nginx config, but keep your own backups too
4. **Database migrations** - Ensure migrations are backward compatible or run them separately
5. **Gradual rollout** - For major changes, consider deploying during low-traffic periods
6. **Health checks** - Ensure your health check endpoint properly validates all dependencies
7. **Graceful shutdown** - Spring Boot graceful shutdown (30s) allows active requests to finish
## Performance Considerations
- **Build time**: First build takes longer, subsequent builds use cache
- **Memory**: Two containers use ~24GB during deployment (brief period)
- **Network**: No network interruption, Nginx handles the switch seamlessly
- **Database**: No impact, both containers share the same database
## Security Notes
- New container uses same secrets and configuration as old one
- No exposure of new port to internet (only localhost)
- Nginx handles all external traffic
- Health checks are internal only
## Next Steps
After successful deployment:
1. ✅ Monitor new backend for errors
2. ✅ Verify all endpoints are working
3. ✅ Check application logs
4. ✅ Remove old container image (optional): `docker image prune`
## Support
If you encounter issues:
1. Check logs: `docker logs lottery-backend-new`
2. Check Nginx: `sudo nginx -t && sudo tail -f /var/log/nginx/error.log`
3. Rollback if needed (see Rollback Procedure above)
4. Review this guide's Troubleshooting section

208
VPS_DEPLOYMENT_NOTES.md Normal file
View File

@@ -0,0 +1,208 @@
# VPS Deployment Notes - Logging Configuration
## Automatic Setup (Docker - Recommended)
The Docker setup is **automatically configured** to use external logback-spring.xml. No manual setup needed!
### How It Works
1. **Dockerfile** automatically:
- Copies logback-spring.xml to `/app/config/logback-spring.xml` in the container
- Sets `LOGGING_CONFIG` and `LOG_DIR` environment variables
- Configures Java to use external config
2. **docker-compose.inferno.yml** automatically:
- Mounts `/opt/app/backend/config/logback-spring.xml``/app/config/logback-spring.xml` (editable on VPS)
- Mounts `/opt/app/logs``/app/logs` (persistent log storage)
- Sets environment variables
### Initial Setup (One-Time)
Run the setup script to extract logback-spring.xml:
```bash
cd /opt/app/backend
# Make script executable (if not already)
chmod +x scripts/setup-logging.sh
# Run the script
./scripts/setup-logging.sh
```
Or run directly with bash:
```bash
bash scripts/setup-logging.sh
```
Or manually:
```bash
# Create directories
mkdir -p /opt/app/backend/config
mkdir -p /opt/app/logs
# Extract logback-spring.xml from JAR (if building on VPS)
unzip -p target/lottery-be-*.jar BOOT-INF/classes/logback-spring.xml > /opt/app/backend/config/logback-spring.xml
# Or copy from source
cp src/main/resources/logback-spring.xml /opt/app/backend/config/logback-spring.xml
# Set permissions
chmod 644 /opt/app/backend/config/logback-spring.xml
```
### Verify Configuration
After starting the container, check that external config is being used:
```bash
# Check container logs
docker logs lottery-backend | grep -i "logback\|logging"
# Check mounted file exists
ls -la /opt/app/backend/config/logback-spring.xml
# Check log directory
ls -la /opt/app/logs/
```
## Manual Setup (Non-Docker)
If you're not using Docker, follow these steps:
### 1. Extract logback-spring.xml from JAR
```bash
# Option 1: Extract from JAR
unzip -p lottery-be.jar BOOT-INF/classes/logback-spring.xml > /opt/lottery-be/logback-spring.xml
# Option 2: Copy from source code
scp logback-spring.xml user@vps:/opt/lottery-be/
```
### 2. Set Up Log Directory
```bash
# Create log directory
mkdir -p /var/log/lottery-be
chown lottery:lottery /var/log/lottery-be
chmod 755 /var/log/lottery-be
```
### 3. Update Your Startup Script/Service
Add these environment variables or system properties:
```bash
# In your startup script or systemd service:
export LOGGING_CONFIG=/opt/lottery-be/logback-spring.xml
export LOG_DIR=/var/log/lottery-be
java -jar lottery-be.jar
```
Or with system properties:
```bash
java -Dlogging.config=/opt/lottery-be/logback-spring.xml \
-DLOG_DIR=/var/log/lottery-be \
-jar lottery-be.jar
```
### 4. Verify External Config is Being Used
Check application startup logs for:
```
Loading configuration from: /opt/lottery-be/logback-spring.xml
```
If you see this, the external config is active.
## Changing Log Level at Runtime
### Quick Method (30 seconds to take effect)
**For Docker deployment:**
1. Edit the mounted logback-spring.xml:
```bash
nano /opt/app/backend/config/logback-spring.xml
```
2. Change the level (example: enable DEBUG):
```xml
<logger name="com.lottery" level="DEBUG"/>
```
3. Save the file. Logback will reload within 30 seconds automatically.
4. Verify:
```bash
tail -f /opt/app/logs/lottery-be.log
# Or from inside container:
docker exec lottery-backend tail -f /app/logs/lottery-be.log
```
**For non-Docker deployment:**
1. Edit the external logback-spring.xml:
```bash
nano /opt/lottery-be/logback-spring.xml
```
2. Change the level (example: enable DEBUG):
```xml
<logger name="com.lottery" level="DEBUG"/>
```
3. Save the file. Logback will reload within 30 seconds automatically.
4. Verify:
```bash
tail -f /var/log/lottery-be/lottery-be.log
```
### Common Log Level Changes
**Enable DEBUG for entire app:**
```xml
<logger name="com.lottery" level="DEBUG"/>
```
**Enable DEBUG for specific service:**
```xml
<logger name="com.lottery.lottery.service.GameRoomService" level="DEBUG"/>
```
**Enable DEBUG for WebSocket:**
```xml
<logger name="com.lottery.lottery.controller.GameWebSocketController" level="DEBUG"/>
```
**Change root level (affects everything):**
```xml
<root level="DEBUG">
```
## Important Notes
- **Default log level**: INFO (good for production)
- **High-traffic services**: WARN (GameRoomService, WebSocketController)
- **Auto-reload**: Changes take effect within 30 seconds
- **No restart needed**: Runtime log level changes work without restarting the app
- **Log location (Docker)**: `/opt/app/logs/` on VPS (mounted to `/app/logs` in container)
- **Log location (Non-Docker)**: `/var/log/lottery-be/` (or `./logs/` if LOG_DIR not set)
- **Config location (Docker)**: `/opt/app/backend/config/logback-spring.xml` on VPS
- **Config location (Non-Docker)**: `/opt/lottery-be/logback-spring.xml` (or your custom path)
## Troubleshooting
**If external config is not being used:**
1. Check the path is correct
2. Verify file permissions (readable by application user)
3. Check startup logs for errors
4. Ensure `-Dlogging.config=` or `LOGGING_CONFIG` is set correctly
**If log level changes don't work:**
1. Verify `scan="true" scanPeriod="30 seconds"` is in logback-spring.xml
2. Check for XML syntax errors
3. Wait 30 seconds after saving
4. Check application logs for Logback errors

188
VPS_DEPLOYMENT_SUMMARY.md Normal file
View File

@@ -0,0 +1,188 @@
# VPS Deployment Summary
## ✅ Compatibility Check
### Backend (lottery-be)
**Dockerfile**: Production-ready
- Multi-stage build (Maven → JRE)
- Exposes port 8080 (internal only)
- HTTP only (no HTTPS configuration)
- Binds to 0.0.0.0 by default (Spring Boot default)
- Graceful shutdown supported
**Configuration**: Externalized
- Database connection via environment variables
- Avatar storage path configurable (`APP_AVATAR_STORAGE_PATH`)
- All sensitive data via `.env` file
- CORS configured via `FRONTEND_URL` env var
**File Uploads**: Persistent storage ready
- Avatar path configurable and mountable as Docker volume
- Uses filesystem (not ephemeral storage)
- Path: `/app/data/avatars` (configurable)
**Networking**: Internal Docker network
- No ports exposed to host in production compose
- Accessible only via Nginx reverse proxy
- Uses Docker bridge network
**Production Readiness**:
- Logging to stdout/stderr (Docker logs)
- Health checks configured
- Graceful shutdown
- No dev-only features enabled
### Frontend (lottery-fe)
**Build Mode**: Production-ready
- `npm run build` creates static files in `dist/`
- Vite production build configured
**API Base URL**: Configurable
- Uses relative URLs in production (empty string)
- Falls back to `localhost:8080` in development
- Can be overridden via `VITE_API_BASE_URL` env var
**Docker Usage**: Optional
- Dockerfile exists but not required for VPS
- Static files can be served directly by Nginx
**Telegram Mini App**: Ready
- Works under HTTPS
- No localhost assumptions
- Uses relative API URLs
## 📋 Required Changes Made
### Frontend Changes
1. **API Base URL Configuration** (`src/api.js`, `src/auth/authService.js`, `src/services/gameWebSocket.js`, `src/utils/remoteLogger.js`)
- Changed to use relative URLs in production
- Falls back to `localhost:8080` only in development
- Pattern: `import.meta.env.VITE_API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8080")`
### Backend Changes
**No changes required** - Already production-ready!
## 📁 New Files Created
1. **`docker-compose.prod.yml`** - Production Docker Compose configuration
- No port exposure to host
- Persistent volumes for database and avatars
- Health checks configured
- Internal Docker network
2. **`nginx.conf.template`** - Nginx reverse proxy configuration
- HTTPS termination
- Frontend static file serving
- Backend API proxying (`/api/*`)
- WebSocket support (`/ws`)
- Avatar file serving (`/avatars/*`)
- Security headers
- Gzip compression
3. **`DEPLOYMENT_GUIDE.md`** - Comprehensive deployment guide
- Step-by-step instructions
- Troubleshooting section
- Maintenance commands
- Security checklist
## 🚀 Deployment Steps Overview
1. **VPS Setup**: Install Docker, Docker Compose, Nginx, Certbot
2. **Directory Structure**: Create `/opt/app` with subdirectories
3. **Backend Deployment**: Copy files, create secret file at `/run/secrets/lottery-config.properties`, build and start
4. **Frontend Deployment**: Build locally, copy `dist/` to VPS
5. **Nginx Configuration**: Copy template, update domain, link config
6. **SSL Setup**: Obtain Let's Encrypt certificate
7. **Telegram Webhook**: Update webhook URL
8. **Verification**: Test all endpoints and functionality
## 🔧 Configuration Required
### Backend Secret File (`/run/secrets/lottery-config.properties`)
All configuration is stored in a mounted secret file. See `lottery-config.properties.template` for the complete template.
**Required variables:**
- `SPRING_DATASOURCE_URL`
- `SPRING_DATASOURCE_USERNAME`
- `SPRING_DATASOURCE_PASSWORD`
- `TELEGRAM_BOT_TOKEN`
- `TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN`
- `TELEGRAM_FOLLOW_TASK_CHANNEL_ID`
- `FRONTEND_URL`
**Optional variables:**
- `APP_AVATAR_STORAGE_PATH`
- `APP_AVATAR_PUBLIC_BASE_URL`
- `APP_SESSION_MAX_ACTIVE_PER_USER`
- `APP_SESSION_CLEANUP_BATCH_SIZE`
- `APP_SESSION_CLEANUP_MAX_BATCHES`
- `GEOIP_DB_PATH`
**Note:** The MySQL container also needs `DB_PASSWORD` and `DB_ROOT_PASSWORD` as environment variables (should match `SPRING_DATASOURCE_PASSWORD`).
## 📂 Final Directory Structure on VPS
```
/opt/app/
├── backend/
│ ├── Dockerfile
│ ├── docker-compose.prod.yml
│ ├── lottery-config.properties.template
│ └── [source files]
├── frontend/
│ └── dist/ (Vite production build)
├── nginx/
│ └── nginx.conf
├── data/
│ └── avatars/ (persistent uploads)
└── mysql/
└── data/ (persistent DB storage)
/run/secrets/
└── lottery-config.properties (mounted secret file)
```
## ✅ Verification Checklist
Before going live:
- [ ] All environment variables set in `.env`
- [ ] Backend containers running (`docker ps`)
- [ ] Frontend `dist/` folder populated
- [ ] Nginx configuration tested (`nginx -t`)
- [ ] SSL certificate installed and valid
- [ ] Telegram webhook updated
- [ ] Health checks passing (`/actuator/health`)
- [ ] Frontend loads in browser
- [ ] API calls work (check browser console)
- [ ] WebSocket connects (game updates work)
- [ ] Avatar uploads work
- [ ] Database persists data (restart test)
## 🔒 Security Notes
- Backend port 8080 not exposed to host
- MySQL port 3306 not exposed to host
- HTTPS enforced (HTTP → HTTPS redirect)
- Strong passwords required
- `.env` file permissions restricted
- Firewall recommended (UFW)
## 📝 Next Steps
1. Review `DEPLOYMENT_GUIDE.md` for detailed instructions
2. Prepare your VPS (Ubuntu recommended)
3. Follow the step-by-step guide
4. Test thoroughly before going live
5. Set up monitoring and backups
---
**Status**: ✅ Ready for VPS Deployment
**Last Updated**: 2026-01-24

457
VPS_SETUP_FROM_SCRATCH.md Normal file
View File

@@ -0,0 +1,457 @@
# Honey VPS Setup from Scratch (Inferno)
This guide walks through setting up a new VPS for the **Honey** app with the same layout as your existing lottery VPS: backend + MySQL + phpMyAdmin in Docker, frontend and admin panel served by Nginx, logging under `/opt/app/logs`, secrets in `/run/secrets`, and MySQL backups to a backup VPS.
**Target layout (mirrors your lottery setup):**
- **Containers:** backend (honey-be), MySQL (honey_db), phpMyAdmin
- **Served by Nginx:** frontend (honey-fe), admin panel (honey-admin)
- **Paths:** `/opt/app` for app files, `/run/secrets` for config, `/opt/app/logs` for logs
- **Nginx:** main config + site config (e.g. `nginx.conf` + `sites-enabled/your-domain`)
---
## 1. VPS basics
### 1.1 System update and installs
```bash
sudo apt update && sudo apt upgrade -y
```
```bash
# Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
# Docker Compose (plugin)
sudo apt-get update
sudo apt-get install -y docker-compose-plugin
# Nginx + Certbot
sudo apt install -y nginx certbot python3-certbot-nginx
```
Log out and back in so `docker` group applies.
### 1.2 Directory structure under `/opt/app`
Create the same layout as lottery (backend, frontend, admin, nginx, data, logs, backups, mysql):
```bash
sudo mkdir -p /opt/app/backend
sudo mkdir -p /opt/app/frontend
sudo mkdir -p /opt/app/admin
sudo mkdir -p /opt/app/admin-panel
sudo mkdir -p /opt/app/nginx
sudo mkdir -p /opt/app/data/avatars
sudo mkdir -p /opt/app/logs
sudo mkdir -p /opt/app/backups
sudo mkdir -p /opt/app/mysql/data
sudo mkdir -p /opt/app/mysql/conf
sudo chown -R $USER:$USER /opt/app
sudo chmod -R 755 /opt/app
```
### 1.3 Git
```bash
sudo apt install git -y
git config --global alias.st status
ssh-keygen -t ed25519 -C "your_email@example.com" (replace email)
cat ~/.ssh/id_ed25519.pub (copy the key and add to origin)
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up (and login in local browser to tailscale)
```
Clone all repositories to respective folders.
---
## 2. Backend (honey-be) on VPS
### 2.1 Secret file (honey-config.properties)
Backend reads **`/run/secrets/honey-config.properties`** (see `ConfigLoader` and `docker-compose.prod.yml`). Create it from the template; **do not commit real values**.
On the VPS:
```bash
sudo mkdir -p /run/secrets
sudo cp /opt/app/backend/honey-be/honey-config.properties.template /run/secrets/honey-config.properties
sudo chmod 640 /run/secrets/honey-config.properties
sudo chown root:docker /run/secrets/honey-config.properties # if your user is in docker group, or root:$USER
```
Edit and set real values:
```bash
sudo nano /run/secrets/honey-config.properties
```
Notes:
- `SPRING_DATASOURCE_URL` - set to new DB URL
- `SPRING_DATASOURCE_PASSWORD` - just generate new secret
- `TELEGRAM_BOT_TOKEN` - token for Telegram bot
- `FRONTEND_URL` - put new domain here
- `APP_ADMIN_JWT_SECRET` - generate new secret using `openssl rand -base64 48` on VPS and put here
- `APP_TELEGRAM_WEBHOOK_TOKEN` - generate a new secret and set it using `POST https://api.telegram.org/bot<token>/setWebhook?url=https://<domain>/api/telegram/webhook/<secret>&max_connections=100`
- `PMA_ABSOLUTE_URI` - generate a new secret and set it. Don't forget to set the same to nginx
Create 2 files `admin_api_url` and `admin_base_path` with URL and secret path in `/run/secrets` folder.
### 2.3 Load DB password for Docker Compose
`docker-compose.prod.yml` expects `DB_ROOT_PASSWORD` (and MySQL healthcheck uses it). The repo has `scripts/load-db-password.sh` which reads the secret file; its currently wired to **lottery** path. For Honey, either:
- Edit `scripts/load-db-password.sh` and set:
- `SECRET_FILE="/run/secrets/honey-config.properties"`
- Or create a small wrapper that exports the same variables from `honey-config.properties`.
**When you need to source it:** Only for one-off manual `docker compose` runs (e.g. first-time start in §2.6, or starting phpMyAdmin in §4.1). You do **not** need to source it for deployment: `scripts/rolling-update.sh` loads the password from the secret file automatically when `DB_ROOT_PASSWORD` is not set.
**IMPORTANT**
- `Change Java memory in docker-compose.prod.yml when you know the PROD VPS characteristics.`
- `Change the rolling-update.sh script to match sites-enabled nginx file name for PROD when you know the domain`
### 2.4 Logging (logback) and config dir
Backend uses an external **logback** config so you can change log level without rebuilding. Create the config dir and put `logback-spring.xml` there:
```bash
mkdir -p /opt/app/backend/config
mkdir -p /opt/app/logs
```
Either copy from the JAR or from source:
```bash
# From backend dir
cp src/main/resources/logback-spring.xml /opt/app/backend/config/
# or extract from built JAR:
# unzip -p target/honey-be-*.jar BOOT-INF/classes/logback-spring.xml > /opt/app/backend/config/logback-spring.xml
```
Optional: run the existing setup script (it may still reference lottery paths; adjust or run the copy above):
```bash
cd /opt/app/backend/honey-be
./scripts/setup-logging.sh
```
Edit log level at any time:
```bash
nano /opt/app/backend/config/logback-spring.xml
# e.g. change <logger name="com.honey" level="INFO"/> to DEBUG
# Logback rescans periodically (e.g. 30s); no restart needed if scan is enabled
```
### 2.5 MySQL my.cnf (optional)
If you use a custom MySQL config in prod (e.g. for buffer pool):
```bash
# Create /opt/app/mysql/conf/my.cnf with your tuning; then in docker-compose.prod.yml
# the volume is already: /opt/app/mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf:ro
```
Note: `on Staged VPS it has 4G RAM, so don't forget to change it for PROD accordingly.`
### 2.6 First start (backend + DB only)
```bash
cd /opt/app/backend/honey-be
source scripts/load-db-password.sh
docker compose -f docker-compose.prod.yml up -d db
# wait for DB healthy
docker compose -f docker-compose.prod.yml up -d backend
```
Note: `for Staged use a separate docker compose.`
Check:
```bash
docker ps
curl -s http://127.0.0.1:8080/actuator/health/readiness
```
Backend should listen only on `127.0.0.1:8080` (Nginx will proxy to it). Do **not** expose 8080 to the internet.
---
## 3. Nginx
### 3.1 Split config (like your lottery VPS)
- `Take 2 files from already working VPS: nginx.conf and sites-enabled/<domain.com> and put to new VPS.`
- `Remove or comment lines reg certificates and change 2 listen lines`:
```bash
# SSL Certificates
ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem;
Change 'listen 443 ssl http2;' to 'listen 443;`
Change `listen [::]:443 ssl http2;` to `listen [::]:443;`
```
Enable and test:
```bash
sudo ln -s /etc/nginx/sites-available/testforapp.website /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### 3.3 SSL (Lets Encrypt)
```bash
sudo certbot --nginx -d testforapp.website
```
Certbot will adjust the server block for certificates. Reload Nginx if needed.
And remove redundant ssl/listen lines from server block to not override certbot's configs.
---
## 4. phpMyAdmin
### 4.1 Start phpMyAdmin container
`docker-compose.prod.yml` already defines a **phpmyadmin** service (port 8081, same network as `db`). Start it:
```bash
cd /opt/app/backend/honey-be
source scripts/load-db-password.sh
docker compose -f docker-compose.prod.yml up -d phpmyadmin
```
### 4.2 Access via Nginx (recommended)
Do **not** expose 8081 publicly. Proxy it via Nginx under a secret path (e.g. `/your-secret-pma-path/`), as in the example above. Set `PMA_ABSOLUTE_URI` in the secret file so phpMyAdmin generates correct URLs:
In `/run/secrets/honey-config.properties` add (or use env when running compose):
```properties
PMA_ABSOLUTE_URI=https://your-domain.com/your-secret-pma-path/
```
Then reload Nginx and open `https://your-domain.com/your-secret-pma-path/`. Login: user `root`, password = `SPRING_DATASOURCE_PASSWORD` from the same secret file.
### 4.3 Optional: UFW for phpMyAdmin
If you ever expose 8081 temporarily, restrict it:
```bash
sudo ufw allow from YOUR_IP to any port 8081
sudo ufw reload
```
Prefer keeping 8081 bound to 127.0.0.1 and using only Nginx proxy.
---
## 5. Frontend (honey-fe)
### 5.1 Build locally and upload
On your machine (e.g. in `honey-test-fe` or your honey-fe repo):
```bash
cd honey-test-fe # or honey-fe
npm install
npm run build
scp -r dist/* root@YOUR_VPS_IP:/opt/app/frontend/dist/
```
Or with rsync:
```bash
rsync -avz dist/ root@YOUR_VPS_IP:/opt/app/frontend/dist/
```
Ensure the apps API base URL is correct for production (e.g. relative `""` or `VITE_API_BASE_URL` for your domain).
---
## 6. Admin panel (honey-admin)
### 6.1 Build with secret (on VPS or locally)
Admin often has a build that injects a public URL or env. From the repo:
```bash
cd honey-admin
npm install
npm run build:with-secret
```
Then copy the built output to the Nginx admin root:
**If you build on the VPS:**
```bash
cd /opt/app/admin/honey-admin
npm run build:with-secret
cp -r dist/* /opt/app/admin-panel/
```
**If you build locally:**
```bash
scp -r dist/* root@YOUR_VPS_IP:/opt/app/admin-panel/
```
The Nginx location for admin (e.g. `/your-secret-admin-path/`) must serve this directory and support SPA routing (`try_files` to `index.html`).
---
## 7. Rolling backend updates
Use the existing **rolling-update** script so Nginx switches to a new backend container with no downtime.
### 7.1 Script adaptation for Honey
The script in the repo may still reference **lottery** container names and Nginx paths. For Honey:
- **Containers:** `honey-backend` (primary), `honey-backend-new` (standby).
- **Nginx:** same idea as lottery: one upstream `backend` with `server 127.0.0.1:8080` and optionally `server 127.0.0.1:8082 backup;`. The script flips which port is primary.
Edit `scripts/rolling-update.sh` and replace:
- `lottery-backend``honey-backend`
- `lottery-backend-new``honey-backend-new`
The script auto-detects Nginx config from paths like `/etc/nginx/sites-enabled/win-spin.live`. For Honey, either:
- Symlink or name your site config so the script finds it (e.g. add a similar check for `honey.conf` in the script), or
- Set the path explicitly before running: `export NGINX_CONF=/etc/nginx/sites-enabled/your-domain && sudo ./scripts/rolling-update.sh`
### 7.2 Run rolling update
From the backend directory, run (no need to source `load-db-password.sh` — the script does it):
```bash
cd /opt/app/backend/honey-be
chmod +x scripts/rolling-update.sh
sudo ./scripts/rolling-update.sh
```
The script loads `DB_ROOT_PASSWORD` from the secret file if not set, then: builds the new image, starts `backend-new` on 8082, health-checks it, points Nginx to 8082, reloads Nginx, then stops the old backend.
---
## 8. Logging
- **App logs:** `/opt/app/logs/` (mounted into backend container; path can be set via `LOG_DIR` / logback).
- **Config:** `/opt/app/backend/config/logback-spring.xml` (edit to change level; no restart if scan is enabled).
- **Nginx:** `/var/log/nginx/access.log`, `/var/log/nginx/error.log`.
View backend logs:
```bash
docker logs -f honey-backend
# or
tail -f /opt/app/logs/honey-be.log
```
---
## 9. MySQL backups to backup VPS
### 9.1 Backup script for Honey
Copy and adapt the existing **`scripts/backup-database.sh`** (or create a Honey-specific one). Set:
- `MYSQL_CONTAINER="honey-mysql"`
- `MYSQL_DATABASE="honey_db"`
- `SECRET_FILE="/run/secrets/honey-config.properties"`
- `BACKUP_FILENAME="honey_db_backup_${TIMESTAMP}.sql"` (and `.gz` if compressing)
- Remote path and retention (e.g. `BACKUP_VPS_PATH`, keep last 30 days) to match your backup server.
Ensure the script runs as root (or with sudo) so it can read `/run/secrets/honey-config.properties`, and that it uses the same `DB_PASSWORD` / `SPRING_DATASOURCE_PASSWORD` as in the secret file.
### 9.2 SSH key to backup VPS
On the Honey VPS:
```bash
ssh-keygen -t ed25519 -C "backup@honey-vps" -f ~/.ssh/backup_key
ssh-copy-id -i ~/.ssh/backup_key.pub user@BACKUP_VPS_IP
```
Test:
```bash
ssh -i ~/.ssh/backup_key user@BACKUP_VPS_IP "echo OK"
```
### 9.3 Cron (daily at 2 AM)
```bash
sudo crontab -e
```
Add:
```cron
0 2 * * * /opt/app/backend/honey-be/scripts/backup-database.sh >> /opt/app/logs/backup.log 2>&1
```
Use the Honey-adapted backup script path and ensure `backup-database.sh` uses `honey_db` and `honey-mysql`.
---
## 10. Quick reference
| Item | Honey |
|------|-----------------------------------------------------------------|
| **App root** | `/opt/app` |
| **Backend code** | `/opt/app/backend/honey-be` (honey-be) |
| **Frontend static** | `/opt/app/frontend/dist` (honey-fe build) |
| **Admin static** | `/opt/app/admin-panel` (honey-admin build) |
| **Secret file** | `/run/secrets/honey-config.properties` |
| **Logs** | `/opt/app/logs` (+ logback config in `/opt/app/backend/config`) |
| **Avatars** | `/opt/app/data/avatars` |
| **Nginx** | `/etc/nginx/nginx.conf` + `/etc/nginx/sites-enabled/your-domain` |
| **DB container** | `honey-mysql` |
| **DB name** | `honey_db` |
| **Backend containers** | `honey-backend`, `honey-backend-new` (rolling) |
| **phpMyAdmin** | Container `honey-phpmyadmin`, port 8081 → proxy via Nginx secret path |
### Deploy commands (summary)
- **Backend (rolling):**
`cd /opt/app/backend/honey-be && chmod +x scripts/rolling-update.sh && sudo ./scripts/rolling-update.sh`
(Password is loaded from the secret file inside the script.)
- **Frontend:**
Local: `npm run build` then `scp -r dist/* root@VPS:/opt/app/frontend/dist/`
- **Admin:**
On VPS: `cd /opt/app/admin/honey-admin && npm run build:with-secret && cp -r dist/* /opt/app/admin-panel/`
Or build locally and `scp -r dist/* root@VPS:/opt/app/admin-panel/`
- **Log level:**
Edit `/opt/app/backend/config/logback-spring.xml` (no restart if scan enabled).
---
## 11. Checklist after setup
- [ ] `/opt/app` structure created; ownership and permissions correct.
- [ ] `/run/secrets/honey-config.properties` created and filled (no placeholders).
- [ ] `load-db-password.sh` (and backup/rolling scripts) use Honey secret path and container/db names.
- [ ] Backend + DB + phpMyAdmin start; health check returns 200.
- [ ] Nginx site config in place; `nginx -t` OK; HTTPS works.
- [ ] Frontend and admin builds deployed to `/opt/app/frontend/dist` and `/opt/app/admin-panel`.
- [ ] API and WebSocket work through Nginx; avatars and admin paths load.
- [ ] phpMyAdmin reachable only via Nginx secret path; 8081 not public.
- [ ] Rolling update script updated for `honey-backend` / `honey-backend-new` and tested.
- [ ] Backup script adapted for `honey_db` / `honey-mysql`; cron runs and backups appear on backup VPS.
- [ ] Logs under `/opt/app/logs` and logback config under `/opt/app/backend/config`; log level change works.
This gives you the same layout and workflow as your lottery VPS, but for Honey (honey-be, honey-fe, honey-admin) with Nginx, phpMyAdmin, logging, and backups.

View File

@@ -0,0 +1,77 @@
version: "3.9"
services:
db:
image: mysql:8.0
container_name: honey-mysql
restart: always
environment:
MYSQL_DATABASE: honey_db
MYSQL_USER: honey_user
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes:
- honey_mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- honey-network
app:
build:
context: .
dockerfile: Dockerfile.inferno
container_name: honey-backend
restart: always
depends_on:
db:
condition: service_healthy
environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/honey_db
- SPRING_DATASOURCE_USERNAME=honey_user
- SPRING_DATASOURCE_PASSWORD=${MYSQL_PASSWORD}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- FRONTEND_URL=${FRONTEND_URL}
# Logging configuration (external logback-spring.xml)
- LOGGING_CONFIG=/app/config/logback-spring.xml
- LOG_DIR=/app/logs
volumes:
# Mount secret file from tmpfs
- /run/secrets:/run/secrets:ro
# Mount logback config directory (editable on VPS without rebuilding)
# Note: File must exist on host before mounting. Run setup-logging.sh first.
- /opt/app/backend/config:/app/config:rw
# Mount logs directory (persistent storage)
- /opt/app/logs:/app/logs
networks:
- honey-network
# Don't expose port directly - nginx will handle it
nginx:
image: nginx:alpine
container_name: honey-nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
# SSL certificates (if using HTTPS)
# - ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- app
networks:
- honey-network
volumes:
honey_mysql_data:
networks:
honey-network:
driver: bridge

193
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,193 @@
version: "3.9"
services:
db:
image: mysql:8.0
container_name: honey-mysql
restart: always
# Database credentials are read from the secret file via backend container
# The backend will construct the connection URL from SPRING_DATASOURCE_* properties
# For MySQL container, we need to set these via environment or use a separate secret
# Option 1: Use environment variables (for MySQL container only)
# Note: MYSQL_USER cannot be "root" - root user is configured via MYSQL_ROOT_PASSWORD only
environment:
MYSQL_DATABASE: honey_db
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
# Option 2: Mount secret file and read values (more secure)
# This requires parsing the secret file or using a script
# For simplicity, we'll use environment variables for MySQL container
# The secret file is primarily for the backend application
# Do NOT expose MySQL port to host - only accessible within Docker network
# ports:
# - "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
# Mount MySQL performance configuration (created on VPS at /opt/app/mysql/conf/my.cnf)
- /opt/app/mysql/conf/my.cnf:/etc/mysql/conf.d/my.cnf:ro
# Resource limits for MySQL (16GB buffer pool + 2GB overhead)
deploy:
resources:
limits:
cpus: '2.0'
memory: 18G
healthcheck:
# Use shell to access environment variable (Docker Compose doesn't interpolate in healthcheck arrays)
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD || exit 1"]
interval: 10s
timeout: 5s
retries: 5
networks:
- honey-network
backend:
build:
context: .
dockerfile: Dockerfile
container_name: honey-backend
depends_on:
db:
condition: service_healthy
# Expose backend port to localhost only (for Nginx on host to access)
# This is safe - only accessible from the host, not from internet
# Port 8080 is the primary/active backend
ports:
- "127.0.0.1:8080:8080"
# Labels for rolling update management
labels:
- "deployment.role=primary"
- "deployment.version=current"
volumes:
# Mount persistent avatar storage (absolute path for consistency)
- /opt/app/data/avatars:/app/data/avatars
# Mount secret configuration file (read-only)
- /run/secrets/honey-config.properties:/run/secrets/honey-config.properties:ro
# Mount logback config directory (editable on VPS without rebuilding)
# Note: File must exist on host before mounting. Run setup-logging.sh first.
- /opt/app/backend/config:/app/config:rw
# Mount logs directory (persistent storage)
- /opt/app/logs:/app/logs
environment:
# Java memory settings: 10GB heap (Xms/Xmx) + G1GC for low latency
# -Xms: Start with 10GB (prevents resizing overhead)
# -Xmx: Max limit 10GB
# -XX:+UseG1GC: Use G1 garbage collector (best for large heaps)
# -XX:MaxGCPauseMillis=200: Target max GC pause time
JAVA_OPTS: -Xms10g -Xmx10g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
# Logging configuration (external logback-spring.xml)
LOGGING_CONFIG: /app/config/logback-spring.xml
LOG_DIR: /app/logs
# Resource limits for backend (10GB heap + 2GB overhead for stack/metaspace)
deploy:
resources:
limits:
cpus: '4.0'
memory: 12G
networks:
- honey-network
restart: always
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
backend-new:
# This service is used during rolling updates
# It will be started manually via deployment script
build:
context: .
dockerfile: Dockerfile
container_name: honey-backend-new
depends_on:
db:
condition: service_healthy
# Port 8082 is the new/standby backend during deployment (8081 is used by phpMyAdmin)
ports:
- "127.0.0.1:8082:8080"
profiles:
- rolling-update
# Labels for rolling update management
labels:
- "deployment.role=standby"
- "deployment.version=new"
volumes:
# Mount persistent avatar storage (absolute path for consistency)
- /opt/app/data/avatars:/app/data/avatars
# Mount secret configuration file (read-only)
- /run/secrets/honey-config.properties:/run/secrets/honey-config.properties:ro
# Mount logback config directory (editable on VPS without rebuilding)
# Note: File must exist on host before mounting. Run setup-logging.sh first.
- /opt/app/backend/config:/app/config:rw
# Mount logs directory (persistent storage)
- /opt/app/logs:/app/logs
environment:
# Java memory settings: 10GB heap (Xms/Xmx) + G1GC for low latency
# -Xms: Start with 10GB (prevents resizing overhead)
# -Xmx: Max limit 10GB
# -XX:+UseG1GC: Use G1 garbage collector (best for large heaps)
# -XX:MaxGCPauseMillis=200: Target max GC pause time
JAVA_OPTS: -Xms10g -Xmx10g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
# Logging configuration (external logback-spring.xml)
LOGGING_CONFIG: /app/config/logback-spring.xml
LOG_DIR: /app/logs
# Resource limits for backend (10GB heap + 2GB overhead for stack/metaspace)
deploy:
resources:
limits:
cpus: '4.0'
memory: 12G
networks:
- honey-network
restart: always
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
phpmyadmin:
image: phpmyadmin:latest
container_name: honey-phpmyadmin
restart: always
depends_on:
db:
condition: service_healthy
# Expose phpMyAdmin to localhost only (Nginx will proxy it with path protection)
ports:
- "127.0.0.1:8081:80"
environment:
# Connect to MySQL service using Docker service name
PMA_HOST: db
PMA_PORT: 3306
# Use the same root password as MySQL container
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
# Security: Set upload limit
UPLOAD_LIMIT: 64M
# Configure absolute URI so phpMyAdmin generates correct URLs for assets
# This variable must be set from a secret file on the VPS (not in git)
# Example: export PMA_ABSOLUTE_URI="https://win-spin.live/your-secret-path"
PMA_ABSOLUTE_URI: ${PMA_ABSOLUTE_URI:-}
# Tell phpMyAdmin it's behind a proxy using HTTPS
PMA_SSL: "true"
# Trust proxy headers (X-Forwarded-Proto, etc.)
PMA_TRUSTED_PROXIES: "127.0.0.1"
networks:
- honey-network
# Resource limits for phpMyAdmin
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
volumes:
mysql_data:
driver: local
networks:
honey-network:
driver: bridge

138
docker-compose.staged.yml Normal file
View File

@@ -0,0 +1,138 @@
# Staged environment: same as prod but tuned for 8GB VPS (lower heap and container limits).
# Use with: docker compose -f docker-compose.staged.yml up -d
# Rolling update: scripts/rolling-update.staged.sh
version: "3.9"
services:
db:
image: mysql:8.0
container_name: honey-mysql
restart: always
environment:
MYSQL_DATABASE: honey_db
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
deploy:
resources:
limits:
cpus: '1.0'
memory: 2G
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD || exit 1"]
interval: 10s
timeout: 5s
retries: 5
networks:
- honey-network
backend:
build:
context: .
dockerfile: Dockerfile
container_name: honey-backend
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:8080:8080"
labels:
- "deployment.role=primary"
- "deployment.version=current"
volumes:
- /opt/app/data/avatars:/app/data/avatars
- /run/secrets/honey-config.properties:/run/secrets/honey-config.properties:ro
- /opt/app/backend/config:/app/config:rw
- /opt/app/logs:/app/logs
environment:
# Staged: 2GB heap (prod uses 10GB)
JAVA_OPTS: -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
LOGGING_CONFIG: /app/config/logback-spring.xml
LOG_DIR: /app/logs
deploy:
resources:
limits:
cpus: '2.0'
memory: 3G
networks:
- honey-network
restart: always
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
backend-new:
build:
context: .
dockerfile: Dockerfile
container_name: honey-backend-new
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:8082:8080"
profiles:
- rolling-update
labels:
- "deployment.role=standby"
- "deployment.version=new"
volumes:
- /opt/app/data/avatars:/app/data/avatars
- /run/secrets/honey-config.properties:/run/secrets/honey-config.properties:ro
- /opt/app/backend/config:/app/config:rw
- /opt/app/logs:/app/logs
environment:
JAVA_OPTS: -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
LOGGING_CONFIG: /app/config/logback-spring.xml
LOG_DIR: /app/logs
deploy:
resources:
limits:
cpus: '2.0'
memory: 3G
networks:
- honey-network
restart: always
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health/liveness"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
phpmyadmin:
image: phpmyadmin:latest
container_name: honey-phpmyadmin
restart: always
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:8081:80"
environment:
PMA_HOST: db
PMA_PORT: 3306
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
UPLOAD_LIMIT: 64M
PMA_ABSOLUTE_URI: ${PMA_ABSOLUTE_URI:-}
PMA_SSL: "true"
PMA_TRUSTED_PROXIES: "127.0.0.1"
networks:
- honey-network
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
volumes:
mysql_data:
driver: local
networks:
honey-network:
driver: bridge

41
docker-compose.yml Normal file
View File

@@ -0,0 +1,41 @@
version: "3.9"
services:
db:
image: mysql:8.0
container_name: honey-mysql
restart: always
environment:
MYSQL_DATABASE: ${DB_NAME:honey_db}
MYSQL_USER: ${DB_USERNAME:root}
MYSQL_PASSWORD: ${DB_PASSWORD:password}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:password}
ports:
- "3306:3306"
volumes:
- honey_mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:password}"]
interval: 10s
timeout: 5s
retries: 5
app:
env_file:
- .env
build: .
container_name: honey-backend
depends_on:
db:
condition: service_healthy
ports:
- "8080:8080"
environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/${DB_NAME:honey_db}
- SPRING_DATASOURCE_USERNAME=${DB_USERNAME:root}
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD:password}
volumes:
honey_mysql_data:

360
docs/GITEA_VPS_DEPLOY.md Normal file
View File

@@ -0,0 +1,360 @@
# Gitea Actions: Deploy honey-be to VPS on push to main
This guide sets up automatic deployment of **honey-be** to your Honey VPS (188.116.23.7) when you push to the `main` branch. The workflow runs on your Gitea runner, syncs the repo via rsync over SSH, then runs the rolling-update script on the VPS.
## Prerequisites
- Gitea with Actions enabled and at least one runner registered (e.g. `ubuntu-latest`).
- Honey VPS (188.116.23.7) with backend at `/opt/app/backend/honey-be` and `scripts/rolling-update.staged.sh`.
- The **Gitea server (or the machine where the runner runs)** must be able to reach 188.116.23.7 over the network (e.g. outbound SSH). Gitea itself can stay Tailscale-only.
---
## 1. Create the deploy SSH key on your Mac
**Where:** Local Mac (Terminal).
**1.1** Generate a dedicated deploy key (Ed25519, no passphrase):
```bash
ssh-keygen -t ed25519 -C "gitea-deploy-honey-be" -f ~/.ssh/gitea_deploy_honey_be -N ""
```
**1.2** Where the keys are on your Mac:
| File on your Mac | Purpose |
|------------------|--------|
| `~/.ssh/gitea_deploy_honey_be` | **Private key** — you will paste this into Gitea (Step 3). Never put this on the Staged VPS. |
| `~/.ssh/gitea_deploy_honey_be.pub` | **Public key** — you will put this on the Staged VPS (Step 2). |
**1.3** Optional: display the keys so you can copy them later.
To show the **public** key (for Step 2):
```bash
cat ~/.ssh/gitea_deploy_honey_be.pub
```
To show the **private** key (for Step 3 — paste into Gitea):
```bash
cat ~/.ssh/gitea_deploy_honey_be
```
Copy each output as a whole (including `ssh-ed25519 ...` for the public key and `-----BEGIN ... KEY-----` / `-----END ... KEY-----` for the private key). You can run these commands again anytime.
---
## 2. Put the public key on the Staged VPS
The **Staged VPS** is your Honey server: **188.116.23.7**. The Gitea runner will SSH into this machine as `root` (or your deploy user), so the **public** key must be in that users `authorized_keys` on the Staged VPS.
**2.1 — On your Local Mac:** Copy the public key to the clipboard (so you can paste it on the VPS):
```bash
cat ~/.ssh/gitea_deploy_honey_be.pub | pbcopy
```
Or just note the single line that looks like:
`ssh-ed25519 AAAAC3... gitea-deploy-honey-be`
**2.2 — Log in to the Staged VPS** from your Mac:
```bash
ssh root@188.116.23.7
```
(Use the same user you normally use to manage this server, e.g. `root` or a user with sudo. If you use a different user, replace `root` in the next steps with that user.)
**2.3 — On the Staged VPS (188.116.23.7):** Ensure `.ssh` exists and add the public key.
Run these commands **one by one** on the Staged VPS (after youre logged in via SSH):
```bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
```
Then add the public key. **Either** paste the line you copied (replace `PASTE_YOUR_PUBLIC_KEY_LINE_HERE` with the actual line):
```bash
echo 'PASTE_YOUR_PUBLIC_KEY_LINE_HERE' >> ~/.ssh/authorized_keys
```
**Or**, if you have the key in your Mac clipboard, on the Staged VPS you can do (from Mac, one line):
```bash
ssh root@188.116.23.7 "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '$(cat ~/.ssh/gitea_deploy_honey_be.pub)' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
```
If you ran the `echo ... >> authorized_keys` manually on the VPS, set permissions:
```bash
chmod 600 ~/.ssh/authorized_keys
```
**2.4 — Verify from your Mac:** Check that the deploy key can log in without a password:
```bash
ssh -i ~/.ssh/gitea_deploy_honey_be root@188.116.23.7 "echo OK"
```
You should see `OK`. If you get "Permission denied (publickey)", the public key was not added correctly to `~/.ssh/authorized_keys` on the Staged VPS.
---
## 3. Put the private key into Gitea (repository secret)
**Where:** Private key stays on your Mac; you only **paste its contents** into Gitea in the browser.
**3.1 — On your Local Mac:** Show the private key so you can copy it:
```bash
cat ~/.ssh/gitea_deploy_honey_be
```
Copy the **entire** output, including:
- `-----BEGIN OPENSSH PRIVATE KEY-----`
- all lines in the middle
- `-----END OPENSSH PRIVATE KEY-----`
**3.2 — In Gitea (browser):** Add it as a repository secret.
1. Open your repo: `http://100.122.146.65:3000/admin/honey-be` (or your Gitea URL over Tailscale).
2. Go to **Settings****Secrets and Variables****Actions**.
3. Under **Repository Secrets**, click **Add Secret**.
4. **Name:** `DEPLOY_SSH_PRIVATE_KEY`
5. **Value:** Paste the full private key you copied from the previous command. Paste once, with no extra spaces or blank lines before/after; the key must start with `-----BEGIN OPENSSH PRIVATE KEY-----` and end with `-----END OPENSSH PRIVATE KEY-----`. Do not paste the `.pub` (public) file by mistake — that will cause "Error loading key ... invalid format" in the workflow.
6. Save.
Optional secrets (workflow has defaults):
| Name | Value | When to set |
|------|--------|-------------|
| `DEPLOY_VPS_HOST` | `188.116.23.7` | Only if your Staged VPS has a different IP. |
| `DEPLOY_VPS_USER` | `root` | Only if the deploy user is not `root`. |
| `GITEA_HOST_IP` | e.g. `172.20.0.1` | If checkout fails with "Could not resolve host: server", set this to the **default gateway** seen from the job container. Run a debug job that runs `ip route show default \| awk '{print $3}'` and use that value. The workflow defaults to `172.20.0.1` if unset. |
---
## 4. Add the workflow file to the repo and push it
The workflow is a YAML file that tells Gitea **what to do** when you push to `main`. It does not get “installed” anywhere except inside the repository.
**4.1 — What the file is and where it lives**
- **Path in repo:** `.gitea/workflows/deploy-vps.yaml`
- **What it does:** On every push to `main`, Gitea runs a job that: checks out the code → installs SSH/rsync → syncs the repo to the Staged VPS at `/opt/app/backend/honey-be` → runs `sudo ./scripts/rolling-update.staged.sh` on the VPS.
- Gitea only runs workflows that exist **on the branch you push**. So this file must be committed and pushed to `main`.
**4.2 — What you need to do**
**Where:** Local Mac (in your honey-be project directory).
1. Confirm the workflow file exists:
```bash
cd /path/to/your/honey-be
ls -la .gitea/workflows/deploy-vps.yaml
```
2. If its there, add it to git, commit, and push to `main`:
```bash
git add .gitea/workflows/deploy-vps.yaml
git commit -m "Add VPS deploy workflow"
git push origin main
```
3. If the file is not there, create the directory and the file (the file contents are in this repo), then run the same `git add` / `git commit` / `git push` as above.
After the push, Gitea will see the workflow. It will run **only when** a runner is available (Step 5). So you can push the workflow first and set up the runner next, or the other way around.
---
## 5. Set up Gitea Actions and the runner
Gitea needs **Actions enabled** and at least one **runner** registered. The runner is the machine (or container) that actually runs the workflow steps. Your Gitea runs on a VPS with Docker; the runner is usually the **act_runner** container next to Gitea.
**5.1 — Enable Actions (if not already)**
**Where:** Gitea in the browser (admin or repo).
- **Site-wide:** Log in as admin → **Site Administration** → **Actions** → enable **Enable Actions**.
- Or at **repo level:** open **honey-be** → **Settings** → **Actions** → enable if there is an option.
**5.2 — Ensure the runner container is running**
**Where:** Gitea VPS (the server where Giteas Docker runs, e.g. 100.122.146.65 over Tailscale).
SSH into the Gitea server and check that both `gitea` and `gitea_runner` (or your runner container name) are up:
```bash
docker ps
```
You should see something like `gitea` and `gitea_runner`. If the runner container is stopped:
```bash
cd ~/gitea # or wherever your docker-compose.yml is
docker compose up -d
```
**5.3 — Get the runner registration token from Gitea**
**Where:** Gitea in the browser.
1. Open the **honey-be** repository.
2. Go to **Settings** → **Actions** → **Runners**.
3. Click **Create new runner** (or **Add runner** / **Register runner**).
4. Gitea will show a **registration token** and often a command or instructions. Copy the token (youll use it in the next step if the runner is not already registered).
**5.4 — Register the runner (if it isnt already)**
**Where:** Gitea VPS (SSH).
Your `docker-compose` must set **GITEA_INSTANCE_URL** to a URL the runner container can reach. Use the **Docker service name**, not `127.0.0.1`:
- **Use:** `GITEA_INSTANCE_URL=http://server:3000` (so the runner resolves `server` on the Docker network).
- **Do not use:** `GITEA_INSTANCE_URL=http://127.0.0.1:3000` — from inside the runner container, that is the container itself, so the runner stays "Offline" and never connects.
Your `docker-compose` may already set `GITEA_RUNNER_REGISTRATION_TOKEN` and the runner registers on startup. Check in Gitea: **Settings** → **Actions** → **Runners**. If you see a runner with status **Idle** and label **ubuntu-latest**, you can skip to 5.5.
If no runner appears (or it stays "Offline"), register or re-register on the Gitea VPS. If you changed `GITEA_INSTANCE_URL`, clear the runners persisted state first so it uses the new URL:
```bash
cd ~/gitea
docker compose stop runner
rm -rf ./data/runner/*
docker compose up -d --force-recreate runner
```
If you still need to register manually:
```bash
docker exec -it gitea_runner sh
```
Inside the container:
```bash
act_runner register --instance http://server:3000 --token PASTE_TOKEN_FROM_STEP_5.3
```
Then exit and restart the runner container so it runs the daemon:
```bash
exit
docker restart gitea_runner
```
**5.5 — Check that the runner has the right label**
**Where:** Gitea in the browser.
1. Go to **honey-be** → **Settings** → **Actions** → **Runners**.
2. You should see at least one runner with:
- **Status:** Idle (or Running when a job is active)
- **Labels:** must include **ubuntu-latest**, because the workflow file has `runs-on: ubuntu-latest`.
If your runner has a different label (e.g. `linux`), you have two options:
- **Option A:** In Gitea when registering the runner, add the label **ubuntu-latest** (if the UI lets you choose labels).
- **Option B:** Edit `.gitea/workflows/deploy-vps.yaml` and change the line `runs-on: ubuntu-latest` to your runners label (e.g. `runs-on: linux`), then commit and push.
**5.6 — Summary**
- Actions enabled in Gitea.
- Runner container running on the Gitea VPS.
- Runner registered and visible under **Settings** → **Actions** → **Runners** with label **ubuntu-latest** (or workflow updated to match your label).
---
## 6. Test the deployment
Do this **after** the workflow file is on `main` (Step 4) and the runner is set up (Step 5).
**6.1 — Trigger a run**
Push a commit to `main` (e.g. a small change or the workflow/docs you added):
```bash
cd /path/to/your/honey-be
git add .gitea/workflows/deploy-vps.yaml docs/GITEA_VPS_DEPLOY.md
git commit -m "Add VPS deploy workflow and guide"
git push origin main
```
**6.2 — Check the run in Gitea**
**Where:** Gitea in the browser.
1. Open **honey-be** → **Actions** (tab in the repo).
2. You should see a run for the “Deploy to VPS” workflow. Open it.
3. Confirm all steps are green. If something fails, open the failed step to see the log.
**6.3 — Check the Staged VPS**
**Where:** Staged VPS (188.116.23.7) or your Mac (SSH to VPS).
1. SSH in: `ssh root@188.116.23.7`
2. Check that code was updated: `ls -la /opt/app/backend/honey-be`
3. Check that the app restarted: `docker ps` (or your usual way to verify the backend is running).
---
## Troubleshooting
### Runner stays "Offline" / "Last Online Time: Never" / "Cannot ping the Gitea instance server"
- **Cause:** The runner container is trying to reach Gitea at `http://127.0.0.1:3000`. From inside the runner container, `127.0.0.1` is the container itself, not the Gitea server, so the connection is refused.
- **Fix:**
1. In your **docker-compose** on the Gitea VPS, set `GITEA_INSTANCE_URL=http://server:3000` (use the Docker service name of the Gitea container, e.g. `server`).
2. Clear the runner's persisted state and recreate the container so it re-registers with the new URL:
```bash
cd ~/gitea
docker compose stop runner
rm -rf ./data/runner/*
docker compose up -d --force-recreate runner
```
3. In Gitea → **Settings** → **Actions** → **Runners**, the runner should show **Idle** within a few seconds. If you see a new runner and the old one still "Offline", you can remove the old one in the UI.
---
### Checkout fails: "Could not resolve host: server" / "fatal: unable to access 'http://server:3000/...'"
- **Cause:** The workflow job runs inside a **separate** Docker container (started by the runner). That job container is not on the same Docker network as Gitea, so the hostname `server` does not resolve there. Changing Gitea's `LOCAL_ROOT_URL` or the host's `/etc/hosts` does not help, because the job container has its own network.
- **Fix:** The deploy workflow in this repo already uses a **manual checkout** that clones via the runner host's IP instead of `server`. If checkout still fails:
1. Add repository secret **GITEA_HOST_IP** with the default gateway as seen from the job container. To get it: run a one-off debug workflow that runs `ip route show default | awk '{print $3}'` and use the printed value (often `172.20.0.1` or `172.17.0.1`).
2. If you don't set the secret, the workflow defaults to `172.20.0.1`; if your Docker uses a different gateway, set **GITEA_HOST_IP** to that value.
---
### Setup SSH fails: "Error loading key ... invalid format"
- **Cause:** The **DEPLOY_SSH_PRIVATE_KEY** secret in Gitea is not valid: pasted with wrong line breaks, truncated, or the **public** key (`.pub`) was pasted by mistake.
- **Fix:**
1. On your Mac, verify the private key file: `ssh-keygen -y -f ~/.ssh/gitea_deploy_honey_be`. If that fails, generate a new key (Step 1) and add the new public key to the VPS and the new private key to Gitea.
2. Copy the **full** private key again: `cat ~/.ssh/gitea_deploy_honey_be | pbcopy` (or open the file and copy everything).
3. In Gitea → **Settings** → **Secrets and Variables** → **Actions**, edit **DEPLOY_SSH_PRIVATE_KEY** and paste the entire key. It must start with `-----BEGIN OPENSSH PRIVATE KEY-----` and end with `-----END OPENSSH PRIVATE KEY-----`, with no extra blank lines or spaces at the start/end. Make sure you are pasting the **private** key file, not the `.pub` file.
4. Save and re-run the workflow.
---
### Other issues
- **Permission denied (publickey)** (during rsync or SSH to Staged VPS)
Check: public key in `authorized_keys` on the Staged VPS, private key in `DEPLOY_SSH_PRIVATE_KEY`, no extra spaces/newlines when pasting. From your Mac, test: `ssh -i ~/.ssh/gitea_deploy_honey_be root@188.116.23.7 "echo OK"`.
- **Runner doesnt pick up the job**
In Gitea: **Settings** → **Actions** → **Runners**. Confirm runner is “idle” and has label `ubuntu-latest`.
- **rsync or SSH fails from the job**
The job runs on the Gitea VPS (in a container). Ensure the Gitea VPS can reach the Staged VPS (188.116.23.7) on port 22. From the Gitea VPS host, test: `ssh -i /path/to/private_key root@188.116.23.7 "echo OK"`.
- **sudo or script fails on VPS**
SSH in as the deploy user and run manually:
`cd /opt/app/backend/honey-be && sudo ./scripts/rolling-update.staged.sh`
Fix permissions or sudo rules as needed.

View File

@@ -0,0 +1,62 @@
# Honey Application Configuration
# Copy this file to /run/secrets/honey-config.properties on your VPS
# Replace all placeholder values with your actual configuration
# ============================================
# Database Configuration
# ============================================
# SPRING_DATASOURCE_URL format: jdbc:mysql://<hostname>:<port>/<database-name>
#
# How to determine the URL:
# - Hostname: 'db' (this is the MySQL service name in docker-compose.prod.yml)
# * In Docker Compose, services communicate using their service names
# * The MySQL service is named 'db', so use 'db' as the hostname
# * Both containers are on the same Docker network, so 'db' resolves to the MySQL container
# - Port: '3306' (default MySQL port, internal to Docker network)
# - Database name: 'honey_db' (must match MYSQL_DATABASE in docker-compose.prod.yml)
#
# Example: jdbc:mysql://db:3306/honey_db
# └─┬─┘ └┬┘ └─┬──┘ └───┬────┘
# │ │ │ └─ Database name
# │ │ └─ Port (3306 is MySQL default)
# │ └─ Service name in docker-compose (acts as hostname)
# └─ JDBC protocol for MySQL
#
# IMPORTANT: Use 'db' as hostname, NOT 'localhost' or '127.0.0.1'
# This is an internal Docker network connection
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/honey_db
SPRING_DATASOURCE_USERNAME=root
SPRING_DATASOURCE_PASSWORD=your_secure_database_password_here
# ============================================
# Telegram Bot Configuration
# ============================================
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN=your_channel_checker_bot_token_here
TELEGRAM_FOLLOW_TASK_CHANNEL_ID=@your_channel_name
# ============================================
# Frontend Configuration
# ============================================
FRONTEND_URL=https://yourdomain.com
# ============================================
# Avatar Storage Configuration
# ============================================
APP_AVATAR_STORAGE_PATH=/app/data/avatars
APP_AVATAR_PUBLIC_BASE_URL=
APP_AVATAR_MAX_SIZE_BYTES=2097152
APP_AVATAR_MAX_DIMENSION=512
# ============================================
# Session Configuration (Optional - defaults shown)
# ============================================
APP_SESSION_MAX_ACTIVE_PER_USER=5
APP_SESSION_CLEANUP_BATCH_SIZE=5000
APP_SESSION_CLEANUP_MAX_BATCHES=20
# ============================================
# GeoIP Configuration (Optional)
# ============================================
GEOIP_DB_PATH=

View File

@@ -0,0 +1,242 @@
# Two frontends on the same backend:
# - Frontend 1: /opt/app/frontend/dist -> https://testforapp.website/ (build with base: '/')
# - Frontend 2: /opt/app/frontend/test-dist -> https://testforapp.website/test/ (build with base: '/test/')
upstream backend {
server 127.0.0.1:8082;
server 127.0.0.1:8080 backup;
keepalive 500;
}
map $http_referer $is_phpmyadmin_request {
~*18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905 1;
default 0;
}
# HTTPS server
server {
server_name testforapp.website;
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
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;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), midi=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), fullscreen=(self), payment=()";
root /opt/app/frontend/dist;
index index.html;
# --- Frontend 2 at /test/ (from test-dist) ---
location = /test {
return 301 /test/;
}
location ^~ /test/ {
alias /opt/app/frontend/test-dist/;
index index.html;
try_files $uri $uri/ /test/index.html;
expires 0;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "frame-ancestors 'self' https://web.telegram.org https://webk.telegram.org https://webz.telegram.org https://t.me telegram.org;" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), midi=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), fullscreen=(self), payment=()";
}
# Admin Panel - root path (redirects to trailing slash)
location = /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa {
return 301 /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/;
}
location ~ ^/dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/(assets/.+)$ {
alias /opt/app/admin-panel/$1;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/ {
alias /opt/app/admin-panel/;
index index.html;
try_files $uri $uri/ /dfab0676b6cb6b257370fb5743d8ddac42ab8153c2661072e8ef2717a10fcfaa/index.html;
expires 0;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
location /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905 {
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
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;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
gzip off;
proxy_set_header Accept-Encoding "";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering on;
proxy_buffer_size 8k;
proxy_buffers 16 8k;
proxy_busy_buffers_size 16k;
proxy_cookie_path / /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/;
sub_filter 'action="index.php?route=/' 'action="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php?route=/';
sub_filter 'action="index.php' 'action="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
sub_filter 'action="/index.php' 'action="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
sub_filter 'Location: /index.php' 'Location: /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
sub_filter 'Location: index.php' 'Location: /18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
sub_filter 'href="index.php' 'href="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
sub_filter 'href="/index.php' 'href="/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/index.php';
sub_filter_once off;
sub_filter_types text/html;
}
location ~ ^/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/(themes|js|favicon\.ico|libraries|templates|setup|config|tmp|index\.php) {
rewrite ^/18cac693dfdec1c7bd3080d3451d0545746081e2335c241cec5d39ff75822905/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
}
location ~ ^/(themes|js|favicon\.ico|libraries|templates|setup|config|tmp|index\.php) {
if ($is_phpmyadmin_request = 0) {
return 404;
}
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
}
location /videos/ {
root /usr/share/nginx/html;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
}
location ^~ /images/ {
root /usr/share/nginx/html;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
autoindex off;
}
# Frontend 1: static assets at root (from dist)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Frontend 1: SPA at / (from dist)
location / {
try_files $uri $uri/ /index.html;
expires 0;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "frame-ancestors 'self' https://web.telegram.org https://webk.telegram.org https://webz.telegram.org https://t.me telegram.org;" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), midi=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), fullscreen=(self), payment=()";
}
location ^~ /avatars/ {
alias /opt/app/data/avatars/;
expires 24h;
add_header Cache-Control "public, must-revalidate";
access_log off;
location ~ \.(php|html)$ {
deny all;
}
}
location /api/telegram/webhook/ {
access_log off;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
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_read_timeout 30s;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
location /api/ {
limit_req zone=api_limit burst=200 nodelay;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
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;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location = /phpmyadmin {
return 404;
}
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
server {
if ($host = testforapp.website) {
return 301 https://$host$request_uri;
}
listen 80;
listen [::]:80;
server_name testforapp.website;
return 404;
}

128
nginx.conf.template Normal file
View File

@@ -0,0 +1,128 @@
# Nginx configuration for Honey Application
# Place this file at: /opt/app/nginx/nginx.conf
#
# This configuration assumes:
# - Frontend static files are at: /opt/app/frontend/dist
# - Avatar files are at: /opt/app/data/avatars
# - Backend is accessible at: http://127.0.0.1:8080 (exposed to localhost only)
# - HTTPS is handled by Nginx (SSL certificates should be configured separately)
# Upstream backend (using localhost since Nginx runs on host, not in Docker)
upstream backend {
server 127.0.0.1:8080;
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name _; # Replace with your domain name
# For Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other HTTP traffic to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server
server {
listen 443 ssl http2;
server_name _; # Replace with your domain name
# SSL certificate configuration
# Update these paths to your actual certificate files
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# SSL configuration (recommended settings)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Root directory for frontend static files
root /opt/app/frontend/dist;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# Serve avatar files with aggressive caching
# Avatars are served by backend, but we can also serve them directly from filesystem
location /avatars/ {
alias /opt/app/data/avatars/;
expires 1h;
add_header Cache-Control "public, immutable";
access_log off;
}
# Backend API endpoints
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
proxy_cache_bypass $http_upgrade;
# Timeouts for long-running requests
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# WebSocket endpoint (for game updates)
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
# WebSocket timeouts
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
# Frontend static files (SPA routing)
location / {
try_files $uri $uri/ /index.html;
expires 1h;
add_header Cache-Control "public";
}
# Cache static assets (JS, CSS, images)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Health check endpoint (optional, for monitoring)
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

84
nginx/conf.d/honey.conf Normal file
View File

@@ -0,0 +1,84 @@
upstream honey_backend {
# Primary backend (port 8080)
server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
# Standby backend (port 8082) - used during rolling updates
# Uncomment the line below to switch traffic to new backend
# server 127.0.0.1:8082 max_fails=3 fail_timeout=30s backup;
# Health check configuration
keepalive 32;
}
server {
listen 80;
server_name _;
# Increase body size limit for API requests
client_max_body_size 10M;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# API endpoints
location /api/ {
proxy_pass http://honey_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
proxy_cache_bypass $http_upgrade;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Actuator endpoints (for health checks)
location /actuator/ {
proxy_pass http://honey_backend;
proxy_http_version 1.1;
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;
}
# Ping endpoint
location /ping {
proxy_pass http://honey_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# Frontend (if serving static files)
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
index index.html;
}
}
# HTTPS configuration (uncomment and configure when SSL certificates are available)
# server {
# listen 443 ssl http2;
# server_name your-domain.com;
#
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
#
# # SSL configuration
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
#
# # Same location blocks as HTTP server
# # ...
# }

35
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,35 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
include /etc/nginx/conf.d/*.conf;
}

141
pom.xml Normal file
View File

@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.honey</groupId>
<artifactId>honey-be</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<!-- Spring Boot Parent -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Flyway -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- MaxMind GeoIP2 -->
<dependency>
<groupId>com.maxmind.geoip2</groupId>
<artifactId>geoip2</artifactId>
<version>4.2.0</version>
</dependency>
<!-- Telegram Bot API -->
<dependency>
<groupId>org.telegram</groupId>
<artifactId>telegrambots</artifactId>
<version>6.9.0</version>
<exclusions>
<!-- Exclude Jackson JAXB module that uses old javax.xml.bind API -->
<exclusion>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-jaxb-annotations</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OpenAPI / Swagger (public API docs only; admin excluded) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Spring Boot Plugin -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

216
scripts/backup-database.sh Normal file
View File

@@ -0,0 +1,216 @@
#!/bin/bash
# Database Backup Script for Lottery Application
# This script creates a MySQL dump and transfers it to the backup VPS
#
# Usage:
# ./scripts/backup-database.sh [--keep-local] [--compress]
#
# Options:
# --keep-local Keep a local copy of the backup (default: delete after transfer)
# --compress Compress the backup before transfer (default: gzip)
#
# Prerequisites:
# 1. SSH key-based authentication to backup VPS (5.45.77.77)
# 2. Database password accessible via /run/secrets/honey-config.properties
# 3. Docker container 'honey-mysql' running
#
# Backup location on backup VPS: /raid/backup/acc_260182/
set -euo pipefail
# Configuration
BACKUP_VPS_HOST="5.45.77.77"
BACKUP_VPS_USER="acc_260182" # User account on backup VPS
BACKUP_VPS_PATH="/raid/backup/acc_260182"
MYSQL_CONTAINER="honey-mysql"
MYSQL_DATABASE="lottery_db"
SECRET_FILE="/run/secrets/honey-config.properties"
BACKUP_DIR="/opt/app/backups"
KEEP_LOCAL=false
COMPRESS=true
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--keep-local)
KEEP_LOCAL=true
shift
;;
--no-compress)
COMPRESS=false
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--keep-local] [--no-compress]"
exit 1
;;
esac
done
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Logging function
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
# Check if running as root or with sudo
if [ "$EUID" -ne 0 ]; then
error "This script must be run as root (or with sudo)"
exit 1
fi
# Load database password
if [ ! -f "$SECRET_FILE" ]; then
error "Secret file not found at $SECRET_FILE"
exit 1
fi
DB_PASSWORD=$(grep "^SPRING_DATASOURCE_PASSWORD=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -z "$DB_PASSWORD" ]; then
error "SPRING_DATASOURCE_PASSWORD not found in secret file"
exit 1
fi
# Check if MySQL container is running
if ! docker ps --format '{{.Names}}' | grep -q "^${MYSQL_CONTAINER}$"; then
error "MySQL container '${MYSQL_CONTAINER}' is not running"
exit 1
fi
# Create backup directory if it doesn't exist
mkdir -p "$BACKUP_DIR"
# Generate backup filename with timestamp
TIMESTAMP=$(date +'%Y%m%d_%H%M%S')
BACKUP_FILENAME="lottery_db_backup_${TIMESTAMP}.sql"
BACKUP_PATH="${BACKUP_DIR}/${BACKUP_FILENAME}"
# If compression is enabled, add .gz extension
if [ "$COMPRESS" = true ]; then
BACKUP_FILENAME="${BACKUP_FILENAME}.gz"
BACKUP_PATH="${BACKUP_DIR}/${BACKUP_FILENAME}"
fi
log "Starting database backup..."
log "Database: ${MYSQL_DATABASE}"
log "Container: ${MYSQL_CONTAINER}"
log "Backup file: ${BACKUP_FILENAME}"
# Create MySQL dump
log "Creating MySQL dump..."
if [ "$COMPRESS" = true ]; then
# Dump and compress in one step (saves disk space)
if docker exec "${MYSQL_CONTAINER}" mysqldump \
-u root \
-p"${DB_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
--events \
--quick \
--lock-tables=false \
"${MYSQL_DATABASE}" | gzip > "${BACKUP_PATH}"; then
log "✅ Database dump created and compressed: ${BACKUP_PATH}"
else
error "Failed to create database dump"
exit 1
fi
else
# Dump without compression
if docker exec "${MYSQL_CONTAINER}" mysqldump \
-u root \
-p"${DB_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
--events \
--quick \
--lock-tables=false \
"${MYSQL_DATABASE}" > "${BACKUP_PATH}"; then
log "✅ Database dump created: ${BACKUP_PATH}"
else
error "Failed to create database dump"
exit 1
fi
fi
# Get backup file size
BACKUP_SIZE=$(du -h "${BACKUP_PATH}" | cut -f1)
log "Backup size: ${BACKUP_SIZE}"
# Transfer to backup VPS
log "Transferring backup to backup VPS (${BACKUP_VPS_HOST})..."
# Test SSH connection first
if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "echo 'SSH connection successful'" > /dev/null 2>&1; then
error "Cannot connect to backup VPS via SSH"
error "Please ensure:"
error " 1. SSH key-based authentication is set up"
error " 2. Backup VPS is accessible from this server"
error " 3. User '${BACKUP_VPS_USER}' has access to ${BACKUP_VPS_PATH}"
if [ "$KEEP_LOCAL" = true ]; then
warn "Keeping local backup despite transfer failure: ${BACKUP_PATH}"
else
rm -f "${BACKUP_PATH}"
fi
exit 1
fi
# Create backup directory on remote VPS if it doesn't exist
ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "mkdir -p ${BACKUP_VPS_PATH}"
# Transfer the backup file
if scp "${BACKUP_PATH}" "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/"; then
log "✅ Backup transferred successfully to ${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/${BACKUP_FILENAME}"
# Verify remote file exists
REMOTE_SIZE=$(ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "du -h ${BACKUP_VPS_PATH}/${BACKUP_FILENAME} 2>/dev/null | cut -f1" || echo "0")
if [ "$REMOTE_SIZE" != "0" ]; then
log "✅ Remote backup verified (size: ${REMOTE_SIZE})"
else
warn "Could not verify remote backup file"
fi
else
error "Failed to transfer backup to backup VPS"
if [ "$KEEP_LOCAL" = true ]; then
warn "Keeping local backup despite transfer failure: ${BACKUP_PATH}"
else
rm -f "${BACKUP_PATH}"
fi
exit 1
fi
# Clean up local backup if not keeping it
if [ "$KEEP_LOCAL" = false ]; then
rm -f "${BACKUP_PATH}"
log "Local backup file removed (transferred successfully)"
fi
# Clean up old backups on remote VPS (keep last 10 days)
log "Cleaning up old backups on remote VPS (keeping last 10 days)..."
ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "find ${BACKUP_VPS_PATH} -name 'lottery_db_backup_*.sql*' -type f -mtime +10 -delete" || warn "Failed to clean up old backups"
# Count remaining backups
BACKUP_COUNT=$(ssh "${BACKUP_VPS_USER}@${BACKUP_VPS_HOST}" "ls -1 ${BACKUP_VPS_PATH}/lottery_db_backup_*.sql* 2>/dev/null | wc -l" || echo "0")
log "Total backups on remote VPS: ${BACKUP_COUNT}"
log "✅ Backup completed successfully!"
log " Remote location: ${BACKUP_VPS_HOST}:${BACKUP_VPS_PATH}/${BACKUP_FILENAME}"

View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Script to create secret file from template
# Usage: ./create-secret-file-from-template.sh /path/to/template /path/to/output
TEMPLATE_FILE="${1:-honey-config.properties.template}"
OUTPUT_FILE="${2:-/run/secrets/honey-config.properties}"
OUTPUT_DIR=$(dirname "$OUTPUT_FILE")
# Check if template exists
if [ ! -f "$TEMPLATE_FILE" ]; then
echo "❌ Template file not found: $TEMPLATE_FILE"
exit 1
fi
# Create output directory if it doesn't exist
mkdir -p "$OUTPUT_DIR"
# Copy template to output
cp "$TEMPLATE_FILE" "$OUTPUT_FILE"
# Set secure permissions (read-only for owner, no access for others)
chmod 600 "$OUTPUT_FILE"
echo "✅ Secret file created at $OUTPUT_FILE"
echo "⚠️ IMPORTANT: Edit this file and replace all placeholder values with your actual configuration!"
echo "⚠️ After editing, ensure permissions are secure: chmod 600 $OUTPUT_FILE"

View File

@@ -0,0 +1,28 @@
#!/bin/sh
# Create secret file from environment variables for testing ConfigLoader
# This simulates the mounted secret file approach used in Inferno
SECRET_FILE="/run/secrets/honey-config.properties"
SECRET_DIR="/run/secrets"
# Create directory if it doesn't exist
mkdir -p "$SECRET_DIR"
# Create properties file from environment variables
cat > "$SECRET_FILE" << EOF
# Configuration loaded from secret file (created from env vars for testing)
SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL}
SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME}
SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD}
TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
FRONTEND_URL=${FRONTEND_URL}
PORT=${PORT}
EOF
# Set permissions (readable by the application user)
chmod 644 "$SECRET_FILE"
echo "✅ Secret file created at $SECRET_FILE from environment variables"

View File

@@ -0,0 +1,227 @@
#!/bin/bash
# Diagnostic script for backup-database.sh permission issues
# Run this on your VPS to identify the root cause
SCRIPT="/opt/app/backend/honey-be/scripts/backup-database.sh"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo "=========================================="
echo "Backup Script Permission Diagnostic"
echo "=========================================="
echo ""
# 1. File exists
echo "1. Checking if file exists..."
if [ -f "$SCRIPT" ]; then
echo -e " ${GREEN}✅ File exists${NC}"
else
echo -e " ${RED}❌ File NOT found at: $SCRIPT${NC}"
echo " Please verify the path."
exit 1
fi
echo ""
# 2. File permissions
echo "2. File permissions:"
ls -la "$SCRIPT"
echo ""
# 3. Is executable
echo "3. Is file executable?"
if [ -x "$SCRIPT" ]; then
echo -e " ${GREEN}✅ File is executable${NC}"
else
echo -e " ${RED}❌ File is NOT executable${NC}"
echo " Fix: chmod +x $SCRIPT"
fi
echo ""
# 4. Shebang line
echo "4. Shebang line (first line):"
SHEBANG=$(head -1 "$SCRIPT")
echo " $SHEBANG"
if [[ "$SHEBANG" == "#!/bin/bash" ]] || [[ "$SHEBANG" == "#!/usr/bin/bash" ]]; then
echo -e " ${GREEN}✅ Shebang looks correct${NC}"
else
echo -e " ${YELLOW}⚠️ Unexpected shebang${NC}"
fi
echo ""
# 5. Bash exists
echo "5. Checking if bash interpreter exists:"
if [ -f /bin/bash ]; then
echo -e " ${GREEN}✅ /bin/bash exists${NC}"
/bin/bash --version | head -1
elif [ -f /usr/bin/bash ]; then
echo -e " ${GREEN}✅ /usr/bin/bash exists${NC}"
/usr/bin/bash --version | head -1
else
echo -e " ${RED}❌ bash not found in /bin/bash or /usr/bin/bash${NC}"
echo " Found at: $(which bash 2>/dev/null || echo 'NOT FOUND')"
fi
echo ""
# 6. Line endings
echo "6. Checking line endings:"
FILE_TYPE=$(file "$SCRIPT")
echo " $FILE_TYPE"
if echo "$FILE_TYPE" | grep -q "CRLF"; then
echo -e " ${RED}❌ File has Windows line endings (CRLF)${NC}"
echo " Fix: dos2unix $SCRIPT"
echo " Or: sed -i 's/\r$//' $SCRIPT"
elif echo "$FILE_TYPE" | grep -q "ASCII text"; then
echo -e " ${GREEN}✅ Line endings look correct (LF)${NC}"
else
echo -e " ${YELLOW}⚠️ Could not determine line endings${NC}"
fi
echo ""
# 7. Mount options
echo "7. Checking filesystem mount options:"
MOUNT_INFO=$(mount | grep -E "(/opt|/app)" || echo "Not a separate mount")
echo " $MOUNT_INFO"
if echo "$MOUNT_INFO" | grep -q "noexec"; then
echo -e " ${RED}❌ Filesystem mounted with 'noexec' flag${NC}"
echo " This prevents script execution!"
echo " Fix: Remove 'noexec' from /etc/fstab and remount"
else
echo -e " ${GREEN}✅ No 'noexec' flag detected${NC}"
fi
echo ""
# 8. SELinux
echo "8. Checking SELinux:"
if command -v getenforce &> /dev/null; then
SELINUX_STATUS=$(getenforce 2>/dev/null)
echo " Status: $SELINUX_STATUS"
if [ "$SELINUX_STATUS" = "Enforcing" ]; then
echo -e " ${YELLOW}⚠️ SELinux is enforcing - may block execution${NC}"
echo " Check context: ls -Z $SCRIPT"
else
echo -e " ${GREEN}✅ SELinux not blocking (or disabled)${NC}"
fi
else
echo -e " ${GREEN}✅ SELinux not installed${NC}"
fi
echo ""
# 9. Directory permissions
echo "9. Parent directory permissions:"
DIR=$(dirname "$SCRIPT")
ls -ld "$DIR"
if [ -x "$DIR" ]; then
echo -e " ${GREEN}✅ Directory is executable${NC}"
else
echo -e " ${RED}❌ Directory is NOT executable${NC}"
echo " Fix: chmod +x $DIR"
fi
echo ""
# 10. Syntax check
echo "10. Checking script syntax:"
if bash -n "$SCRIPT" 2>&1; then
echo -e " ${GREEN}✅ Syntax is valid${NC}"
else
echo -e " ${RED}❌ Syntax errors found${NC}"
bash -n "$SCRIPT"
fi
echo ""
# 11. Test execution
echo "11. Testing script execution (dry run):"
echo " Attempting to read first 10 lines..."
if head -10 "$SCRIPT" > /dev/null 2>&1; then
echo -e " ${GREEN}✅ Can read script${NC}"
else
echo -e " ${RED}❌ Cannot read script${NC}"
fi
echo ""
# 12. Cron job check
echo "12. Checking cron configuration:"
if [ "$EUID" -eq 0 ]; then
echo " Root's crontab:"
crontab -l 2>/dev/null | grep -i backup || echo " (No backup cron job found in root's crontab)"
echo ""
echo " To check cron job, run: sudo crontab -l"
else
echo " (Run as root to check crontab: sudo crontab -l)"
fi
echo ""
# 13. Environment check
echo "13. Checking required commands:"
REQUIRED_COMMANDS=("docker" "ssh" "gzip" "bash")
for cmd in "${REQUIRED_COMMANDS[@]}"; do
if command -v "$cmd" &> /dev/null; then
CMD_PATH=$(which "$cmd")
echo -e " ${GREEN}$cmd${NC} found at: $CMD_PATH"
else
echo -e " ${RED}$cmd${NC} NOT found in PATH"
fi
done
echo ""
# 14. Secret file check
echo "14. Checking secret file:"
SECRET_FILE="/run/secrets/honey-config.properties"
if [ -f "$SECRET_FILE" ]; then
echo -e " ${GREEN}✅ Secret file exists${NC}"
if [ -r "$SECRET_FILE" ]; then
echo -e " ${GREEN}✅ Secret file is readable${NC}"
else
echo -e " ${RED}❌ Secret file is NOT readable${NC}"
fi
else
echo -e " ${YELLOW}⚠️ Secret file not found (script will fail at runtime)${NC}"
fi
echo ""
# Summary
echo "=========================================="
echo "Summary & Recommendations"
echo "=========================================="
ISSUES=0
if [ ! -x "$SCRIPT" ]; then
echo -e "${RED}❌ Issue: File is not executable${NC}"
echo " Fix: chmod +x $SCRIPT"
ISSUES=$((ISSUES + 1))
fi
if file "$SCRIPT" | grep -q "CRLF"; then
echo -e "${RED}❌ Issue: Windows line endings detected${NC}"
echo " Fix: dos2unix $SCRIPT (or: sed -i 's/\r$//' $SCRIPT)"
ISSUES=$((ISSUES + 1))
fi
if mount | grep -E "(/opt|/app)" | grep -q "noexec"; then
echo -e "${RED}❌ Issue: Filesystem mounted with noexec${NC}"
echo " Fix: Remove noexec from /etc/fstab and remount"
ISSUES=$((ISSUES + 1))
fi
if [ "$ISSUES" -eq 0 ]; then
echo -e "${GREEN}✅ No obvious issues found${NC}"
echo ""
echo "If cron still fails, try:"
echo " 1. Update cron to use bash explicitly:"
echo " 0 2 * * * /bin/bash $SCRIPT >> /opt/app/logs/backup.log 2>&1"
echo ""
echo " 2. Check cron logs:"
echo " sudo journalctl -u cron | tail -50"
echo ""
echo " 3. Test manual execution:"
echo " sudo $SCRIPT --keep-local"
else
echo ""
echo -e "${YELLOW}Found $ISSUES issue(s) that need to be fixed.${NC}"
fi
echo ""
echo "=========================================="

View File

@@ -0,0 +1,77 @@
# Fix 301 redirect loop (Certbot duplicate 443 block)
## Cause
Certbot added a **second** HTTPS server block that only has:
- `location / { return 301 https://$host$request_uri; }`
- `listen 443 ssl` + SSL cert paths
That block is matched first for `https://testforapp.website/`, so every request gets 301 → same URL → loop. Your real HTTPS block (frontend, API, phpMyAdmin) is never used for `/`.
## Fix on VPS
1. **Open the site config**
```bash
sudo nano /etc/nginx/sites-enabled/testforapp.website
```
2. **Find and remove the Certbot-only HTTPS block**
Look for a block that looks like this (it may be at the **top** of the file, before the `map` and your big HTTPS server):
```nginx
server {
server_name testforapp.website;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
```
**Delete this entire `server { ... }` block** (from `server {` through the closing `}`).
3. **Ensure your main HTTPS block has SSL and listen**
Find your main HTTPS server (the one with `# HTTPS server`, `root /opt/app/frontend/dist`, all the `location` blocks). It must have at the top of that block (right after `server {`):
- `listen 443 ssl;` and `listen [::]:443 ssl;`
- `ssl_certificate` and `ssl_certificate_key` (and optionally `include /etc/letsencrypt/options-ssl-nginx.conf;` and `ssl_dhparam`)
If those lines are missing, add them (copy from the block you deleted):
```nginx
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name testforapp.website;
ssl_certificate /etc/letsencrypt/live/testforapp.website/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/testforapp.website/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# ... rest of your config (root, locations, etc.)
}
```
4. **Test and reload**
```bash
sudo nginx -t && sudo systemctl reload nginx
```
5. **Verify**
```bash
curl -I -k https://127.0.0.1/ -H "Host: testforapp.website"
```
You should see `200 OK` (or `304`) and no `Location` header, and https://testforapp.website/ should load in the browser.

View File

@@ -0,0 +1,38 @@
#!/bin/bash
# Script to load database password from secret file
# This ensures DB_PASSWORD and DB_ROOT_PASSWORD match SPRING_DATASOURCE_PASSWORD
# Usage: source ./load-db-password.sh
SECRET_FILE="/run/secrets/honey-config.properties"
if [ ! -f "$SECRET_FILE" ]; then
echo "❌ Error: Secret file not found at $SECRET_FILE"
echo " Please create the secret file first (see deployment guide Step 3.3)"
return 1 2>/dev/null || exit 1
fi
# Read SPRING_DATASOURCE_PASSWORD from secret file
DB_PASSWORD=$(grep "^SPRING_DATASOURCE_PASSWORD=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -z "$DB_PASSWORD" ]; then
echo "❌ Error: SPRING_DATASOURCE_PASSWORD not found in secret file"
echo " Please ensure the secret file contains: SPRING_DATASOURCE_PASSWORD=your_password"
return 1 2>/dev/null || exit 1
fi
# Export both variables (MySQL uses both)
export DB_PASSWORD="$DB_PASSWORD"
export DB_ROOT_PASSWORD="$DB_PASSWORD"
# Optionally load PMA_ABSOLUTE_URI from secret file (for phpMyAdmin path protection)
PMA_ABSOLUTE_URI=$(grep "^PMA_ABSOLUTE_URI=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed 's/^"//;s/"$//' | sed "s/^'//;s/'$//")
if [ -n "$PMA_ABSOLUTE_URI" ]; then
export PMA_ABSOLUTE_URI="$PMA_ABSOLUTE_URI"
echo "✅ PMA_ABSOLUTE_URI loaded from secret file"
fi
echo "✅ Database password loaded from secret file"
echo " DB_PASSWORD and DB_ROOT_PASSWORD are now set (matching SPRING_DATASOURCE_PASSWORD)"

183
scripts/restore-database.sh Normal file
View File

@@ -0,0 +1,183 @@
#!/bin/bash
# Database Restore Script for Lottery Application
# This script restores a MySQL database from a backup file
#
# Usage:
# ./scripts/restore-database.sh <backup-file>
#
# Examples:
# # Restore from local file
# ./scripts/restore-database.sh /opt/app/backups/lottery_db_backup_20240101_120000.sql.gz
#
# # Restore from backup VPS
# ./scripts/restore-database.sh 5.45.77.77:/raid/backup/acc_260182/lottery_db_backup_20240101_120000.sql.gz
#
# Prerequisites:
# 1. Database password accessible via /run/secrets/honey-config.properties
# 2. Docker container 'honey-mysql' running
# 3. Database will be DROPPED and RECREATED (all data will be lost!)
set -euo pipefail
# Configuration
MYSQL_CONTAINER="honey-mysql"
MYSQL_DATABASE="lottery_db"
SECRET_FILE="/run/secrets/honey-config.properties"
BACKUP_VPS_USER="acc_260182" # User account on backup VPS
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Logging function
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
# Check arguments
if [ $# -eq 0 ]; then
error "No backup file specified"
echo "Usage: $0 <backup-file>"
echo ""
echo "Examples:"
echo " $0 /opt/app/backups/lottery_db_backup_20240101_120000.sql.gz"
echo " $0 5.45.77.77:/raid/backup/acc_260182/lottery_db_backup_20240101_120000.sql.gz"
exit 1
fi
BACKUP_SOURCE="$1"
# Check if running as root or with sudo
if [ "$EUID" -ne 0 ]; then
error "This script must be run as root (or with sudo)"
exit 1
fi
# Load database password
if [ ! -f "$SECRET_FILE" ]; then
error "Secret file not found at $SECRET_FILE"
exit 1
fi
DB_PASSWORD=$(grep "^SPRING_DATASOURCE_PASSWORD=" "$SECRET_FILE" | cut -d'=' -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -z "$DB_PASSWORD" ]; then
error "SPRING_DATASOURCE_PASSWORD not found in secret file"
exit 1
fi
# Check if MySQL container is running
if ! docker ps --format '{{.Names}}' | grep -q "^${MYSQL_CONTAINER}$"; then
error "MySQL container '${MYSQL_CONTAINER}' is not running"
exit 1
fi
# Determine if backup is remote or local
TEMP_BACKUP="/tmp/restore_backup_$$"
BACKUP_IS_COMPRESSED=false
if [[ "$BACKUP_SOURCE" == *":"* ]]; then
# Remote backup (format: host:/path/to/file)
log "Detected remote backup: ${BACKUP_SOURCE}"
HOST_PATH=(${BACKUP_SOURCE//:/ })
REMOTE_HOST="${HOST_PATH[0]}"
REMOTE_PATH="${HOST_PATH[1]}"
log "Downloading backup from ${REMOTE_HOST}..."
if scp "${REMOTE_HOST}:${REMOTE_PATH}" "${TEMP_BACKUP}"; then
log "✅ Backup downloaded successfully"
else
error "Failed to download backup from remote VPS"
exit 1
fi
else
# Local backup
if [ ! -f "$BACKUP_SOURCE" ]; then
error "Backup file not found: ${BACKUP_SOURCE}"
exit 1
fi
log "Using local backup: ${BACKUP_SOURCE}"
cp "$BACKUP_SOURCE" "${TEMP_BACKUP}"
fi
# Check if backup is compressed
if [[ "$TEMP_BACKUP" == *.gz ]] || file "$TEMP_BACKUP" | grep -q "gzip compressed"; then
BACKUP_IS_COMPRESSED=true
log "Backup is compressed (gzip)"
fi
# Get backup file size
BACKUP_SIZE=$(du -h "${TEMP_BACKUP}" | cut -f1)
log "Backup size: ${BACKUP_SIZE}"
# WARNING: This will destroy all existing data!
warn "⚠️ WARNING: This will DROP and RECREATE the database '${MYSQL_DATABASE}'"
warn "⚠️ ALL EXISTING DATA WILL BE LOST!"
echo ""
read -p "Are you sure you want to continue? Type 'YES' to confirm: " CONFIRM
if [ "$CONFIRM" != "YES" ]; then
log "Restore cancelled by user"
rm -f "${TEMP_BACKUP}"
exit 0
fi
log "Starting database restore..."
# Drop and recreate database
log "Dropping existing database (if exists)..."
docker exec "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" -e "DROP DATABASE IF EXISTS ${MYSQL_DATABASE};" || true
log "Creating fresh database..."
docker exec "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" -e "CREATE DATABASE ${MYSQL_DATABASE} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# Restore database
log "Restoring database from backup..."
if [ "$BACKUP_IS_COMPRESSED" = true ]; then
# Restore from compressed backup
if gunzip -c "${TEMP_BACKUP}" | docker exec -i "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" "${MYSQL_DATABASE}"; then
log "✅ Database restored successfully from compressed backup"
else
error "Failed to restore database"
rm -f "${TEMP_BACKUP}"
exit 1
fi
else
# Restore from uncompressed backup
if docker exec -i "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" "${MYSQL_DATABASE}" < "${TEMP_BACKUP}"; then
log "✅ Database restored successfully"
else
error "Failed to restore database"
rm -f "${TEMP_BACKUP}"
exit 1
fi
fi
# Clean up temporary file
rm -f "${TEMP_BACKUP}"
# Verify restore
log "Verifying restore..."
TABLE_COUNT=$(docker exec "${MYSQL_CONTAINER}" mysql -u root -p"${DB_PASSWORD}" -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '${MYSQL_DATABASE}';" 2>/dev/null || echo "0")
if [ "$TABLE_COUNT" -gt 0 ]; then
log "✅ Restore verified: ${TABLE_COUNT} tables found in database"
else
warn "⚠️ Warning: No tables found in database after restore"
fi
log "✅ Database restore completed!"
warn "⚠️ Remember to restart the backend container if it's running:"
warn " docker restart honey-backend"

629
scripts/rolling-update.sh Normal file
View File

@@ -0,0 +1,629 @@
#!/bin/bash
# Rolling Update Deployment Script (production)
# Uses docker-compose.prod.yml. For staged (8GB VPS) use: scripts/rolling-update.staged.sh
# This script performs zero-downtime deployment by:
# 1. Building new backend image
# 2. Starting new backend container on port 8082
# 3. Health checking the new container
# 4. Updating Nginx to point to new container
# 5. Reloading Nginx (zero downtime)
# 6. Stopping old container after grace period
set -euo pipefail
# Colors (define early for use in config detection)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Logging functions (define early)
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
COMPOSE_FILE="${PROJECT_DIR}/docker-compose.prod.yml"
# Detect Nginx config file (try common locations)
# Priority: sites-enabled (what Nginx actually loads) > conf.d > custom paths
NGINX_CONF="${NGINX_CONF:-}"
if [ -z "$NGINX_CONF" ]; then
if [ -f "/etc/nginx/sites-enabled/testforapp.website" ]; then
NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website"
log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)"
elif [ -f "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then
NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website.conf"
log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)"
elif [ -f "/etc/nginx/conf.d/honey.conf" ]; then
NGINX_CONF="/etc/nginx/conf.d/honey.conf"
log "Using Nginx config: $NGINX_CONF (conf.d)"
elif [ -f "/opt/app/nginx/testforapp.website.conf" ]; then
warn "Found config at /opt/app/nginx/testforapp.website.conf"
warn "Checking if it's symlinked to /etc/nginx/sites-enabled/..."
if [ -L "/etc/nginx/sites-enabled/testforapp.website" ] || [ -L "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then
# Find the actual target
local target=$(readlink -f /etc/nginx/sites-enabled/testforapp.website 2>/dev/null || readlink -f /etc/nginx/sites-enabled/testforapp.website.conf 2>/dev/null)
if [ -n "$target" ]; then
NGINX_CONF="$target"
log "Using Nginx config: $NGINX_CONF (symlink target)"
else
NGINX_CONF="/opt/app/nginx/testforapp.website.conf"
warn "Using custom path - will update this file, but you may need to copy to sites-enabled"
fi
else
NGINX_CONF="/opt/app/nginx/testforapp.website.conf"
warn "Using custom path - will update this file, but you may need to copy to sites-enabled"
fi
else
error "Cannot find Nginx config file."
error "Searched:"
error " - /etc/nginx/sites-enabled/testforapp.website"
error " - /etc/nginx/sites-enabled/testforapp.website.conf"
error " - /etc/nginx/conf.d/honey.conf"
error " - /opt/app/nginx/testforapp.website.conf"
error ""
error "Please set NGINX_CONF environment variable with the correct path."
exit 1
fi
else
log "Using Nginx config: $NGINX_CONF (from NGINX_CONF environment variable)"
fi
# Create backup in /tmp to avoid nginx including it (sites-enabled/* includes all files)
NGINX_CONF_BACKUP="/tmp/nginx-backup-$(basename $NGINX_CONF).$(date +%Y%m%d_%H%M%S)"
# Ports for backends (will be swapped dynamically)
PRIMARY_PORT=8080
STANDBY_PORT=8082
# Detect which backend is currently active
detect_active_backend() {
# Check which port Nginx is currently using in upstream block
# Look for server line that is NOT marked as backup
local active_port_line=$(grep -A 10 "^upstream backend {" "$NGINX_CONF" | grep "server 127\.0\.0\.1:" | grep -v "backup" | head -1)
if echo "$active_port_line" | grep -q "127\.0\.0\.1:8082"; then
# Port 8082 is active (not backup)
ACTIVE_PORT=8082
STANDBY_PORT=8080
ACTIVE_CONTAINER="honey-backend-new"
STANDBY_CONTAINER="honey-backend"
log "Detected: Port 8082 is currently active"
else
# Port 8080 is active (default or only one present)
ACTIVE_PORT=8080
STANDBY_PORT=8082
ACTIVE_CONTAINER="honey-backend"
STANDBY_CONTAINER="honey-backend-new"
log "Detected: Port 8080 is currently active"
fi
PRIMARY_PORT=$ACTIVE_PORT
HEALTH_CHECK_URL="http://127.0.0.1:${STANDBY_PORT}/actuator/health/readiness"
}
HEALTH_CHECK_RETRIES=60 # Increased for Spring Boot startup (60 * 2s = 120s max)
HEALTH_CHECK_INTERVAL=2
GRACE_PERIOD=10
# Check for KEEP_FAILED_CONTAINER environment variable (preserve it for rollback)
# This allows keeping failed containers for debugging even when using sudo
if [ "${KEEP_FAILED_CONTAINER:-}" = "true" ]; then
SCRIPT_KEEP_FAILED_CONTAINER="true"
export SCRIPT_KEEP_FAILED_CONTAINER
log "KEEP_FAILED_CONTAINER=true - failed containers will be kept for debugging"
fi
# Detect docker compose command (newer Docker uses 'docker compose', older uses 'docker-compose')
DOCKER_COMPOSE_CMD=""
if docker compose version &> /dev/null; then
DOCKER_COMPOSE_CMD="docker compose"
elif command -v docker-compose &> /dev/null; then
DOCKER_COMPOSE_CMD="docker-compose"
else
error "Neither 'docker compose' nor 'docker-compose' is available"
exit 1
fi
# Check prerequisites
check_prerequisites() {
log "Checking prerequisites..."
# Check if running as root
if [ "$EUID" -ne 0 ]; then
error "This script must be run as root (or with sudo)"
exit 1
fi
# Check if docker compose is available (already detected above)
log "Using Docker Compose command: $DOCKER_COMPOSE_CMD"
# Check if Nginx config exists
if [ ! -f "$NGINX_CONF" ]; then
error "Nginx config not found at $NGINX_CONF"
exit 1
fi
# Check if DB_ROOT_PASSWORD is set
if [ -z "${DB_ROOT_PASSWORD:-}" ]; then
warn "DB_ROOT_PASSWORD not set, attempting to load from secret file..."
if [ -f "${SCRIPT_DIR}/load-db-password.sh" ]; then
source "${SCRIPT_DIR}/load-db-password.sh"
else
error "Cannot load DB_ROOT_PASSWORD. Please set it or run: source scripts/load-db-password.sh"
exit 1
fi
fi
# Detect which backend is currently active
detect_active_backend
# Check if active backend is running
if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then
error "Active backend container (${ACTIVE_CONTAINER}) is not running"
error "Please start it first: docker-compose -f ${COMPOSE_FILE} up -d backend"
exit 1
fi
log "✅ Prerequisites check passed"
log "Active backend: ${ACTIVE_CONTAINER} on port ${ACTIVE_PORT}"
log "New backend will use: ${STANDBY_CONTAINER} on port ${STANDBY_PORT}"
}
# Build new backend image
build_new_image() {
log "Building new backend image..."
cd "$PROJECT_DIR"
# Determine which service to build based on which container will be used
# Both services use the same Dockerfile, but we need to build the correct one
# to ensure the image cache is updated for the service that will be started
if [ "$STANDBY_PORT" = "8082" ]; then
SERVICE_TO_BUILD="backend-new"
else
SERVICE_TO_BUILD="backend"
fi
log "Building service: ${SERVICE_TO_BUILD} (for port ${STANDBY_PORT})..."
# Build the image for the service that will be used
# This ensures the correct service's image cache is updated with latest migrations
if [ "$SERVICE_TO_BUILD" = "backend-new" ]; then
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then
log "✅ New backend image built successfully"
else
error "Failed to build new backend image"
exit 1
fi
else
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then
log "✅ New backend image built successfully"
else
error "Failed to build new backend image"
exit 1
fi
fi
}
# Start new backend container
start_new_container() {
log "Starting new backend container on port ${STANDBY_PORT}..."
cd "$PROJECT_DIR"
# Determine which service to start based on standby port
if [ "$STANDBY_PORT" = "8082" ]; then
SERVICE_NAME="backend-new"
CONTAINER_NAME="honey-backend-new"
else
SERVICE_NAME="backend"
CONTAINER_NAME="honey-backend"
fi
# Check if standby container exists (running or stopped)
# We need to remove it to ensure a fresh start with migrations
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
warn "${CONTAINER_NAME} container is already running, stopping it first..."
else
warn "${CONTAINER_NAME} container exists but is stopped, removing it for fresh start..."
fi
if [ "$SERVICE_NAME" = "backend-new" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop "$SERVICE_NAME" || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f "$SERVICE_NAME" || true
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f "$SERVICE_NAME" || true
fi
fi
# Start the new container
if [ "$SERVICE_NAME" = "backend-new" ]; then
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d "$SERVICE_NAME"; then
log "✅ New backend container started"
else
error "Failed to start new backend container"
exit 1
fi
else
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d "$SERVICE_NAME"; then
log "✅ New backend container started"
else
error "Failed to start new backend container"
exit 1
fi
fi
# Wait for container to initialize (Spring Boot needs time to start)
log "Waiting for container to initialize (Spring Boot startup can take 60+ seconds)..."
sleep 10
# Check if container is still running (might have crashed)
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
error "Container ${CONTAINER_NAME} stopped immediately after start. Check logs:"
error " docker logs ${CONTAINER_NAME}"
exit 1
fi
}
# Health check new container
health_check_new_container() {
log "Performing health check on new backend container (port ${STANDBY_PORT})..."
# First, check if container is still running
if [ "$STANDBY_PORT" = "8082" ]; then
local container_name="honey-backend-new"
else
local container_name="honey-backend"
fi
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
error "Container ${container_name} is not running!"
error "Check logs: docker logs ${container_name}"
return 1
fi
# Check container health status
local health_status=$(docker inspect --format='{{.State.Health.Status}}' "${container_name}" 2>/dev/null || echo "none")
if [ "$health_status" != "none" ]; then
info "Container health status: $health_status"
fi
local retries=0
while [ $retries -lt $HEALTH_CHECK_RETRIES ]; do
# Check if container is still running
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
error "Container ${container_name} stopped during health check!"
error "Check logs: docker logs ${container_name}"
return 1
fi
# Try health check
if curl -sf "$HEALTH_CHECK_URL" > /dev/null 2>&1; then
log "✅ New backend container is healthy"
return 0
fi
retries=$((retries + 1))
if [ $retries -lt $HEALTH_CHECK_RETRIES ]; then
# Show container status every 5 attempts
if [ $((retries % 5)) -eq 0 ]; then
info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES)"
info "Container status: $(docker ps --filter name=${container_name} --format '{{.Status}}')"
info "Last 5 log lines:"
docker logs --tail 5 "${container_name}" 2>&1 | sed 's/^/ /'
else
info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES), retrying in ${HEALTH_CHECK_INTERVAL}s..."
fi
sleep $HEALTH_CHECK_INTERVAL
fi
done
error "Health check failed after $HEALTH_CHECK_RETRIES attempts"
error "New backend container is not responding at $HEALTH_CHECK_URL"
error ""
error "Container status:"
docker ps --filter name=${container_name} --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true
error ""
error "Last 200 log lines:"
docker logs --tail 200 "${container_name}" 2>&1 | sed 's/^/ /'
error ""
error "To debug, keep container running and check:"
error " docker logs -f ${container_name}"
error " docker logs --tail 500 ${container_name} # For even more logs"
error " curl -v $HEALTH_CHECK_URL"
return 1
}
# Update Nginx configuration
update_nginx_config() {
log "Updating Nginx configuration to point to new backend (port ${STANDBY_PORT})..."
# Backup current config
cp "$NGINX_CONF" "$NGINX_CONF_BACKUP"
log "Backed up Nginx config to: $NGINX_CONF_BACKUP"
# Use Python for reliable config manipulation
# Pass variables directly to Python (not via sys.argv)
python3 << PYTHON_SCRIPT
import re
import sys
config_file = "$NGINX_CONF"
standby_port = "$STANDBY_PORT"
active_port = "$ACTIVE_PORT"
try:
# Read the entire file
with open(config_file, 'r') as f:
lines = f.readlines()
# Find and update upstream block
new_lines = []
in_upstream = False
upstream_start_idx = -1
upstream_end_idx = -1
keepalive_line = None
keepalive_idx = -1
# First pass: find upstream block boundaries
for i, line in enumerate(lines):
if re.match(r'^\s*upstream\s+backend\s*\{', line):
upstream_start_idx = i
in_upstream = True
elif in_upstream and re.match(r'^\s*\}', line):
upstream_end_idx = i
break
elif in_upstream and re.search(r'keepalive', line):
keepalive_line = line
keepalive_idx = i
if upstream_start_idx == -1 or upstream_end_idx == -1:
raise Exception("Could not find upstream backend block")
# Build new lines
for i, line in enumerate(lines):
if i < upstream_start_idx:
# Before upstream block - keep as is
new_lines.append(line)
elif i == upstream_start_idx:
# Start of upstream block
new_lines.append(line)
elif i > upstream_start_idx and i < upstream_end_idx:
# Inside upstream block
# Skip old server lines
if re.search(r'server\s+127\.0\.0\.1:808[02]', line):
continue
# Skip keepalive (we'll add it at the end)
if re.search(r'keepalive', line):
continue
# Keep comments and other lines
new_lines.append(line)
elif i == upstream_end_idx:
# Before closing brace - add server lines and keepalive
new_lines.append(f" server 127.0.0.1:{standby_port};\n")
new_lines.append(f" server 127.0.0.1:{active_port} backup;\n")
if keepalive_line:
new_lines.append(keepalive_line)
else:
new_lines.append(" keepalive 200;\n")
new_lines.append(line)
else:
# After upstream block - keep as is
new_lines.append(line)
# Write updated config
with open(config_file, 'w') as f:
f.writelines(new_lines)
print("Nginx config updated successfully")
except Exception as e:
print(f"Error updating Nginx config: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
PYTHON_SCRIPT
if [ $? -ne 0 ]; then
error "Failed to update Nginx config"
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
exit 1
fi
# Test Nginx configuration
if nginx -t; then
log "✅ Nginx configuration is valid"
else
error "Nginx configuration test failed, restoring backup..."
error "Error details:"
nginx -t 2>&1 | sed 's/^/ /'
error ""
error "Current config (first 50 lines):"
head -50 "$NGINX_CONF" | sed 's/^/ /'
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
exit 1
fi
}
# Reload Nginx (zero downtime)
reload_nginx() {
log "Reloading Nginx (zero downtime)..."
if systemctl reload nginx; then
log "✅ Nginx reloaded successfully"
log "✅ Traffic is now being served by new backend (port 8082)"
else
error "Failed to reload Nginx, restoring backup config..."
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
systemctl reload nginx
exit 1
fi
}
# Stop old container after grace period
stop_old_container() {
log "Waiting ${GRACE_PERIOD}s grace period for active connections to finish..."
sleep $GRACE_PERIOD
log "Stopping old backend container (${ACTIVE_CONTAINER})..."
cd "$PROJECT_DIR"
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new; then
log "✅ Old backend container stopped"
else
warn "Failed to stop old backend container gracefully"
fi
else
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend; then
log "✅ Old backend container stopped"
else
warn "Failed to stop old backend container gracefully"
fi
fi
# Optionally remove the old container (comment out if you want to keep it for rollback)
# if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
# docker-compose -f "$COMPOSE_FILE" --profile rolling-update rm -f backend-new
# else
# docker-compose -f "$COMPOSE_FILE" rm -f backend
# fi
}
# Rollback function
rollback() {
error "Rolling back to previous version..."
# Check KEEP_FAILED_CONTAINER (check both current env and script-level variable)
local keep_container="${KEEP_FAILED_CONTAINER:-false}"
if [ "$keep_container" != "true" ] && [ "${SCRIPT_KEEP_FAILED_CONTAINER:-false}" = "true" ]; then
keep_container="true"
fi
# Restore Nginx config
if [ -f "$NGINX_CONF_BACKUP" ]; then
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
systemctl reload nginx
log "✅ Nginx config restored"
fi
# Stop new container (but keep it for debugging if KEEP_FAILED_CONTAINER is set)
cd "$PROJECT_DIR"
if [ "$keep_container" = "true" ]; then
warn ""
warn "═══════════════════════════════════════════════════════════════"
warn "KEEP_FAILED_CONTAINER=true - Container will be KEPT for debugging"
warn "═══════════════════════════════════════════════════════════════"
if [ "$STANDBY_PORT" = "8082" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true
warn ""
warn "Container 'honey-backend-new' is STOPPED but NOT REMOVED"
warn ""
warn "To check logs:"
warn " docker logs honey-backend-new"
warn " docker logs --tail 100 honey-backend-new"
warn ""
warn "To remove manually:"
warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE --profile rolling-update rm -f backend-new"
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true
warn ""
warn "Container 'honey-backend' is STOPPED but NOT REMOVED"
warn ""
warn "To check logs:"
warn " docker logs honey-backend"
warn " docker logs --tail 100 honey-backend"
warn ""
warn "To remove manually:"
warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE rm -f backend"
fi
warn "═══════════════════════════════════════════════════════════════"
else
if [ "$STANDBY_PORT" = "8082" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f backend-new || true
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f backend || true
fi
fi
# Start old container if it was stopped
if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update start backend-new || \
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d backend-new
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" start backend || \
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d backend
fi
fi
error "Rollback completed"
exit 1
}
# Main deployment flow
main() {
log "Starting rolling update deployment..."
# Trap errors for rollback
trap rollback ERR
check_prerequisites
build_new_image
start_new_container
if ! health_check_new_container; then
rollback
fi
update_nginx_config
reload_nginx
# Clear error trap after successful switch
trap - ERR
stop_old_container
log "✅ Rolling update completed successfully!"
log ""
log "Summary:"
log " - New backend is running on port ${STANDBY_PORT} (${STANDBY_CONTAINER})"
log " - Nginx is serving traffic from new backend"
log " - Old backend (${ACTIVE_CONTAINER}) has been stopped"
log ""
log "To rollback (if needed):"
log " 1. Restore Nginx config: cp $NGINX_CONF_BACKUP $NGINX_CONF"
log " 2. Reload Nginx: systemctl reload nginx"
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
log " 3. Start old backend: docker-compose -f $COMPOSE_FILE --profile rolling-update start backend-new"
log " 4. Stop new backend: docker-compose -f $COMPOSE_FILE stop backend"
else
log " 3. Start old backend: docker-compose -f $COMPOSE_FILE start backend"
log " 4. Stop new backend: docker-compose -f $COMPOSE_FILE --profile rolling-update stop backend-new"
fi
}
# Run main function
main "$@"

View File

@@ -0,0 +1,622 @@
#!/bin/bash
# Rolling Update Deployment Script (staged / 8GB VPS)
# Same as rolling-update.sh but uses docker-compose.staged.yml (lower memory limits).
# This script performs zero-downtime deployment by:
# 1. Building new backend image
# 2. Starting new backend container on port 8082
# 3. Health checking the new container
# 4. Updating Nginx to point to new container
# 5. Reloading Nginx (zero downtime)
# 6. Stopping old container after grace period
set -euo pipefail
# Colors (define early for use in config detection)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Logging functions (define early)
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
# Configuration (staged: use docker-compose.staged.yml)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
COMPOSE_FILE="${PROJECT_DIR}/docker-compose.staged.yml"
# Detect Nginx config file (try common locations)
# Priority: sites-enabled (what Nginx actually loads) > conf.d > custom paths
NGINX_CONF="${NGINX_CONF:-}"
if [ -z "$NGINX_CONF" ]; then
if [ -f "/etc/nginx/sites-enabled/testforapp.website" ]; then
NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website"
log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)"
elif [ -f "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then
NGINX_CONF="/etc/nginx/sites-enabled/testforapp.website.conf"
log "Using Nginx config: $NGINX_CONF (sites-enabled - active config)"
elif [ -f "/etc/nginx/conf.d/honey.conf" ]; then
NGINX_CONF="/etc/nginx/conf.d/honey.conf"
log "Using Nginx config: $NGINX_CONF (conf.d)"
elif [ -f "/opt/app/nginx/testforapp.website.conf" ]; then
warn "Found config at /opt/app/nginx/testforapp.website.conf"
warn "Checking if it's symlinked to /etc/nginx/sites-enabled/..."
if [ -L "/etc/nginx/sites-enabled/testforapp.website" ] || [ -L "/etc/nginx/sites-enabled/testforapp.website.conf" ]; then
# Find the actual target
local target=$(readlink -f /etc/nginx/sites-enabled/testforapp.website 2>/dev/null || readlink -f /etc/nginx/sites-enabled/testforapp.website.conf 2>/dev/null)
if [ -n "$target" ]; then
NGINX_CONF="$target"
log "Using Nginx config: $NGINX_CONF (symlink target)"
else
NGINX_CONF="/opt/app/nginx/testforapp.website.conf"
warn "Using custom path - will update this file, but you may need to copy to sites-enabled"
fi
else
NGINX_CONF="/opt/app/nginx/testforapp.website.conf"
warn "Using custom path - will update this file, but you may need to copy to sites-enabled"
fi
else
error "Cannot find Nginx config file."
error "Searched:"
error " - /etc/nginx/sites-enabled/testforapp.website"
error " - /etc/nginx/sites-enabled/testforapp.website.conf"
error " - /etc/nginx/conf.d/honey.conf"
error " - /opt/app/nginx/testforapp.website.conf"
error ""
error "Please set NGINX_CONF environment variable with the correct path."
exit 1
fi
else
log "Using Nginx config: $NGINX_CONF (from NGINX_CONF environment variable)"
fi
# Create backup in /tmp to avoid nginx including it (sites-enabled/* includes all files)
NGINX_CONF_BACKUP="/tmp/nginx-backup-$(basename $NGINX_CONF).$(date +%Y%m%d_%H%M%S)"
# Ports for backends (will be swapped dynamically)
PRIMARY_PORT=8080
STANDBY_PORT=8082
# Detect which backend is currently active
detect_active_backend() {
# Check which port Nginx is currently using in upstream block
# Look for server line that is NOT marked as backup
local active_port_line=$(grep -A 10 "^upstream backend {" "$NGINX_CONF" | grep "server 127\.0\.0\.1:" | grep -v "backup" | head -1)
if echo "$active_port_line" | grep -q "127\.0\.0\.1:8082"; then
# Port 8082 is active (not backup)
ACTIVE_PORT=8082
STANDBY_PORT=8080
ACTIVE_CONTAINER="honey-backend-new"
STANDBY_CONTAINER="honey-backend"
log "Detected: Port 8082 is currently active"
else
# Port 8080 is active (default or only one present)
ACTIVE_PORT=8080
STANDBY_PORT=8082
ACTIVE_CONTAINER="honey-backend"
STANDBY_CONTAINER="honey-backend-new"
log "Detected: Port 8080 is currently active"
fi
PRIMARY_PORT=$ACTIVE_PORT
HEALTH_CHECK_URL="http://127.0.0.1:${STANDBY_PORT}/actuator/health/readiness"
}
HEALTH_CHECK_RETRIES=60 # Increased for Spring Boot startup (60 * 2s = 120s max)
HEALTH_CHECK_INTERVAL=2
GRACE_PERIOD=10
# Check for KEEP_FAILED_CONTAINER environment variable (preserve it for rollback)
# This allows keeping failed containers for debugging even when using sudo
if [ "${KEEP_FAILED_CONTAINER:-}" = "true" ]; then
SCRIPT_KEEP_FAILED_CONTAINER="true"
export SCRIPT_KEEP_FAILED_CONTAINER
log "KEEP_FAILED_CONTAINER=true - failed containers will be kept for debugging"
fi
# Detect docker compose command (newer Docker uses 'docker compose', older uses 'docker-compose')
DOCKER_COMPOSE_CMD=""
if docker compose version &> /dev/null; then
DOCKER_COMPOSE_CMD="docker compose"
elif command -v docker-compose &> /dev/null; then
DOCKER_COMPOSE_CMD="docker-compose"
else
error "Neither 'docker compose' nor 'docker-compose' is available"
exit 1
fi
# Check prerequisites
check_prerequisites() {
log "Checking prerequisites..."
# Check if running as root
if [ "$EUID" -ne 0 ]; then
error "This script must be run as root (or with sudo)"
exit 1
fi
# Check if docker compose is available (already detected above)
log "Using Docker Compose command: $DOCKER_COMPOSE_CMD"
log "Using compose file: $COMPOSE_FILE (staged)"
# Check if Nginx config exists
if [ ! -f "$NGINX_CONF" ]; then
error "Nginx config not found at $NGINX_CONF"
exit 1
fi
# Check if DB_ROOT_PASSWORD is set
if [ -z "${DB_ROOT_PASSWORD:-}" ]; then
warn "DB_ROOT_PASSWORD not set, attempting to load from secret file..."
if [ -f "${SCRIPT_DIR}/load-db-password.sh" ]; then
source "${SCRIPT_DIR}/load-db-password.sh"
else
error "Cannot load DB_ROOT_PASSWORD. Please set it or run: source scripts/load-db-password.sh"
exit 1
fi
fi
# Detect which backend is currently active
detect_active_backend
# Check if active backend is running
if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then
error "Active backend container (${ACTIVE_CONTAINER}) is not running"
error "Please start it first: docker compose -f ${COMPOSE_FILE} up -d backend"
exit 1
fi
log "✅ Prerequisites check passed"
log "Active backend: ${ACTIVE_CONTAINER} on port ${ACTIVE_PORT}"
log "New backend will use: ${STANDBY_CONTAINER} on port ${STANDBY_PORT}"
}
# Build new backend image
build_new_image() {
log "Building new backend image..."
cd "$PROJECT_DIR"
# Determine which service to build based on which container will be used
# Both services use the same Dockerfile, but we need to build the correct one
# to ensure the image cache is updated for the service that will be started
if [ "$STANDBY_PORT" = "8082" ]; then
SERVICE_TO_BUILD="backend-new"
else
SERVICE_TO_BUILD="backend"
fi
log "Building service: ${SERVICE_TO_BUILD} (for port ${STANDBY_PORT})..."
# Build the image for the service that will be used
# This ensures the correct service's image cache is updated with latest migrations
if [ "$SERVICE_TO_BUILD" = "backend-new" ]; then
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then
log "✅ New backend image built successfully"
else
error "Failed to build new backend image"
exit 1
fi
else
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" build "$SERVICE_TO_BUILD" 2>&1 | tee /tmp/rolling-update-build.log; then
log "✅ New backend image built successfully"
else
error "Failed to build new backend image"
exit 1
fi
fi
}
# Start new backend container
start_new_container() {
log "Starting new backend container on port ${STANDBY_PORT}..."
cd "$PROJECT_DIR"
# Determine which service to start based on standby port
if [ "$STANDBY_PORT" = "8082" ]; then
SERVICE_NAME="backend-new"
CONTAINER_NAME="honey-backend-new"
else
SERVICE_NAME="backend"
CONTAINER_NAME="honey-backend"
fi
# Check if standby container exists (running or stopped)
# We need to remove it to ensure a fresh start with migrations
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
warn "${CONTAINER_NAME} container is already running, stopping it first..."
else
warn "${CONTAINER_NAME} container exists but is stopped, removing it for fresh start..."
fi
if [ "$SERVICE_NAME" = "backend-new" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop "$SERVICE_NAME" || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f "$SERVICE_NAME" || true
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f "$SERVICE_NAME" || true
fi
fi
# Start the new container
if [ "$SERVICE_NAME" = "backend-new" ]; then
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d "$SERVICE_NAME"; then
log "✅ New backend container started"
else
error "Failed to start new backend container"
exit 1
fi
else
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d "$SERVICE_NAME"; then
log "✅ New backend container started"
else
error "Failed to start new backend container"
exit 1
fi
fi
# Wait for container to initialize (Spring Boot needs time to start)
log "Waiting for container to initialize (Spring Boot startup can take 60+ seconds)..."
sleep 10
# Check if container is still running (might have crashed)
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
error "Container ${CONTAINER_NAME} stopped immediately after start. Check logs:"
error " docker logs ${CONTAINER_NAME}"
exit 1
fi
}
# Health check new container
health_check_new_container() {
log "Performing health check on new backend container (port ${STANDBY_PORT})..."
# First, check if container is still running
if [ "$STANDBY_PORT" = "8082" ]; then
local container_name="honey-backend-new"
else
local container_name="honey-backend"
fi
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
error "Container ${container_name} is not running!"
error "Check logs: docker logs ${container_name}"
return 1
fi
# Check container health status
local health_status=$(docker inspect --format='{{.State.Health.Status}}' "${container_name}" 2>/dev/null || echo "none")
if [ "$health_status" != "none" ]; then
info "Container health status: $health_status"
fi
local retries=0
while [ $retries -lt $HEALTH_CHECK_RETRIES ]; do
# Check if container is still running
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
error "Container ${container_name} stopped during health check!"
error "Check logs: docker logs ${container_name}"
return 1
fi
# Try health check
if curl -sf "$HEALTH_CHECK_URL" > /dev/null 2>&1; then
log "✅ New backend container is healthy"
return 0
fi
retries=$((retries + 1))
if [ $retries -lt $HEALTH_CHECK_RETRIES ]; then
# Show container status every 5 attempts
if [ $((retries % 5)) -eq 0 ]; then
info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES)"
info "Container status: $(docker ps --filter name=${container_name} --format '{{.Status}}')"
info "Last 5 log lines:"
docker logs --tail 5 "${container_name}" 2>&1 | sed 's/^/ /'
else
info "Health check failed (attempt $retries/$HEALTH_CHECK_RETRIES), retrying in ${HEALTH_CHECK_INTERVAL}s..."
fi
sleep $HEALTH_CHECK_INTERVAL
fi
done
error "Health check failed after $HEALTH_CHECK_RETRIES attempts"
error "New backend container is not responding at $HEALTH_CHECK_URL"
error ""
error "Container status:"
docker ps --filter name=${container_name} --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true
error ""
error "Last 200 log lines:"
docker logs --tail 200 "${container_name}" 2>&1 | sed 's/^/ /'
error ""
error "To debug, keep container running and check:"
error " docker logs -f ${container_name}"
error " docker logs --tail 500 ${container_name} # For even more logs"
error " curl -v $HEALTH_CHECK_URL"
return 1
}
# Update Nginx configuration
update_nginx_config() {
log "Updating Nginx configuration to point to new backend (port ${STANDBY_PORT})..."
# Backup current config
cp "$NGINX_CONF" "$NGINX_CONF_BACKUP"
log "Backed up Nginx config to: $NGINX_CONF_BACKUP"
# Use Python for reliable config manipulation
# Pass variables directly to Python (not via sys.argv)
python3 << PYTHON_SCRIPT
import re
import sys
config_file = "$NGINX_CONF"
standby_port = "$STANDBY_PORT"
active_port = "$ACTIVE_PORT"
try:
# Read the entire file
with open(config_file, 'r') as f:
lines = f.readlines()
# Find and update upstream block
new_lines = []
in_upstream = False
upstream_start_idx = -1
upstream_end_idx = -1
keepalive_line = None
keepalive_idx = -1
# First pass: find upstream block boundaries
for i, line in enumerate(lines):
if re.match(r'^\s*upstream\s+backend\s*\{', line):
upstream_start_idx = i
in_upstream = True
elif in_upstream and re.match(r'^\s*\}', line):
upstream_end_idx = i
break
elif in_upstream and re.search(r'keepalive', line):
keepalive_line = line
keepalive_idx = i
if upstream_start_idx == -1 or upstream_end_idx == -1:
raise Exception("Could not find upstream backend block")
# Build new lines
for i, line in enumerate(lines):
if i < upstream_start_idx:
# Before upstream block - keep as is
new_lines.append(line)
elif i == upstream_start_idx:
# Start of upstream block
new_lines.append(line)
elif i > upstream_start_idx and i < upstream_end_idx:
# Inside upstream block
# Skip old server lines
if re.search(r'server\s+127\.0\.0\.1:808[02]', line):
continue
# Skip keepalive (we'll add it at the end)
if re.search(r'keepalive', line):
continue
# Keep comments and other lines
new_lines.append(line)
elif i == upstream_end_idx:
# Before closing brace - add server lines and keepalive
new_lines.append(f" server 127.0.0.1:{standby_port};\n")
new_lines.append(f" server 127.0.0.1:{active_port} backup;\n")
if keepalive_line:
new_lines.append(keepalive_line)
else:
new_lines.append(" keepalive 200;\n")
new_lines.append(line)
else:
# After upstream block - keep as is
new_lines.append(line)
# Write updated config
with open(config_file, 'w') as f:
f.writelines(new_lines)
print("Nginx config updated successfully")
except Exception as e:
print(f"Error updating Nginx config: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
PYTHON_SCRIPT
if [ $? -ne 0 ]; then
error "Failed to update Nginx config"
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
exit 1
fi
# Test Nginx configuration
if nginx -t; then
log "✅ Nginx configuration is valid"
else
error "Nginx configuration test failed, restoring backup..."
error "Error details:"
nginx -t 2>&1 | sed 's/^/ /'
error ""
error "Current config (first 50 lines):"
head -50 "$NGINX_CONF" | sed 's/^/ /'
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
exit 1
fi
}
# Reload Nginx (zero downtime)
reload_nginx() {
log "Reloading Nginx (zero downtime)..."
if systemctl reload nginx; then
log "✅ Nginx reloaded successfully"
log "✅ Traffic is now being served by new backend (port 8082)"
else
error "Failed to reload Nginx, restoring backup config..."
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
systemctl reload nginx
exit 1
fi
}
# Stop old container after grace period
stop_old_container() {
log "Waiting ${GRACE_PERIOD}s grace period for active connections to finish..."
sleep $GRACE_PERIOD
log "Stopping old backend container (${ACTIVE_CONTAINER})..."
cd "$PROJECT_DIR"
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new; then
log "✅ Old backend container stopped"
else
warn "Failed to stop old backend container gracefully"
fi
else
if $DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend; then
log "✅ Old backend container stopped"
else
warn "Failed to stop old backend container gracefully"
fi
fi
}
# Rollback function
rollback() {
error "Rolling back to previous version..."
# Check KEEP_FAILED_CONTAINER (check both current env and script-level variable)
local keep_container="${KEEP_FAILED_CONTAINER:-false}"
if [ "$keep_container" != "true" ] && [ "${SCRIPT_KEEP_FAILED_CONTAINER:-false}" = "true" ]; then
keep_container="true"
fi
# Restore Nginx config
if [ -f "$NGINX_CONF_BACKUP" ]; then
cp "$NGINX_CONF_BACKUP" "$NGINX_CONF"
systemctl reload nginx
log "✅ Nginx config restored"
fi
# Stop new container (but keep it for debugging if KEEP_FAILED_CONTAINER is set)
cd "$PROJECT_DIR"
if [ "$keep_container" = "true" ]; then
warn ""
warn "═══════════════════════════════════════════════════════════════"
warn "KEEP_FAILED_CONTAINER=true - Container will be KEPT for debugging"
warn "═══════════════════════════════════════════════════════════════"
if [ "$STANDBY_PORT" = "8082" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true
warn ""
warn "Container 'honey-backend-new' is STOPPED but NOT REMOVED"
warn ""
warn "To check logs:"
warn " docker logs honey-backend-new"
warn " docker logs --tail 100 honey-backend-new"
warn ""
warn "To remove manually:"
warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE --profile rolling-update rm -f backend-new"
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true
warn ""
warn "Container 'honey-backend' is STOPPED but NOT REMOVED"
warn ""
warn "To check logs:"
warn " docker logs honey-backend"
warn " docker logs --tail 100 honey-backend"
warn ""
warn "To remove manually:"
warn " $DOCKER_COMPOSE_CMD -f $COMPOSE_FILE rm -f backend"
fi
warn "═══════════════════════════════════════════════════════════════"
else
if [ "$STANDBY_PORT" = "8082" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update stop backend-new || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update rm -f backend-new || true
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" stop backend || true
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" rm -f backend || true
fi
fi
# Start old container if it was stopped
if ! docker ps --format '{{.Names}}' | grep -q "^${ACTIVE_CONTAINER}$"; then
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update start backend-new || \
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" --profile rolling-update up -d backend-new
else
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" start backend || \
$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d backend
fi
fi
error "Rollback completed"
exit 1
}
# Main deployment flow
main() {
log "Starting rolling update deployment (staged)..."
# Trap errors for rollback
trap rollback ERR
check_prerequisites
build_new_image
start_new_container
if ! health_check_new_container; then
rollback
fi
update_nginx_config
reload_nginx
# Clear error trap after successful switch
trap - ERR
stop_old_container
log "✅ Rolling update completed successfully!"
log ""
log "Summary:"
log " - New backend is running on port ${STANDBY_PORT} (${STANDBY_CONTAINER})"
log " - Nginx is serving traffic from new backend"
log " - Old backend (${ACTIVE_CONTAINER}) has been stopped"
log ""
log "To rollback (if needed):"
log " 1. Restore Nginx config: cp $NGINX_CONF_BACKUP $NGINX_CONF"
log " 2. Reload Nginx: systemctl reload nginx"
if [ "$ACTIVE_CONTAINER" = "honey-backend-new" ]; then
log " 3. Start old backend: docker compose -f $COMPOSE_FILE --profile rolling-update start backend-new"
log " 4. Stop new backend: docker compose -f $COMPOSE_FILE stop backend"
else
log " 3. Start old backend: docker compose -f $COMPOSE_FILE start backend"
log " 4. Stop new backend: docker compose -f $COMPOSE_FILE --profile rolling-update stop backend-new"
fi
}
# Run main function
main "$@"

119
scripts/setup-logging.sh Normal file
View File

@@ -0,0 +1,119 @@
#!/bin/bash
# Setup script for external logback-spring.xml on VPS
# This script extracts logback-spring.xml from the JAR and places it in the config directory
# MUST be run before starting Docker containers to create the required files
set -e
# Determine config directory based on current location
if [ -d "/opt/app/backend" ]; then
CONFIG_DIR="/opt/app/backend/config"
LOG_DIR="/opt/app/logs"
elif [ -d "/opt/app/backend/honey-be" ]; then
CONFIG_DIR="/opt/app/backend/honey-be/config"
LOG_DIR="/opt/app/logs"
else
# Try to find from current directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_DIR="$BACKEND_DIR/config"
LOG_DIR="/opt/app/logs"
fi
echo "Setting up external logging configuration..."
echo "Config directory: $CONFIG_DIR"
echo "Log directory: $LOG_DIR"
# Create config directory if it doesn't exist
mkdir -p "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR"
# Create log directory if it doesn't exist
mkdir -p "$LOG_DIR"
chmod 755 "$LOG_DIR"
# Extract logback-spring.xml from JAR if it doesn't exist
if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then
echo "Extracting logback-spring.xml from JAR..."
# Try multiple locations for JAR file
JAR_PATH=""
for search_path in "/opt/app/backend" "/opt/app/backend/honey-be" "$(dirname "$CONFIG_DIR")" "$(dirname "$(dirname "$CONFIG_DIR")")"; do
if [ -d "$search_path" ]; then
found_jar=$(find "$search_path" -name "honey-be-*.jar" -type f 2>/dev/null | head -n 1)
if [ -n "$found_jar" ]; then
JAR_PATH="$found_jar"
break
fi
fi
done
# Try to find in target directory
if [ -z "$JAR_PATH" ]; then
for search_path in "/opt/app/backend" "/opt/app/backend/honey-be" "$(dirname "$CONFIG_DIR")"; do
if [ -d "$search_path/target" ]; then
found_jar=$(find "$search_path/target" -name "*.jar" -type f | head -n 1)
if [ -n "$found_jar" ]; then
JAR_PATH="$found_jar"
break
fi
fi
done
fi
if [ -z "$JAR_PATH" ]; then
echo "Warning: JAR file not found. Trying to copy from source..."
# If JAR not found, copy from source (if available)
for search_path in "/opt/app/backend" "/opt/app/backend/honey-be" "$(dirname "$CONFIG_DIR")"; do
if [ -f "$search_path/src/main/resources/logback-spring.xml" ]; then
cp "$search_path/src/main/resources/logback-spring.xml" "$CONFIG_DIR/logback-spring.xml"
echo "Copied from source: $search_path/src/main/resources/logback-spring.xml"
break
fi
done
if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then
echo "Error: Cannot find logback-spring.xml in JAR or source."
echo "Please ensure the file exists or copy it manually to: $CONFIG_DIR/logback-spring.xml"
exit 1
fi
else
echo "Found JAR: $JAR_PATH"
# Extract from JAR
unzip -p "$JAR_PATH" BOOT-INF/classes/logback-spring.xml > "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || \
unzip -p "$JAR_PATH" logback-spring.xml > "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || {
echo "Warning: Could not extract from JAR. Trying to copy from source..."
# Try copying from source
for search_path in "/opt/app/backend" "/opt/app/backend/honey-be" "$(dirname "$CONFIG_DIR")"; do
if [ -f "$search_path/src/main/resources/logback-spring.xml" ]; then
cp "$search_path/src/main/resources/logback-spring.xml" "$CONFIG_DIR/logback-spring.xml"
break
fi
done
if [ ! -f "$CONFIG_DIR/logback-spring.xml" ]; then
echo "Error: Cannot extract or find logback-spring.xml."
echo "Please copy it manually to: $CONFIG_DIR/logback-spring.xml"
exit 1
fi
}
echo "Extracted from JAR: $JAR_PATH"
fi
echo "logback-spring.xml created at $CONFIG_DIR/logback-spring.xml"
else
echo "logback-spring.xml already exists at $CONFIG_DIR/logback-spring.xml"
fi
# Set proper permissions
chmod 644 "$CONFIG_DIR/logback-spring.xml"
chown $USER:$USER "$CONFIG_DIR/logback-spring.xml" 2>/dev/null || true
echo "Logging configuration setup complete!"
echo ""
echo "Configuration file: $CONFIG_DIR/logback-spring.xml"
echo "Log directory: $LOG_DIR"
echo ""
echo "You can now edit $CONFIG_DIR/logback-spring.xml to change log levels at runtime."
echo "Changes will take effect within 30 seconds (no restart needed)."

View File

@@ -0,0 +1,22 @@
package com.honey.honey;
import com.honey.honey.config.ConfigLoader;
import com.honey.honey.config.TelegramProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableAsync
@EnableConfigurationProperties({TelegramProperties.class})
public class HoneyBackendApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(HoneyBackendApplication.class);
app.addListeners(new ConfigLoader());
app.run(args);
}
}

View File

@@ -0,0 +1,138 @@
package com.honey.honey.config;
import com.honey.honey.security.admin.AdminDetailsService;
import com.honey.honey.security.admin.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class AdminSecurityConfig {
@Value("${FRONTEND_URL:}")
private String frontendUrl;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final AdminDetailsService adminDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider adminAuthenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(adminDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager adminAuthenticationManager() {
return new ProviderManager(adminAuthenticationProvider());
}
/**
* Swagger/OpenAPI docs: permitAll with highest precedence so the default Spring Boot chain
* (which requires auth for /**) never handles these paths. Includes webjars and resources
* so the UI can load CSS/JS.
*/
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws Exception {
RequestMatcher swaggerMatcher = new OrRequestMatcher(
new AntPathRequestMatcher("/swagger-ui/**"),
new AntPathRequestMatcher("/swagger-ui.html"),
new AntPathRequestMatcher("/v3/api-docs"),
new AntPathRequestMatcher("/v3/api-docs/**"),
new AntPathRequestMatcher("/webjars/**"),
new AntPathRequestMatcher("/swagger-resources/**"),
new AntPathRequestMatcher("/configuration/**")
);
http
.securityMatcher(swaggerMatcher)
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/admin/**")
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/login").permitAll()
.requestMatchers("/api/admin/users/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/payments/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/payouts/**").hasAnyRole("ADMIN", "PAYOUT_SUPPORT", "GAME_ADMIN")
.requestMatchers("/api/admin/rooms/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/configurations/**").hasAnyRole("ADMIN", "GAME_ADMIN")
.requestMatchers("/api/admin/tickets/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN")
.requestMatchers("/api/admin/quick-answers/**").hasAnyRole("ADMIN", "TICKETS_SUPPORT", "GAME_ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().denyAll()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
List<String> allowedOrigins = Stream.concat(
Stream.of(
"http://localhost:5173",
"http://localhost:3000"
),
frontendUrl != null && !frontendUrl.isBlank()
? Arrays.stream(frontendUrl.split("\\s*,\\s*")).filter(s -> !s.isBlank())
: Stream.empty()
).distinct().collect(Collectors.toList());
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(allowedOrigins);
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/admin/**", configuration);
return source;
}
}

View File

@@ -0,0 +1,82 @@
package com.honey.honey.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.MutablePropertySources;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
/**
* Loads configuration from a mounted secret file (tmpfs) with fallback to environment variables.
* This allows switching between Railway (env vars) and Inferno (mounted file) deployments.
*
* Priority:
* 1. Mounted file at /run/secrets/honey-config.properties (Inferno)
* 2. Environment variables (Railway)
*/
@Slf4j
public class ConfigLoader implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {
private static final String SECRET_FILE_PATH = "/run/secrets/honey-config.properties";
@Override
public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
ConfigurableEnvironment environment = event.getEnvironment();
MutablePropertySources propertySources = environment.getPropertySources();
Map<String, Object> configProperties = new HashMap<>();
// Try to load from mounted file first (Inferno deployment)
File secretFile = new File(SECRET_FILE_PATH);
if (secretFile.exists() && secretFile.isFile() && secretFile.canRead()) {
log.info("📁 Loading configuration from mounted secret file: {}", SECRET_FILE_PATH);
try {
Properties props = new Properties();
try (FileInputStream fis = new FileInputStream(secretFile)) {
props.load(fis);
}
for (String key : props.stringPropertyNames()) {
String value = props.getProperty(key);
configProperties.put(key, value);
log.debug("Loaded from file: {} = {}", key, maskSensitiveValue(key, value));
}
log.info("✅ Successfully loaded {} properties from secret file", configProperties.size());
} catch (IOException e) {
log.warn("⚠️ Failed to read secret file, falling back to environment variables: {}", e.getMessage());
}
} else {
log.info("📝 Secret file not found at {}, using environment variables", SECRET_FILE_PATH);
}
// Environment variables are already loaded by Spring Boot by default
// We just add file-based config as a higher priority source if it exists
if (!configProperties.isEmpty()) {
propertySources.addFirst(new MapPropertySource("secretFileConfig", configProperties));
log.info("✅ Configuration loaded: {} properties from file, environment variables as fallback",
configProperties.size());
} else {
log.info("✅ Using environment variables for configuration");
}
}
private String maskSensitiveValue(String key, String value) {
if (value == null) return "null";
if (key.toLowerCase().contains("password") ||
key.toLowerCase().contains("token") ||
key.toLowerCase().contains("secret") ||
key.toLowerCase().contains("key")) {
return value.length() > 4 ? value.substring(0, 2) + "***" + value.substring(value.length() - 2) : "***";
}
return value;
}
}

View File

@@ -0,0 +1,53 @@
package com.honey.honey.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Configuration
public class CorsConfig {
@Value("${FRONTEND_URL:}")
private String frontendUrl;
private static final List<String> ADDITIONAL_ORIGINS = Arrays.asList(
"https://honey-test-fe-production.up.railway.app",
"https://web.telegram.org",
"https://webk.telegram.org",
"https://t.me"
);
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
List<String> origins = Stream.concat(
Arrays.stream(frontendUrl != null && !frontendUrl.isBlank()
? frontendUrl.split("\\s*,\\s*")
: new String[]{}),
ADDITIONAL_ORIGINS.stream()
).filter(s -> s != null && !s.isBlank()).distinct().collect(Collectors.toList());
if (origins.isEmpty()) {
origins = ADDITIONAL_ORIGINS;
}
registry.addMapping("/**")
.allowedOrigins(origins.toArray(new String[0]))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
};
}
}

View File

@@ -0,0 +1,61 @@
package com.honey.honey.config;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
@Configuration
public class LocaleConfig {
// Supported languages
public static final List<String> SUPPORTED_LANGUAGES = Arrays.asList(
"EN", "RU", "DE", "IT", "NL", "PL", "FR", "ES", "ID", "TR"
);
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setDefaultEncoding("UTF-8");
messageSource.setFallbackToSystemLocale(false);
return messageSource;
}
@Bean
public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.ENGLISH);
return resolver;
}
/**
* Converts language code (EN, RU, etc.) to Locale.
*/
public static Locale languageCodeToLocale(String languageCode) {
if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) {
return Locale.ENGLISH;
}
String upperCode = languageCode.toUpperCase();
// Handle special cases
switch (upperCode) {
case "ID":
return new Locale("id"); // Indonesian
default:
try {
return new Locale(upperCode.toLowerCase());
} catch (Exception e) {
return Locale.ENGLISH; // Default fallback
}
}
}
}

View File

@@ -0,0 +1,33 @@
package com.honey.honey.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springdoc.core.models.GroupedOpenApi;
/**
* OpenAPI / Swagger configuration for the public API only.
* Admin endpoints (/api/admin/**) are excluded from the documentation.
*/
@Configuration
public class OpenApiConfig {
@Bean
public GroupedOpenApi publicApi() {
return GroupedOpenApi.builder()
.group("public")
.pathsToMatch("/**")
.pathsToExclude("/api/admin/**")
.build();
}
@Bean
public OpenAPI honeyOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Honey Public API")
.description("API for the Honey frontend. Admin panel endpoints are not included.")
.version("1.0"));
}
}

View File

@@ -0,0 +1,35 @@
package com.honey.honey.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "telegram")
@Data
public class TelegramProperties {
private String botToken;
/**
* Bot token for checking channel membership.
* Can be set via environment variable TELEGRAM_CHANNEL_CHECKER_BOT_TOKEN
* or in mounted file at /run/secrets/honey-config.properties as telegram.channel-checker-bot-token
*/
private String channelCheckerBotToken;
/**
* Channel ID for follow tasks (e.g., "@win_spin_news" or numeric ID).
* Can be set via environment variable TELEGRAM_FOLLOW_TASK_CHANNEL_ID
* or in mounted file at /run/secrets/honey-config.properties as telegram.follow-task-channel-id
*/
private String followTaskChannelId;
/**
* Channel ID for follow withdrawals channel task (e.g., "@win_spin_withdrawals" or numeric ID).
* Can be set via environment variable TELEGRAM_FOLLOW_TASK_CHANNEL_ID_2
* or in mounted file at /run/secrets/honey-config.properties as telegram.follow-task-channel-id-2
*/
private String followTaskChannelId2;
}

View File

@@ -0,0 +1,51 @@
package com.honey.honey.config;
import com.honey.honey.security.AuthInterceptor;
import com.honey.honey.security.RateLimitInterceptor;
import com.honey.honey.security.UserRateLimitInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final RateLimitInterceptor rateLimitInterceptor;
private final UserRateLimitInterceptor userRateLimitInterceptor;
@Override
public void addInterceptors(org.springframework.web.servlet.config.annotation.InterceptorRegistry registry) {
// NOTE: Rate limiting is NOT applied to Telegram webhook endpoint
// Telegram sends webhooks from multiple IPs and we need to process all updates, especially payments
// Rate limiting interceptor is only for bot registration endpoint (if needed elsewhere)
// User session interceptor for all other authenticated endpoints
registry.addInterceptor(authInterceptor)
.excludePathPatterns(
"/ping",
"/actuator/**",
"/api/auth/tma/session", // Session creation endpoint doesn't require auth
"/api/telegram/webhook/**", // Telegram webhook (token in path, validated in controller)
"/avatars/**", // Avatar static files don't require auth (served by Nginx in production)
"/api/check_user/**", // User check endpoint for external applications (open endpoint)
"/api/deposit_webhook/**", // 3rd party deposit completion webhook (token in path, no auth)
"/api/notify_broadcast/**", // Notify broadcast start/stop (token in path, no auth)
"/api/admin/**", // Admin endpoints are handled by Spring Security
// Swagger / OpenAPI docs (no auth required for documentation)
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs",
"/v3/api-docs/**",
"/webjars/**",
"/swagger-resources/**",
"/configuration/**"
);
// User-based rate limiting for payment creation and payout creation (applied after auth interceptor)
registry.addInterceptor(userRateLimitInterceptor)
.addPathPatterns("/api/payments/create", "/api/payouts");
}
}

View File

@@ -0,0 +1,201 @@
package com.honey.honey.controller;
import com.honey.honey.model.Payment;
import com.honey.honey.model.Payout;
import com.honey.honey.repository.PaymentRepository;
import com.honey.honey.repository.PayoutRepository;
import com.honey.honey.repository.UserARepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/analytics")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminAnalyticsController {
private final UserARepository userARepository;
private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository;
/**
* Get revenue and payout time series data for charts.
* @param range Time range: 7d, 30d, 90d, 1y, all
* @return Time series data with daily/weekly/monthly aggregation
*/
@GetMapping("/revenue")
public ResponseEntity<Map<String, Object>> getRevenueAnalytics(
@RequestParam(defaultValue = "30d") String range) {
Instant now = Instant.now();
Instant startDate;
String granularity;
// Determine start date and granularity based on range
switch (range.toLowerCase()) {
case "7d":
startDate = now.minus(7, ChronoUnit.DAYS);
granularity = "daily";
break;
case "30d":
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
break;
case "90d":
startDate = now.minus(90, ChronoUnit.DAYS);
granularity = "daily";
break;
case "1y":
startDate = now.minus(365, ChronoUnit.DAYS);
granularity = "weekly";
break;
case "all":
startDate = Instant.ofEpochSecond(0); // All time
granularity = "monthly";
break;
default:
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
}
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startDate;
while (current.isBefore(now)) {
Instant periodEnd;
if (granularity.equals("daily")) {
periodEnd = current.plus(1, ChronoUnit.DAYS);
} else if (granularity.equals("weekly")) {
periodEnd = current.plus(7, ChronoUnit.DAYS);
} else {
periodEnd = current.plus(30, ChronoUnit.DAYS);
}
if (periodEnd.isAfter(now)) {
periodEnd = now;
}
// CRYPTO only: revenue and payouts in USD for this period
java.math.BigDecimal revenueUsd = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtBetween(
Payment.PaymentStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO);
java.math.BigDecimal payoutsUsd = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtBetween(
Payout.PayoutStatus.COMPLETED, current, periodEnd).orElse(java.math.BigDecimal.ZERO);
java.math.BigDecimal netRevenueUsd = revenueUsd.subtract(payoutsUsd);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("revenue", revenueUsd);
point.put("payouts", payoutsUsd);
point.put("netRevenue", netRevenueUsd);
dataPoints.add(point);
current = periodEnd;
}
Map<String, Object> response = new HashMap<>();
response.put("range", range);
response.put("granularity", granularity);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
/**
* Get user activity time series data (registrations, active players, rounds).
* @param range Time range: 7d, 30d, 90d, 1y, all
* @return Time series data
*/
@GetMapping("/activity")
public ResponseEntity<Map<String, Object>> getActivityAnalytics(
@RequestParam(defaultValue = "30d") String range) {
Instant now = Instant.now();
Instant startDate;
String granularity;
switch (range.toLowerCase()) {
case "7d":
startDate = now.minus(7, ChronoUnit.DAYS);
granularity = "daily";
break;
case "30d":
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
break;
case "90d":
startDate = now.minus(90, ChronoUnit.DAYS);
granularity = "daily";
break;
case "1y":
startDate = now.minus(365, ChronoUnit.DAYS);
granularity = "weekly";
break;
case "all":
startDate = Instant.ofEpochSecond(0);
granularity = "monthly";
break;
default:
startDate = now.minus(30, ChronoUnit.DAYS);
granularity = "daily";
}
List<Map<String, Object>> dataPoints = new ArrayList<>();
Instant current = startDate;
while (current.isBefore(now)) {
Instant periodEnd;
if (granularity.equals("daily")) {
periodEnd = current.plus(1, ChronoUnit.DAYS);
} else if (granularity.equals("weekly")) {
periodEnd = current.plus(7, ChronoUnit.DAYS);
} else {
periodEnd = current.plus(30, ChronoUnit.DAYS);
}
if (periodEnd.isAfter(now)) {
periodEnd = now;
}
// Convert to Unix timestamps for UserA queries
int periodStartTs = (int) current.getEpochSecond();
int periodEndTs = (int) periodEnd.getEpochSecond();
// Count new registrations in this period (between current and periodEnd)
long newUsers = userARepository.countByDateRegBetween(periodStartTs, periodEndTs);
// Count active players (logged in) in this period
long activePlayers = userARepository.countByDateLoginBetween(periodStartTs, periodEndTs);
Map<String, Object> point = new HashMap<>();
point.put("date", current.getEpochSecond());
point.put("newUsers", newUsers);
point.put("activePlayers", activePlayers);
point.put("rounds", 0L);
dataPoints.add(point);
current = periodEnd;
}
Map<String, Object> response = new HashMap<>();
response.put("range", range);
response.put("granularity", granularity);
response.put("data", dataPoints);
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,181 @@
package com.honey.honey.controller;
import com.honey.honey.model.Payment;
import com.honey.honey.model.Payout;
import com.honey.honey.model.SupportTicket;
import com.honey.honey.model.UserA;
import com.honey.honey.repository.PaymentRepository;
import com.honey.honey.repository.PayoutRepository;
import com.honey.honey.repository.SupportTicketRepository;
import com.honey.honey.repository.UserARepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/dashboard")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminDashboardController {
private final UserARepository userARepository;
private final PaymentRepository paymentRepository;
private final PayoutRepository payoutRepository;
private final SupportTicketRepository supportTicketRepository;
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getDashboardStats() {
Instant now = Instant.now();
Instant todayStart = now.truncatedTo(ChronoUnit.DAYS);
Instant weekStart = now.minus(7, ChronoUnit.DAYS);
Instant monthStart = now.minus(30, ChronoUnit.DAYS);
Instant dayAgo = now.minus(24, ChronoUnit.HOURS);
Instant weekAgo = now.minus(7, ChronoUnit.DAYS);
Instant monthAgo = now.minus(30, ChronoUnit.DAYS);
// Convert to Unix timestamps (seconds) for UserA date fields
int todayStartTs = (int) todayStart.getEpochSecond();
int weekStartTs = (int) weekStart.getEpochSecond();
int monthStartTs = (int) monthStart.getEpochSecond();
int dayAgoTs = (int) dayAgo.getEpochSecond();
int weekAgoTs = (int) weekAgo.getEpochSecond();
int monthAgoTs = (int) monthAgo.getEpochSecond();
Map<String, Object> stats = new HashMap<>();
// Total Users
long totalUsers = userARepository.count();
long newUsersToday = userARepository.countByDateRegAfter(todayStartTs);
long newUsersWeek = userARepository.countByDateRegAfter(weekStartTs);
long newUsersMonth = userARepository.countByDateRegAfter(monthStartTs);
// Active Players (users who logged in recently)
long activePlayers24h = userARepository.countByDateLoginAfter(dayAgoTs);
long activePlayers7d = userARepository.countByDateLoginAfter(weekAgoTs);
long activePlayers30d = userARepository.countByDateLoginAfter(monthAgoTs);
// Revenue (from completed payments) - in Stars
int totalRevenue = paymentRepository.sumStarsAmountByStatus(Payment.PaymentStatus.COMPLETED)
.orElse(0);
int revenueToday = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payment.PaymentStatus.COMPLETED, todayStart).orElse(0);
int revenueWeek = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payment.PaymentStatus.COMPLETED, weekStart).orElse(0);
int revenueMonth = paymentRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payment.PaymentStatus.COMPLETED, monthStart).orElse(0);
// Payouts (from completed payouts) - in Stars
int totalPayouts = payoutRepository.sumStarsAmountByStatus(Payout.PayoutStatus.COMPLETED)
.orElse(0);
int payoutsToday = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payout.PayoutStatus.COMPLETED, todayStart).orElse(0);
int payoutsWeek = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payout.PayoutStatus.COMPLETED, weekStart).orElse(0);
int payoutsMonth = payoutRepository.sumStarsAmountByStatusAndCreatedAtAfter(
Payout.PayoutStatus.COMPLETED, monthStart).orElse(0);
// Net Revenue (in Stars)
int netRevenue = totalRevenue - totalPayouts;
int netRevenueToday = revenueToday - payoutsToday;
int netRevenueWeek = revenueWeek - payoutsWeek;
int netRevenueMonth = revenueMonth - payoutsMonth;
// CRYPTO only: revenue and payouts in USD (for dashboard / financial analytics)
BigDecimal cryptoRevenueTotal = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNull(Payment.PaymentStatus.COMPLETED).orElse(BigDecimal.ZERO);
BigDecimal cryptoRevenueToday = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, todayStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoRevenueWeek = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, weekStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoRevenueMonth = paymentRepository.sumUsdAmountByStatusAndUsdAmountNotNullAndCreatedAtAfter(Payment.PaymentStatus.COMPLETED, monthStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsTotal = payoutRepository.sumUsdAmountByTypeCryptoAndStatus(Payout.PayoutStatus.COMPLETED).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsToday = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, todayStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsWeek = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, weekStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoPayoutsMonth = payoutRepository.sumUsdAmountByTypeCryptoAndStatusAndCreatedAtAfter(Payout.PayoutStatus.COMPLETED, monthStart).orElse(BigDecimal.ZERO);
BigDecimal cryptoNetRevenueTotal = cryptoRevenueTotal.subtract(cryptoPayoutsTotal);
BigDecimal cryptoNetRevenueToday = cryptoRevenueToday.subtract(cryptoPayoutsToday);
BigDecimal cryptoNetRevenueWeek = cryptoRevenueWeek.subtract(cryptoPayoutsWeek);
BigDecimal cryptoNetRevenueMonth = cryptoRevenueMonth.subtract(cryptoPayoutsMonth);
// Support Tickets
long openTickets = supportTicketRepository.countByStatus(SupportTicket.TicketStatus.OPENED);
// Count tickets closed today
long ticketsResolvedToday = supportTicketRepository.findAll().stream()
.filter(t -> t.getStatus() == SupportTicket.TicketStatus.CLOSED &&
t.getUpdatedAt() != null &&
t.getUpdatedAt().isAfter(todayStart))
.count();
// Build response
stats.put("users", Map.of(
"total", totalUsers,
"newToday", newUsersToday,
"newWeek", newUsersWeek,
"newMonth", newUsersMonth
));
stats.put("activePlayers", Map.of(
"last24h", activePlayers24h,
"last7d", activePlayers7d,
"last30d", activePlayers30d
));
stats.put("revenue", Map.of(
"total", totalRevenue,
"today", revenueToday,
"week", revenueWeek,
"month", revenueMonth
));
stats.put("payouts", Map.of(
"total", totalPayouts,
"today", payoutsToday,
"week", payoutsWeek,
"month", payoutsMonth
));
stats.put("netRevenue", Map.of(
"total", netRevenue,
"today", netRevenueToday,
"week", netRevenueWeek,
"month", netRevenueMonth
));
Map<String, Object> crypto = new HashMap<>();
crypto.put("revenueUsd", cryptoRevenueTotal);
crypto.put("revenueUsdToday", cryptoRevenueToday);
crypto.put("revenueUsdWeek", cryptoRevenueWeek);
crypto.put("revenueUsdMonth", cryptoRevenueMonth);
crypto.put("payoutsUsd", cryptoPayoutsTotal);
crypto.put("payoutsUsdToday", cryptoPayoutsToday);
crypto.put("payoutsUsdWeek", cryptoPayoutsWeek);
crypto.put("payoutsUsdMonth", cryptoPayoutsMonth);
crypto.put("profitUsd", cryptoNetRevenueTotal);
crypto.put("profitUsdToday", cryptoNetRevenueToday);
crypto.put("profitUsdWeek", cryptoNetRevenueWeek);
crypto.put("profitUsdMonth", cryptoNetRevenueMonth);
stats.put("crypto", crypto);
stats.put("rounds", Map.of(
"total", 0L,
"today", 0L,
"week", 0L,
"month", 0L,
"avgPool", 0
));
stats.put("supportTickets", Map.of(
"open", openTickets,
"resolvedToday", ticketsResolvedToday
));
return ResponseEntity.ok(stats);
}
}

View File

@@ -0,0 +1,36 @@
package com.honey.honey.controller;
import com.honey.honey.service.FeatureSwitchService;
import com.honey.honey.service.FeatureSwitchService.FeatureSwitchDto;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/feature-switches")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminFeatureSwitchController {
private final FeatureSwitchService featureSwitchService;
@GetMapping
public ResponseEntity<List<FeatureSwitchDto>> getAll() {
return ResponseEntity.ok(featureSwitchService.getAll());
}
@PatchMapping("/{key}")
public ResponseEntity<FeatureSwitchDto> update(
@PathVariable String key,
@RequestBody Map<String, Boolean> body) {
Boolean enabled = body != null ? body.get("enabled") : null;
if (enabled == null) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(featureSwitchService.setEnabled(key, enabled));
}
}

View File

@@ -0,0 +1,51 @@
package com.honey.honey.controller;
import com.honey.honey.dto.AdminLoginRequest;
import com.honey.honey.dto.AdminLoginResponse;
import com.honey.honey.service.AdminService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminLoginController {
private final AdminService adminService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody AdminLoginRequest request) {
if (request.getUsername() == null || request.getPassword() == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Username and password are required");
}
Optional<String> tokenOpt = adminService.authenticate(
request.getUsername(),
request.getPassword()
);
if (tokenOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Invalid credentials");
}
// Get admin to retrieve role
var adminOpt = adminService.getAdminByUsername(request.getUsername());
String role = adminOpt.map(admin -> admin.getRole()).orElse("ROLE_ADMIN");
return ResponseEntity.ok(new AdminLoginResponse(
tokenOpt.get(),
request.getUsername(),
role
));
}
}

View File

@@ -0,0 +1,26 @@
package com.honey.honey.controller;
import com.honey.honey.dto.AdminMasterDto;
import com.honey.honey.service.AdminMasterService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin/masters")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminMasterController {
private final AdminMasterService adminMasterService;
@GetMapping
public ResponseEntity<List<AdminMasterDto>> getMasters() {
return ResponseEntity.ok(adminMasterService.getMasters());
}
}

View File

@@ -0,0 +1,42 @@
package com.honey.honey.controller;
import com.honey.honey.dto.NotifyBroadcastRequest;
import com.honey.honey.service.NotificationBroadcastService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
/**
* Admin API to trigger or stop notification broadcast (ADMIN only).
*/
@Slf4j
@RestController
@RequestMapping("/api/admin/notifications")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminNotificationController {
private final NotificationBroadcastService notificationBroadcastService;
@PostMapping("/send")
public ResponseEntity<Void> send(@RequestBody(required = false) NotifyBroadcastRequest body) {
NotifyBroadcastRequest req = body != null ? body : new NotifyBroadcastRequest();
notificationBroadcastService.runBroadcast(
req.getMessage(),
req.getImageUrl(),
req.getVideoUrl(),
req.getUserIdFrom(),
req.getUserIdTo(),
req.getButtonText(),
req.getIgnoreBlocked());
return ResponseEntity.accepted().build();
}
@PostMapping("/stop")
public ResponseEntity<Void> stop() {
notificationBroadcastService.requestStop();
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,147 @@
package com.honey.honey.controller;
import com.honey.honey.dto.AdminPaymentDto;
import com.honey.honey.model.Payment;
import com.honey.honey.model.UserA;
import com.honey.honey.repository.PaymentRepository;
import com.honey.honey.repository.UserARepository;
import com.honey.honey.repository.UserDRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/payments")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public class AdminPaymentController {
private final PaymentRepository paymentRepository;
private final UserARepository userARepository;
private final UserDRepository userDRepository;
private boolean isGameAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
}
@GetMapping
public ResponseEntity<Map<String, Object>> getPayments(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
// Filters
@RequestParam(required = false) String status,
@RequestParam(required = false) Integer userId,
@RequestParam(required = false) String dateFrom,
@RequestParam(required = false) String dateTo,
@RequestParam(required = false) Long amountMin,
@RequestParam(required = false) Long amountMax) {
// Build sort
Sort sort = Sort.by("createdAt").descending(); // Default sort
if (sortBy != null && !sortBy.trim().isEmpty()) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir)
? Sort.Direction.ASC
: Sort.Direction.DESC;
sort = Sort.by(direction, sortBy);
}
Pageable pageable = PageRequest.of(page, size, sort);
List<Integer> masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of();
// Build specification
Specification<Payment> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (!masterIds.isEmpty()) {
predicates.add(cb.not(root.get("userId").in(masterIds)));
}
if (status != null && !status.trim().isEmpty()) {
try {
Payment.PaymentStatus paymentStatus = Payment.PaymentStatus.valueOf(status.toUpperCase());
predicates.add(cb.equal(root.get("status"), paymentStatus));
} catch (IllegalArgumentException e) {
// Invalid status, ignore
}
}
if (userId != null) {
predicates.add(cb.equal(root.get("userId"), userId));
}
// Date range filters would need Instant conversion
// For now, we'll skip them or implement if needed
return cb.and(predicates.toArray(new Predicate[0]));
};
Page<Payment> paymentPage = paymentRepository.findAll(spec, pageable);
// Fetch user names
List<Integer> userIds = paymentPage.getContent().stream()
.map(Payment::getUserId)
.distinct()
.collect(Collectors.toList());
Map<Integer, String> userNameMap = userARepository.findAllById(userIds).stream()
.collect(Collectors.toMap(
UserA::getId,
u -> u.getTelegramName() != null && !u.getTelegramName().equals("-")
? u.getTelegramName()
: u.getScreenName()
));
// Convert to DTOs
Page<AdminPaymentDto> dtoPage = paymentPage.map(payment -> {
String userName = userNameMap.getOrDefault(payment.getUserId(), "Unknown");
return AdminPaymentDto.builder()
.id(payment.getId())
.userId(payment.getUserId())
.userName(userName)
.orderId(payment.getOrderId())
.starsAmount(payment.getStarsAmount())
.ticketsAmount(payment.getTicketsAmount())
.status(payment.getStatus().name())
.telegramPaymentChargeId(payment.getTelegramPaymentChargeId())
.telegramProviderPaymentChargeId(payment.getTelegramProviderPaymentChargeId())
.createdAt(payment.getCreatedAt())
.completedAt(payment.getCompletedAt())
.build();
});
Map<String, Object> response = new HashMap<>();
response.put("content", dtoPage.getContent());
response.put("totalElements", dtoPage.getTotalElements());
response.put("totalPages", dtoPage.getTotalPages());
response.put("currentPage", dtoPage.getNumber());
response.put("size", dtoPage.getSize());
response.put("hasNext", dtoPage.hasNext());
response.put("hasPrevious", dtoPage.hasPrevious());
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,204 @@
package com.honey.honey.controller;
import com.honey.honey.dto.AdminPayoutDto;
import com.honey.honey.model.Payout;
import com.honey.honey.model.UserA;
import com.honey.honey.model.UserB;
import com.honey.honey.repository.PayoutRepository;
import com.honey.honey.repository.UserARepository;
import com.honey.honey.repository.UserBRepository;
import com.honey.honey.repository.UserDRepository;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.service.PayoutService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import jakarta.persistence.criteria.Predicate;
import org.springframework.http.HttpStatus;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/payouts")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT', 'GAME_ADMIN')")
public class AdminPayoutController {
private final PayoutRepository payoutRepository;
private final UserARepository userARepository;
private final UserBRepository userBRepository;
private final UserDRepository userDRepository;
private final PayoutService payoutService;
private final LocalizationService localizationService;
private boolean isGameAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
}
@GetMapping
public ResponseEntity<Map<String, Object>> getPayouts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
// Filters
@RequestParam(required = false) String status,
@RequestParam(required = false) Integer userId,
@RequestParam(required = false) String type) {
// Build sort
Sort sort = Sort.by("createdAt").descending(); // Default sort
if (sortBy != null && !sortBy.trim().isEmpty()) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir)
? Sort.Direction.ASC
: Sort.Direction.DESC;
sort = Sort.by(direction, sortBy);
}
Pageable pageable = PageRequest.of(page, size, sort);
List<Integer> masterIds = isGameAdmin() ? userDRepository.findMasterUserIds() : List.of();
// Build specification
Specification<Payout> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (!masterIds.isEmpty()) {
predicates.add(cb.not(root.get("userId").in(masterIds)));
}
if (status != null && !status.trim().isEmpty()) {
try {
Payout.PayoutStatus payoutStatus = Payout.PayoutStatus.valueOf(status.toUpperCase());
predicates.add(cb.equal(root.get("status"), payoutStatus));
} catch (IllegalArgumentException e) {
// Invalid status, ignore
}
}
if (userId != null) {
predicates.add(cb.equal(root.get("userId"), userId));
}
if (type != null && !type.trim().isEmpty()) {
try {
Payout.PayoutType payoutType = Payout.PayoutType.valueOf(type.toUpperCase());
predicates.add(cb.equal(root.get("type"), payoutType));
} catch (IllegalArgumentException e) {
// Invalid type, ignore
}
}
return cb.and(predicates.toArray(new Predicate[0]));
};
Page<Payout> payoutPage = payoutRepository.findAll(spec, pageable);
// Fetch user names
List<Integer> userIds = payoutPage.getContent().stream()
.map(Payout::getUserId)
.distinct()
.collect(Collectors.toList());
Map<Integer, String> userNameMap = userARepository.findAllById(userIds).stream()
.collect(Collectors.toMap(
UserA::getId,
u -> u.getTelegramName() != null && !u.getTelegramName().equals("-")
? u.getTelegramName()
: u.getScreenName()
));
// Convert to DTOs
Page<AdminPayoutDto> dtoPage = payoutPage.map(payout -> {
String userName = userNameMap.getOrDefault(payout.getUserId(), "Unknown");
return AdminPayoutDto.builder()
.id(payout.getId())
.userId(payout.getUserId())
.userName(userName)
.username(payout.getUsername())
.type(payout.getType().name())
.giftName(payout.getGiftName() != null ? payout.getGiftName().name() : null)
.total(payout.getTotal())
.starsAmount(payout.getStarsAmount())
.quantity(payout.getQuantity())
.status(payout.getStatus().name())
.createdAt(payout.getCreatedAt())
.resolvedAt(payout.getResolvedAt())
.build();
});
Map<String, Object> response = new HashMap<>();
response.put("content", dtoPage.getContent());
response.put("totalElements", dtoPage.getTotalElements());
response.put("totalPages", dtoPage.getTotalPages());
response.put("currentPage", dtoPage.getNumber());
response.put("size", dtoPage.getSize());
response.put("hasNext", dtoPage.hasNext());
response.put("hasPrevious", dtoPage.hasPrevious());
return ResponseEntity.ok(response);
}
@PostMapping("/{id}/complete")
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT')")
public ResponseEntity<?> completePayout(@PathVariable Long id) {
try {
Optional<Payout> payoutOpt = payoutRepository.findById(id);
if (payoutOpt.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("error", localizationService.getMessage("payout.error.notFound", String.valueOf(id))));
}
Payout payout = payoutOpt.get();
if (payout.getStatus() != Payout.PayoutStatus.PROCESSING) {
return ResponseEntity.badRequest()
.body(Map.of("error", localizationService.getMessage("payout.error.onlyProcessingCanComplete", payout.getStatus().name())));
}
payoutService.markPayoutCompleted(id);
return ResponseEntity.ok(Map.of("message", "Payout marked as completed"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAnyRole('ADMIN', 'PAYOUT_SUPPORT')")
public ResponseEntity<?> cancelPayout(@PathVariable Long id) {
try {
Optional<Payout> payoutOpt = payoutRepository.findById(id);
if (payoutOpt.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("error", localizationService.getMessage("payout.error.notFound", String.valueOf(id))));
}
Payout payout = payoutOpt.get();
if (payout.getStatus() != Payout.PayoutStatus.PROCESSING) {
return ResponseEntity.badRequest()
.body(Map.of("error", localizationService.getMessage("payout.error.onlyProcessingCanCancel", payout.getStatus().name())));
}
payoutService.markPayoutCancelled(id);
return ResponseEntity.ok(Map.of("message", "Payout cancelled"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage()));
}
}
}

View File

@@ -0,0 +1,139 @@
package com.honey.honey.controller;
import com.honey.honey.dto.*;
import com.honey.honey.service.AdminPromotionService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/admin/promotions")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminPromotionController {
private final AdminPromotionService adminPromotionService;
@GetMapping
public ResponseEntity<List<AdminPromotionDto>> listPromotions() {
return ResponseEntity.ok(adminPromotionService.listPromotions());
}
@GetMapping("/{id}")
public ResponseEntity<AdminPromotionDto> getPromotion(@PathVariable int id) {
return adminPromotionService.getPromotion(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<AdminPromotionDto> createPromotion(@Valid @RequestBody AdminPromotionRequest request) {
try {
AdminPromotionDto dto = adminPromotionService.createPromotion(request);
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PutMapping("/{id}")
public ResponseEntity<AdminPromotionDto> updatePromotion(
@PathVariable int id,
@Valid @RequestBody AdminPromotionRequest request) {
try {
return adminPromotionService.updatePromotion(id, request)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePromotion(@PathVariable int id) {
return adminPromotionService.deletePromotion(id)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
// --- Rewards ---
@GetMapping("/{promoId}/rewards")
public ResponseEntity<List<AdminPromotionRewardDto>> listRewards(@PathVariable int promoId) {
return ResponseEntity.ok(adminPromotionService.listRewards(promoId));
}
@PostMapping("/{promoId}/rewards")
public ResponseEntity<AdminPromotionRewardDto> createReward(
@PathVariable int promoId,
@Valid @RequestBody AdminPromotionRewardRequest request) {
try {
AdminPromotionRewardDto dto = adminPromotionService.createReward(promoId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PutMapping("/rewards/{rewardId}")
public ResponseEntity<AdminPromotionRewardDto> updateReward(
@PathVariable int rewardId,
@Valid @RequestBody AdminPromotionRewardRequest request) {
try {
return adminPromotionService.updateReward(rewardId, request)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@DeleteMapping("/rewards/{rewardId}")
public ResponseEntity<Void> deleteReward(@PathVariable int rewardId) {
return adminPromotionService.deleteReward(rewardId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
// --- Promotion users (leaderboard / results) ---
@GetMapping("/{promoId}/users")
public ResponseEntity<Map<String, Object>> getPromotionUsers(
@PathVariable int promoId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
@RequestParam(required = false) Integer userId) {
Page<AdminPromotionUserDto> dtoPage = adminPromotionService.getPromotionUsers(
promoId, page, size, sortBy, sortDir, userId);
Map<String, Object> response = new HashMap<>();
response.put("content", dtoPage.getContent());
response.put("totalElements", dtoPage.getTotalElements());
response.put("totalPages", dtoPage.getTotalPages());
response.put("currentPage", dtoPage.getNumber());
response.put("size", dtoPage.getSize());
response.put("hasNext", dtoPage.hasNext());
response.put("hasPrevious", dtoPage.hasPrevious());
return ResponseEntity.ok(response);
}
@PatchMapping("/{promoId}/users/{userId}/points")
public ResponseEntity<AdminPromotionUserDto> updatePromotionUserPoints(
@PathVariable int promoId,
@PathVariable int userId,
@Valid @RequestBody AdminPromotionUserPointsRequest request) {
Optional<AdminPromotionUserDto> updated = adminPromotionService.updatePromotionUserPoints(
promoId, userId, request.getPoints());
return updated.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,316 @@
package com.honey.honey.controller;
import com.honey.honey.dto.*;
import com.honey.honey.model.Admin;
import com.honey.honey.model.SupportMessage;
import com.honey.honey.model.SupportTicket;
import com.honey.honey.model.UserA;
import com.honey.honey.repository.AdminRepository;
import com.honey.honey.repository.SupportMessageRepository;
import com.honey.honey.repository.SupportTicketRepository;
import com.honey.honey.repository.UserARepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import jakarta.persistence.criteria.Predicate;
import jakarta.validation.Valid;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/tickets")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
public class AdminSupportTicketController {
private final SupportTicketRepository supportTicketRepository;
private final SupportMessageRepository supportMessageRepository;
private final UserARepository userARepository;
private final AdminRepository adminRepository;
@GetMapping
public ResponseEntity<Map<String, Object>> getTickets(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String status,
@RequestParam(required = false) Integer userId,
@RequestParam(required = false) String search) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
// Build specification
Specification<SupportTicket> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (status != null && !status.trim().isEmpty()) {
try {
SupportTicket.TicketStatus ticketStatus = SupportTicket.TicketStatus.valueOf(status.toUpperCase());
predicates.add(cb.equal(root.get("status"), ticketStatus));
} catch (IllegalArgumentException e) {
// Invalid status, ignore
}
}
if (userId != null) {
predicates.add(cb.equal(root.get("user").get("id"), userId));
}
if (search != null && !search.trim().isEmpty()) {
String searchPattern = "%" + search.trim() + "%";
predicates.add(cb.or(
cb.like(cb.lower(root.get("subject")), searchPattern.toLowerCase())
));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
Page<SupportTicket> ticketPage = supportTicketRepository.findAll(spec, pageable);
// Fetch user names and message counts
List<Integer> userIds = ticketPage.getContent().stream()
.map(t -> t.getUser().getId())
.distinct()
.collect(Collectors.toList());
Map<Integer, String> userNameMap = userARepository.findAllById(userIds).stream()
.collect(Collectors.toMap(
UserA::getId,
u -> u.getTelegramName() != null && !u.getTelegramName().equals("-")
? u.getTelegramName()
: u.getScreenName()
));
// Convert to DTOs
Page<AdminSupportTicketDto> dtoPage = ticketPage.map(ticket -> {
String userName = userNameMap.getOrDefault(ticket.getUser().getId(), "Unknown");
long messageCount = supportMessageRepository.countByTicketId(ticket.getId());
// Get last message preview
List<SupportMessage> lastMessages = supportMessageRepository.findByTicketIdOrderByCreatedAtAsc(ticket.getId());
String lastMessagePreview = "";
Instant lastMessageAt = null;
if (!lastMessages.isEmpty()) {
SupportMessage lastMsg = lastMessages.get(lastMessages.size() - 1);
lastMessagePreview = lastMsg.getMessage().length() > 100
? lastMsg.getMessage().substring(0, 100) + "..."
: lastMsg.getMessage();
lastMessageAt = lastMsg.getCreatedAt();
}
return AdminSupportTicketDto.builder()
.id(ticket.getId())
.userId(ticket.getUser().getId())
.userName(userName)
.subject(ticket.getSubject())
.status(ticket.getStatus().name())
.createdAt(ticket.getCreatedAt())
.updatedAt(ticket.getUpdatedAt())
.messageCount(messageCount)
.lastMessagePreview(lastMessagePreview)
.lastMessageAt(lastMessageAt)
.build();
});
Map<String, Object> response = new HashMap<>();
response.put("content", dtoPage.getContent());
response.put("totalElements", dtoPage.getTotalElements());
response.put("totalPages", dtoPage.getTotalPages());
response.put("currentPage", dtoPage.getNumber());
response.put("size", dtoPage.getSize());
response.put("hasNext", dtoPage.hasNext());
response.put("hasPrevious", dtoPage.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
public ResponseEntity<AdminSupportTicketDetailDto> getTicketDetail(@PathVariable Long id) {
return supportTicketRepository.findById(id)
.map(ticket -> {
String userName = ticket.getUser().getTelegramName() != null && !ticket.getUser().getTelegramName().equals("-")
? ticket.getUser().getTelegramName()
: ticket.getUser().getScreenName();
List<SupportMessage> messages = supportMessageRepository.findByTicketIdOrderByCreatedAtAsc(id);
// Get all admin user IDs from admins table
List<Integer> adminUserIds = adminRepository.findAll().stream()
.filter(admin -> admin.getUserId() != null)
.map(Admin::getUserId)
.collect(Collectors.toList());
List<AdminSupportMessageDto> messageDtos = messages.stream()
.map(msg -> {
// Check if message is from admin by checking if user_id is in admins table
boolean isAdmin = adminUserIds.contains(msg.getUser().getId());
String msgUserName = msg.getUser().getTelegramName() != null && !msg.getUser().getTelegramName().equals("-")
? msg.getUser().getTelegramName()
: msg.getUser().getScreenName();
return AdminSupportMessageDto.builder()
.id(msg.getId())
.userId(msg.getUser().getId())
.userName(msgUserName)
.message(msg.getMessage())
.createdAt(msg.getCreatedAt())
.isAdmin(isAdmin)
.build();
})
.collect(Collectors.toList());
return AdminSupportTicketDetailDto.builder()
.id(ticket.getId())
.userId(ticket.getUser().getId())
.userName(userName)
.subject(ticket.getSubject())
.status(ticket.getStatus().name())
.createdAt(ticket.getCreatedAt())
.updatedAt(ticket.getUpdatedAt())
.messages(messageDtos)
.build();
})
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{id}/reply")
public ResponseEntity<?> replyToTicket(
@PathVariable Long id,
@Valid @RequestBody SupportTicketReplyRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String adminUsername;
if (authentication.getPrincipal() instanceof UserDetails) {
adminUsername = ((UserDetails) authentication.getPrincipal()).getUsername();
} else {
// Fallback if principal is a String
adminUsername = authentication.getName();
}
// Get admin entity to find user_id
Admin admin = adminRepository.findByUsername(adminUsername)
.orElseThrow(() -> new RuntimeException("Admin not found: " + adminUsername));
if (admin.getUserId() == null) {
return ResponseEntity.badRequest().body(Map.of("error", "Admin account is not linked to a user account"));
}
// Get the admin's UserA entity
UserA adminUser = userARepository.findById(admin.getUserId())
.orElseThrow(() -> new RuntimeException("Admin user account not found: " + admin.getUserId()));
return supportTicketRepository.findById(id)
.map(ticket -> {
// Create message without prefix, using admin's user_id
SupportMessage message = SupportMessage.builder()
.ticket(ticket)
.user(adminUser) // Use admin's UserA entity
.message(request.getMessage()) // Save message as-is, no prefix
.build();
supportMessageRepository.save(message);
// Update ticket updated_at
ticket.setUpdatedAt(java.time.Instant.now());
if (ticket.getStatus() == SupportTicket.TicketStatus.CLOSED) {
ticket.setStatus(SupportTicket.TicketStatus.OPENED); // Reopen if admin replies
}
supportTicketRepository.save(ticket);
return ResponseEntity.ok(Map.of("message", "Reply sent successfully"));
})
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{id}/close")
public ResponseEntity<?> closeTicket(@PathVariable Long id) {
return supportTicketRepository.findById(id)
.map(ticket -> {
ticket.setStatus(SupportTicket.TicketStatus.CLOSED);
ticket.setUpdatedAt(java.time.Instant.now());
supportTicketRepository.save(ticket);
return ResponseEntity.ok(Map.of("message", "Ticket closed"));
})
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{id}/reopen")
public ResponseEntity<?> reopenTicket(@PathVariable Long id) {
return supportTicketRepository.findById(id)
.map(ticket -> {
ticket.setStatus(SupportTicket.TicketStatus.OPENED);
ticket.setUpdatedAt(java.time.Instant.now());
supportTicketRepository.save(ticket);
return ResponseEntity.ok(Map.of("message", "Ticket reopened"));
})
.orElse(ResponseEntity.notFound().build());
}
@PutMapping("/messages/{messageId}")
public ResponseEntity<?> editMessage(
@PathVariable Long messageId,
@Valid @RequestBody SupportTicketReplyRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String adminUsername;
if (authentication.getPrincipal() instanceof UserDetails) {
adminUsername = ((UserDetails) authentication.getPrincipal()).getUsername();
} else {
adminUsername = authentication.getName();
}
// Get admin entity to find user_id
Admin admin = adminRepository.findByUsername(adminUsername)
.orElseThrow(() -> new RuntimeException("Admin not found: " + adminUsername));
if (admin.getUserId() == null) {
return ResponseEntity.badRequest().body(Map.of("error", "Admin account is not linked to a user account"));
}
return supportMessageRepository.findById(messageId)
.map(message -> {
// Check if message is from this admin
if (!message.getUser().getId().equals(admin.getUserId())) {
return ResponseEntity.badRequest().body(Map.of("error", "You can only edit your own messages"));
}
// Check if user is an admin (verify in admins table)
boolean isAdmin = adminRepository.findAll().stream()
.anyMatch(a -> a.getUserId() != null && a.getUserId().equals(message.getUser().getId()));
if (!isAdmin) {
return ResponseEntity.badRequest().body(Map.of("error", "Only admin messages can be edited"));
}
// Update message
message.setMessage(request.getMessage());
supportMessageRepository.save(message);
// Update ticket updated_at
message.getTicket().setUpdatedAt(java.time.Instant.now());
supportTicketRepository.save(message.getTicket());
return ResponseEntity.ok(Map.of("message", "Message updated successfully"));
})
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,250 @@
package com.honey.honey.controller;
import com.honey.honey.dto.*;
import com.honey.honey.service.AdminUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/api/admin/users")
@RequiredArgsConstructor
public class AdminUserController {
/** Sortable fields: UserA properties plus UserB/UserD (handled via custom query in service). */
private static final Set<String> SORTABLE_FIELDS = Set.of(
"id", "screenName", "telegramId", "telegramName", "isPremium",
"languageCode", "countryCode", "deviceCode", "dateReg", "dateLogin", "banned",
"balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit"
);
private static final Set<String> DEPOSIT_SORT_FIELDS = Set.of("id", "usdAmount", "status", "orderId", "createdAt", "completedAt");
private static final Set<String> WITHDRAWAL_SORT_FIELDS = Set.of("id", "usdAmount", "cryptoName", "amountToSend", "txhash", "status", "paymentId", "createdAt", "resolvedAt");
private final AdminUserService adminUserService;
private boolean isGameAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getAuthorities() == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_GAME_ADMIN".equals(a.getAuthority()));
}
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
// Filters
@RequestParam(required = false) String search,
@RequestParam(required = false) Integer banned,
@RequestParam(required = false) String countryCode,
@RequestParam(required = false) String languageCode,
@RequestParam(required = false) Integer dateRegFrom,
@RequestParam(required = false) Integer dateRegTo,
@RequestParam(required = false) Long balanceMin,
@RequestParam(required = false) Long balanceMax,
@RequestParam(required = false) Integer referralCountMin,
@RequestParam(required = false) Integer referralCountMax,
@RequestParam(required = false) Integer referrerId,
@RequestParam(required = false) Integer referralLevel,
@RequestParam(required = false) String ip) {
// Build sort. Fields on UserB/UserD (balanceA, depositTotal, withdrawTotal, referralCount) are handled in service via custom query.
Set<String> sortRequiresJoin = Set.of("balanceA", "depositTotal", "withdrawTotal", "referralCount", "profit");
String effectiveSortBy = sortBy != null && sortBy.trim().isEmpty() ? null : (sortBy != null ? sortBy.trim() : null);
if (effectiveSortBy != null && sortRequiresJoin.contains(effectiveSortBy)) {
// Pass through; service will use custom ordered query
} else if (effectiveSortBy != null && !SORTABLE_FIELDS.contains(effectiveSortBy)) {
effectiveSortBy = null;
}
Sort sort = Sort.by("id").descending();
if (effectiveSortBy != null && !sortRequiresJoin.contains(effectiveSortBy)) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC;
sort = Sort.by(direction, effectiveSortBy);
}
Pageable pageable = PageRequest.of(page, size, sort);
// Convert balance filters from tickets (divide by 1000000) to bigint format
Long balanceMinBigint = balanceMin != null ? balanceMin * 1000000L : null;
Long balanceMaxBigint = balanceMax != null ? balanceMax * 1000000L : null;
boolean excludeMasters = isGameAdmin();
Page<AdminUserDto> dtoPage = adminUserService.getUsers(
pageable,
search,
banned,
countryCode,
languageCode,
dateRegFrom,
dateRegTo,
balanceMinBigint,
balanceMaxBigint,
referralCountMin,
referralCountMax,
referrerId,
referralLevel,
ip,
effectiveSortBy,
sortDir,
excludeMasters
);
Map<String, Object> response = new HashMap<>();
response.put("content", dtoPage.getContent());
response.put("totalElements", dtoPage.getTotalElements());
response.put("totalPages", dtoPage.getTotalPages());
response.put("currentPage", dtoPage.getNumber());
response.put("size", dtoPage.getSize());
response.put("hasNext", dtoPage.hasNext());
response.put("hasPrevious", dtoPage.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<AdminUserDetailDto> getUserDetail(@PathVariable Integer id) {
AdminUserDetailDto userDetail = adminUserService.getUserDetail(id, isGameAdmin());
if (userDetail == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(userDetail);
}
@GetMapping("/{id}/transactions")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUserTransactions(
@PathVariable Integer id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
Page<AdminTransactionDto> transactions = adminUserService.getUserTransactions(id, pageable);
Map<String, Object> response = new HashMap<>();
response.put("content", transactions.getContent());
response.put("totalElements", transactions.getTotalElements());
response.put("totalPages", transactions.getTotalPages());
response.put("currentPage", transactions.getNumber());
response.put("size", transactions.getSize());
response.put("hasNext", transactions.hasNext());
response.put("hasPrevious", transactions.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}/payments")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUserPayments(
@PathVariable Integer id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir) {
String field = sortBy != null && DEPOSIT_SORT_FIELDS.contains(sortBy.trim()) ? sortBy.trim() : "createdAt";
Sort.Direction dir = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC;
Pageable pageable = PageRequest.of(page, size, Sort.by(dir, field));
Page<UserDepositDto> deposits = adminUserService.getUserPayments(id, pageable);
Map<String, Object> response = new HashMap<>();
response.put("content", deposits.getContent());
response.put("totalElements", deposits.getTotalElements());
response.put("totalPages", deposits.getTotalPages());
response.put("currentPage", deposits.getNumber());
response.put("size", deposits.getSize());
response.put("hasNext", deposits.hasNext());
response.put("hasPrevious", deposits.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}/payouts")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUserPayouts(
@PathVariable Integer id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir) {
String field = sortBy != null && WITHDRAWAL_SORT_FIELDS.contains(sortBy.trim()) ? sortBy.trim() : "createdAt";
Sort.Direction dir = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC;
Pageable pageable = PageRequest.of(page, size, Sort.by(dir, field));
Page<UserWithdrawalDto> payouts = adminUserService.getUserPayouts(id, pageable);
Map<String, Object> response = new HashMap<>();
response.put("content", payouts.getContent());
response.put("totalElements", payouts.getTotalElements());
response.put("totalPages", payouts.getTotalPages());
response.put("currentPage", payouts.getNumber());
response.put("size", payouts.getSize());
response.put("hasNext", payouts.hasNext());
response.put("hasPrevious", payouts.hasPrevious());
return ResponseEntity.ok(response);
}
@GetMapping("/{id}/tasks")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<Map<String, Object>> getUserTasks(@PathVariable Integer id) {
Map<String, Object> tasks = adminUserService.getUserTasks(id);
return ResponseEntity.ok(tasks);
}
@PatchMapping("/{id}/ban")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> setUserBanned(
@PathVariable Integer id,
@RequestBody Map<String, Boolean> body) {
Boolean banned = body != null ? body.get("banned") : null;
if (banned == null) {
return ResponseEntity.badRequest().body(Map.of("error", "banned is required (true/false)"));
}
try {
adminUserService.setBanned(id, banned);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PatchMapping("/{id}/withdrawals-enabled")
@PreAuthorize("hasAnyRole('ADMIN', 'GAME_ADMIN')")
public ResponseEntity<?> setWithdrawalsEnabled(
@PathVariable Integer id,
@RequestBody Map<String, Boolean> body) {
Boolean enabled = body != null ? body.get("enabled") : null;
if (enabled == null) {
return ResponseEntity.badRequest().body(Map.of("error", "enabled is required (true/false)"));
}
try {
adminUserService.setWithdrawalsEnabled(id, enabled);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/{id}/balance/adjust")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> adjustBalance(
@PathVariable Integer id,
@Valid @RequestBody com.honey.honey.dto.BalanceAdjustmentRequest request) {
try {
com.honey.honey.dto.BalanceAdjustmentResponse response = adminUserService.adjustBalance(id, request);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}

View File

@@ -0,0 +1,99 @@
package com.honey.honey.controller;
import com.honey.honey.dto.CreateSessionRequest;
import com.honey.honey.dto.CreateSessionResponse;
import com.honey.honey.exception.BannedUserException;
import com.honey.honey.model.UserA;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.service.SessionService;
import com.honey.honey.service.TelegramAuthService;
import com.honey.honey.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final TelegramAuthService telegramAuthService;
private final SessionService sessionService;
private final UserService userService;
private final LocalizationService localizationService;
/**
* Creates a session by validating Telegram initData.
* This is the only endpoint that accepts initData.
* Handles user registration/login and referral system.
*/
@PostMapping("/tma/session")
public CreateSessionResponse createSession(
@RequestBody CreateSessionRequest request,
HttpServletRequest httpRequest) {
String initData = request.getInitData();
if (initData == null || initData.isBlank()) {
throw new IllegalArgumentException(localizationService.getMessage("auth.error.initDataRequired"));
}
// Validate Telegram initData signature and parse data
Map<String, Object> tgUserData = telegramAuthService.validateAndParseInitData(initData);
// Get or create user (handles registration, login update, and referral system)
// Note: Referral handling is done via bot registration endpoint, not through WebApp initData
UserA user = userService.getOrCreateUser(tgUserData, httpRequest);
if (user.getBanned() != null && user.getBanned() == 1) {
String message = localizationService.getMessageForUser(user.getId(), "auth.error.accessRestricted");
throw new BannedUserException(message);
}
// Create session
String sessionId = sessionService.createSession(user);
log.debug("Session created: userId={}", user.getId());
return CreateSessionResponse.builder()
.access_token(sessionId)
.expires_in(sessionService.getSessionTtlSeconds())
.build();
}
/**
* Logs out by invalidating the session.
* This endpoint requires authentication (Bearer token).
*/
@PostMapping("/logout")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void logout(@RequestHeader(value = "Authorization", required = false) String authHeader) {
if (authHeader == null) {
log.warn("Logout called without Authorization header");
return;
}
String sessionId = extractBearerToken(authHeader);
if (sessionId != null) {
sessionService.invalidateSession(sessionId);
log.debug("Session invalidated via logout");
}
}
/**
* Extracts Bearer token from Authorization header.
*/
private String extractBearerToken(String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return null;
}
}

View File

@@ -0,0 +1,56 @@
package com.honey.honey.controller;
import com.honey.honey.dto.ExternalDepositWebhookRequest;
import com.honey.honey.service.PaymentService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* Controller for 3rd party deposit completion webhook.
* Path: POST /api/deposit_webhook/{token}. Token must match app.deposit-webhook.token (APP_DEPOSIT_WEBHOOK_TOKEN).
* No session auth; token in path only. Set the token on VPS via environment variable.
*/
@Slf4j
@RestController
@RequestMapping("/api/deposit_webhook")
@RequiredArgsConstructor
public class DepositWebhookController {
@Value("${app.deposit-webhook.token:}")
private String expectedToken;
private final PaymentService paymentService;
/**
* Called by 3rd party when a user's crypto deposit was successful.
* Body: user_id (internal id from db_users_a), usd_amount (decimal, e.g. 1.45).
*/
@PostMapping("/{token}")
public ResponseEntity<Void> onDepositCompleted(
@PathVariable String token,
@RequestBody ExternalDepositWebhookRequest request) {
if (expectedToken.isEmpty() || !expectedToken.equals(token)) {
log.warn("Deposit webhook rejected: invalid token");
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (request == null || request.getUserId() == null || request.getUsdAmount() == null) {
return ResponseEntity.badRequest().build();
}
try {
paymentService.processExternalDepositCompletion(
request.getUserId(),
request.getUsdAmount());
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
log.warn("Deposit webhook rejected: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Deposit webhook error: userId={}, usdAmount={}", request.getUserId(), request.getUsdAmount(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

View File

@@ -0,0 +1,56 @@
package com.honey.honey.controller;
import com.honey.honey.dto.NotifyBroadcastRequest;
import com.honey.honey.service.NotificationBroadcastService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* Public API to trigger or stop notification broadcast. Token in path; no secrets in codebase.
* Set APP_NOTIFY_BROADCAST_TOKEN on VPS.
*/
@Slf4j
@RestController
@RequestMapping("/api/notify_broadcast")
@RequiredArgsConstructor
public class NotifyBroadcastController {
@Value("${app.notify-broadcast.token:}")
private String expectedToken;
private final NotificationBroadcastService notificationBroadcastService;
@PostMapping("/{token}")
public ResponseEntity<Void> start(
@PathVariable String token,
@RequestBody(required = false) NotifyBroadcastRequest body) {
if (expectedToken.isEmpty() || !expectedToken.equals(token)) {
log.warn("Notify broadcast rejected: invalid token");
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
NotifyBroadcastRequest req = body != null ? body : new NotifyBroadcastRequest();
notificationBroadcastService.runBroadcast(
req.getMessage(),
req.getImageUrl(),
req.getVideoUrl(),
req.getUserIdFrom(),
req.getUserIdTo(),
req.getButtonText(),
req.getIgnoreBlocked());
return ResponseEntity.accepted().build();
}
@PostMapping("/{token}/stop")
public ResponseEntity<Void> stop(@PathVariable String token) {
if (expectedToken.isEmpty() || !expectedToken.equals(token)) {
log.warn("Notify broadcast stop rejected: invalid token");
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
notificationBroadcastService.requestStop();
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,237 @@
package com.honey.honey.controller;
import com.honey.honey.dto.CryptoWithdrawalResponse;
import com.honey.honey.dto.CreateCryptoWithdrawalRequest;
import com.honey.honey.dto.CreatePaymentRequest;
import com.honey.honey.dto.DepositAddressRequest;
import com.honey.honey.dto.DepositAddressResultDto;
import com.honey.honey.dto.DepositMethodsDto;
import com.honey.honey.dto.ErrorResponse;
import com.honey.honey.dto.PaymentInvoiceResponse;
import com.honey.honey.model.Payout;
import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
import com.honey.honey.dto.WithdrawalMethodDetailsDto;
import com.honey.honey.dto.WithdrawalMethodsDto;
import com.honey.honey.service.CryptoDepositService;
import com.honey.honey.service.CryptoWithdrawalService;
import com.honey.honey.service.FeatureSwitchService;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.service.PaymentService;
import com.honey.honey.service.PayoutService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/payments")
@RequiredArgsConstructor
public class PaymentController {
private final PaymentService paymentService;
private final CryptoDepositService cryptoDepositService;
private final CryptoWithdrawalService cryptoWithdrawalService;
private final PayoutService payoutService;
private final FeatureSwitchService featureSwitchService;
private final LocalizationService localizationService;
/**
* Returns minimum deposit from DB only (no sync). Used by Store screen for validation.
*/
@GetMapping("/minimum-deposit")
public ResponseEntity<?> getMinimumDeposit() {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
return ResponseEntity.ok(java.util.Map.of("minimumDeposit", cryptoDepositService.getMinimumDeposit()));
}
/**
* Returns crypto deposit methods and minimum_deposit from DB only (sync is done every 10 min).
* Called when user opens Payment Options screen.
*/
@GetMapping("/deposit-methods")
public ResponseEntity<?> getDepositMethods() {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
DepositMethodsDto dto = cryptoDepositService.getDepositMethodsFromDb();
return ResponseEntity.ok(dto);
}
/**
* Returns crypto withdrawal methods from DB only (sync is done every 30 min).
* Called when user opens Payout screen.
*/
@GetMapping("/withdrawal-methods")
public ResponseEntity<?> getWithdrawalMethods() {
if (!featureSwitchService.isPayoutEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
}
WithdrawalMethodsDto dto = cryptoWithdrawalService.getWithdrawalMethodsFromDb();
return ResponseEntity.ok(dto);
}
/**
* Returns withdrawal method details (rate_usd, misha_fee_usd) from external API for the given pid.
* Called when user opens Payout Confirmation screen to show network fee and compute "You will receive".
*/
@GetMapping("/withdrawal-method-details")
public ResponseEntity<?> getWithdrawalMethodDetails(@RequestParam("pid") int pid) {
if (!featureSwitchService.isPayoutEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
}
return cryptoWithdrawalService.getWithdrawalMethodDetails(pid)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
/**
* Creates a crypto withdrawal: calls external API, then on success creates payout and deducts balance.
* Uses in-memory lock to prevent double-submit. Validates deposit total and maxWinAfterDeposit.
*/
@PostMapping("/crypto-withdrawal")
public ResponseEntity<?> createCryptoWithdrawal(@RequestBody CreateCryptoWithdrawalRequest request) {
if (!featureSwitchService.isPayoutEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.payoutsUnavailable")));
}
try {
UserA user = UserContext.get();
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Authentication required"));
}
Payout payout = payoutService.createCryptoPayout(user.getId(), request);
return ResponseEntity.ok(CryptoWithdrawalResponse.builder()
.id(payout.getId())
.status(payout.getStatus().name())
.build());
} catch (IllegalArgumentException e) {
log.warn("Crypto withdrawal validation failed: {}", e.getMessage());
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
} catch (IllegalStateException e) {
log.warn("Crypto withdrawal failed: {}", e.getMessage());
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Crypto withdrawal error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Withdrawal failed. Please try again later."));
}
}
/**
* Creates a payment invoice for the current user.
* Returns invoice data that frontend will use to open Telegram payment UI.
*/
@PostMapping("/create")
public ResponseEntity<?> createPaymentInvoice(@RequestBody CreatePaymentRequest request) {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
try {
UserA user = UserContext.get();
PaymentInvoiceResponse response = paymentService.createPaymentInvoice(user.getId(), request);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Payment invoice creation failed: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Payment invoice creation error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Failed to create payment invoice: " + e.getMessage()));
}
}
/**
* Gets a crypto deposit address from the external API (no payment record is created).
* Call when user selects a payment method on Payment Options screen.
* Returns address, amount_coins, name, network for the Payment Confirmation screen.
*/
@PostMapping("/deposit-address")
public ResponseEntity<?> getDepositAddress(@RequestBody DepositAddressRequest request) {
if (!featureSwitchService.isPaymentEnabled()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(localizationService.getMessage("feature.depositsUnavailable")));
}
try {
UserA user = UserContext.get();
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Authentication required"));
}
DepositAddressResultDto result = paymentService.requestCryptoDepositAddress(
user.getId(), request.getPid(), request.getUsdAmount());
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
log.warn("Deposit address request failed: {}", e.getMessage());
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Deposit address error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(e.getMessage() != null ? e.getMessage() : "Failed to get deposit address"));
}
}
/**
* Cancels a payment (e.g., when user cancels in Telegram UI).
*/
@PostMapping("/cancel")
public ResponseEntity<?> cancelPayment(@RequestBody CancelPaymentRequest request) {
try {
String orderId = request.getOrderId();
UserA caller = UserContext.get();
log.info("Payment cancel requested: orderId={}, callerUserId={}", orderId, caller != null ? caller.getId() : null);
paymentService.cancelPayment(orderId);
return ResponseEntity.ok().body(new PaymentWebhookResponse(true, "Payment cancelled"));
} catch (IllegalArgumentException e) {
log.warn("Payment cancellation failed: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
} catch (Exception e) {
log.error("Payment cancellation error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Failed to cancel payment: " + e.getMessage()));
}
}
// Response DTOs
private static class PaymentWebhookResponse {
private final boolean success;
private final String message;
public PaymentWebhookResponse(boolean success, String message) {
this.success = success;
this.message = message;
}
public boolean isSuccess() {
return success;
}
public String getMessage() {
return message;
}
}
private static class CancelPaymentRequest {
private String orderId;
public String getOrderId() {
return orderId;
}
public void setOrderId(String orderId) {
this.orderId = orderId;
}
}
}

View File

@@ -0,0 +1,66 @@
package com.honey.honey.controller;
import com.honey.honey.dto.CreatePayoutRequest;
import com.honey.honey.dto.ErrorResponse;
import com.honey.honey.dto.PayoutHistoryEntryDto;
import com.honey.honey.dto.PayoutResponse;
import com.honey.honey.model.Payout;
import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
import com.honey.honey.service.PayoutService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/payouts")
@RequiredArgsConstructor
public class PayoutController {
private final PayoutService payoutService;
/**
* Creates a payout request for the current user.
* Validates input and deducts balance if validation passes.
*/
@PostMapping
public ResponseEntity<?> createPayout(@RequestBody CreatePayoutRequest request) {
try {
UserA user = UserContext.get();
Payout payout = payoutService.createPayout(user.getId(), request);
PayoutResponse response = payoutService.toResponse(payout);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Payout validation failed: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
} catch (IllegalStateException e) {
log.error("Payout creation failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(e.getMessage()));
}
}
/**
* Gets the last 20 payout history entries for the current user.
*
* @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC.
*/
@GetMapping("/history")
public List<PayoutHistoryEntryDto> getUserPayoutHistory(
@RequestParam(required = false) String timezone) {
UserA user = UserContext.get();
String languageCode = user.getLanguageCode();
if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) {
languageCode = "EN";
}
return payoutService.getUserPayoutHistory(user.getId(), timezone, languageCode);
}
}

View File

@@ -0,0 +1,20 @@
package com.honey.honey.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
public class PingController {
@GetMapping("/ping")
public Map<String, String> ping() {
Map<String, String> response = new HashMap<>();
response.put("status", "ok");
return response;
}
}

View File

@@ -0,0 +1,43 @@
package com.honey.honey.controller;
import com.honey.honey.dto.PromotionDetailDto;
import com.honey.honey.dto.PromotionListItemDto;
import com.honey.honey.service.FeatureSwitchService;
import com.honey.honey.service.PublicPromotionService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* Public API: list and view promotion details (leaderboard, user progress).
* Excludes INACTIVE promotions. Requires Bearer auth (app user).
* When promotions feature switch is false, all endpoints return 404.
*/
@RestController
@RequestMapping("/api/promotions")
@RequiredArgsConstructor
public class PromotionController {
private final PublicPromotionService publicPromotionService;
private final FeatureSwitchService featureSwitchService;
@GetMapping
public ResponseEntity<List<PromotionListItemDto>> list() {
if (!featureSwitchService.isPromotionsEnabled()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(publicPromotionService.listForApp());
}
@GetMapping("/{id}")
public ResponseEntity<PromotionDetailDto> getDetail(@PathVariable int id) {
if (!featureSwitchService.isPromotionsEnabled()) {
return ResponseEntity.notFound().build();
}
return publicPromotionService.getDetailForApp(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,134 @@
package com.honey.honey.controller;
import com.honey.honey.dto.QuickAnswerCreateRequest;
import com.honey.honey.dto.QuickAnswerDto;
import com.honey.honey.model.Admin;
import com.honey.honey.model.QuickAnswer;
import com.honey.honey.repository.AdminRepository;
import com.honey.honey.repository.QuickAnswerRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin/quick-answers")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'TICKETS_SUPPORT', 'GAME_ADMIN')")
public class QuickAnswerController {
private final QuickAnswerRepository quickAnswerRepository;
private final AdminRepository adminRepository;
/**
* Get current admin from authentication context
*/
private Admin getCurrentAdmin() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
return adminRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("Admin not found: " + username));
}
/**
* Get all quick answers for the current admin
*/
@GetMapping
public ResponseEntity<List<QuickAnswerDto>> getQuickAnswers() {
Admin admin = getCurrentAdmin();
List<QuickAnswer> quickAnswers = quickAnswerRepository.findByAdminIdOrderByCreatedAtDesc(admin.getId());
List<QuickAnswerDto> dtos = quickAnswers.stream()
.map(qa -> new QuickAnswerDto(
qa.getId(),
qa.getText(),
qa.getCreatedAt(),
qa.getUpdatedAt()
))
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
/**
* Create a new quick answer for the current admin
*/
@PostMapping
public ResponseEntity<QuickAnswerDto> createQuickAnswer(@Valid @RequestBody QuickAnswerCreateRequest request) {
Admin admin = getCurrentAdmin();
if (request.getText() == null || request.getText().trim().isEmpty()) {
return ResponseEntity.badRequest().build();
}
QuickAnswer quickAnswer = QuickAnswer.builder()
.admin(admin)
.text(request.getText().trim())
.build();
QuickAnswer saved = quickAnswerRepository.save(quickAnswer);
QuickAnswerDto dto = new QuickAnswerDto(
saved.getId(),
saved.getText(),
saved.getCreatedAt(),
saved.getUpdatedAt()
);
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
}
/**
* Update a quick answer (only if it belongs to the current admin)
*/
@PutMapping("/{id}")
public ResponseEntity<QuickAnswerDto> updateQuickAnswer(
@PathVariable Integer id,
@Valid @RequestBody QuickAnswerCreateRequest request) {
Admin admin = getCurrentAdmin();
QuickAnswer quickAnswer = quickAnswerRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Quick answer not found"));
// Verify that the quick answer belongs to the current admin
if (!quickAnswer.getAdmin().getId().equals(admin.getId())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (request.getText() == null || request.getText().trim().isEmpty()) {
return ResponseEntity.badRequest().build();
}
quickAnswer.setText(request.getText().trim());
QuickAnswer saved = quickAnswerRepository.save(quickAnswer);
QuickAnswerDto dto = new QuickAnswerDto(
saved.getId(),
saved.getText(),
saved.getCreatedAt(),
saved.getUpdatedAt()
);
return ResponseEntity.ok(dto);
}
/**
* Delete a quick answer (only if it belongs to the current admin)
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteQuickAnswer(@PathVariable Integer id) {
Admin admin = getCurrentAdmin();
QuickAnswer quickAnswer = quickAnswerRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Quick answer not found"));
// Verify that the quick answer belongs to the current admin
if (!quickAnswer.getAdmin().getId().equals(admin.getId())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
quickAnswerRepository.delete(quickAnswer);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,104 @@
package com.honey.honey.controller;
import com.honey.honey.dto.*;
import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
import com.honey.honey.service.SupportTicketService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/support")
@RequiredArgsConstructor
public class SupportController {
private final SupportTicketService supportTicketService;
/**
* Creates a new support ticket with the first message.
*/
@PostMapping("/tickets")
public ResponseEntity<TicketDto> createTicket(
@Valid @RequestBody CreateTicketRequest request) {
UserA user = UserContext.get();
TicketDto ticket = supportTicketService.createTicket(
user.getId(),
request
);
return ResponseEntity.status(HttpStatus.CREATED).body(ticket);
}
/**
* Gets ticket history for the authenticated user (last 20 tickets).
*/
@GetMapping("/tickets")
public ResponseEntity<List<TicketDto>> getTicketHistory() {
UserA user = UserContext.get();
List<TicketDto> tickets = supportTicketService.getTicketHistory(
user.getId()
);
return ResponseEntity.ok(tickets);
}
/**
* Gets ticket details with all messages.
*/
@GetMapping("/tickets/{ticketId}")
public ResponseEntity<TicketDetailDto> getTicketDetail(
@PathVariable Long ticketId) {
UserA user = UserContext.get();
TicketDetailDto ticket = supportTicketService.getTicketDetail(
user.getId(),
ticketId
);
return ResponseEntity.ok(ticket);
}
/**
* Adds a message to an existing ticket.
*/
@PostMapping("/tickets/{ticketId}/messages")
public ResponseEntity<MessageDto> addMessage(
@PathVariable Long ticketId,
@Valid @RequestBody CreateMessageRequest request) {
UserA user = UserContext.get();
MessageDto message = supportTicketService.addMessage(
user.getId(),
ticketId,
request
);
return ResponseEntity.status(HttpStatus.CREATED).body(message);
}
/**
* Closes a ticket.
*/
@PostMapping("/tickets/{ticketId}/close")
public ResponseEntity<Void> closeTicket(
@PathVariable Long ticketId) {
UserA user = UserContext.get();
supportTicketService.closeTicket(
user.getId(),
ticketId
);
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,100 @@
package com.honey.honey.controller;
import com.honey.honey.dto.ClaimTaskResponse;
import com.honey.honey.dto.DailyBonusStatusDto;
import com.honey.honey.dto.RecentBonusClaimDto;
import com.honey.honey.dto.TaskDto;
import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.service.TaskService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/tasks")
@RequiredArgsConstructor
public class TaskController {
private final TaskService taskService;
private final LocalizationService localizationService;
/**
* Gets all tasks for a specific type (referral, follow, other).
* Includes user progress and claim status.
*/
@GetMapping
public List<TaskDto> getTasks(@RequestParam String type) {
UserA user = UserContext.get();
String languageCode = user.getLanguageCode();
if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) {
languageCode = "EN";
}
return taskService.getTasksByType(user.getId(), type, languageCode);
}
/**
* Gets daily bonus status for the current user.
* Returns availability status and cooldown time if on cooldown.
*/
@GetMapping("/daily-bonus")
public ResponseEntity<DailyBonusStatusDto> getDailyBonusStatus() {
UserA user = UserContext.get();
DailyBonusStatusDto status = taskService.getDailyBonusStatus(user.getId());
return ResponseEntity.ok(status);
}
/**
* Gets the 50 most recent daily bonus claims.
* Returns claims ordered by claimed_at DESC (most recent first).
* Includes user avatar URL, screen name, and formatted claim timestamp with timezone and localized "at" word.
*
* @param timezone Optional timezone (e.g., "Europe/Kiev"). If not provided, uses UTC.
*/
@GetMapping("/daily-bonus/recent-claims")
public ResponseEntity<List<RecentBonusClaimDto>> getRecentDailyBonusClaims(
@RequestParam(required = false) String timezone) {
UserA user = UserContext.get();
String languageCode = user.getLanguageCode();
if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) {
languageCode = "EN";
}
List<RecentBonusClaimDto> claims = taskService.getRecentDailyBonusClaims(timezone, languageCode);
return ResponseEntity.ok(claims);
}
/**
* Claims a task for the current user.
* Checks if task is completed and gives reward if applicable.
* Returns 200 with success status and message.
*/
@PostMapping("/claim")
public ResponseEntity<ClaimTaskResponse> claimTask(@RequestBody ClaimTaskRequest request) {
UserA user = UserContext.get();
boolean claimed = taskService.claimTask(user.getId(), request.getTaskId());
if (claimed) {
return ResponseEntity.ok(ClaimTaskResponse.builder()
.success(true)
.message(localizationService.getMessage("task.message.claimed"))
.build());
} else {
return ResponseEntity.ok(ClaimTaskResponse.builder()
.success(false)
.message(localizationService.getMessage("task.message.notCompleted"))
.build());
}
}
@Data
public static class ClaimTaskRequest {
private Integer taskId;
}
}

View File

@@ -0,0 +1,865 @@
package com.honey.honey.controller;
import com.honey.honey.config.TelegramProperties;
import com.honey.honey.dto.TelegramApiResponse;
import com.honey.honey.dto.PaymentWebhookRequest;
import com.honey.honey.model.UserA;
import com.honey.honey.service.FeatureSwitchService;
import com.honey.honey.service.PaymentService;
import com.honey.honey.service.TelegramBotApiService;
import com.honey.honey.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.HttpClientErrorException;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.api.objects.CallbackQuery;
import org.telegram.telegrambots.meta.api.objects.payments.PreCheckoutQuery;
import org.telegram.telegrambots.meta.api.objects.payments.SuccessfulPayment;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMarkup;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardButton;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardRow;
import org.telegram.telegrambots.meta.api.objects.webapp.WebAppInfo;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.config.LocaleConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import java.util.List;
import java.util.ArrayList;
import java.util.Locale;
import java.util.HashMap;
import java.util.Map;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.util.StreamUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.core.io.ByteArrayResource;
/**
* Webhook controller for receiving Telegram updates directly.
* Path: POST /api/telegram/webhook/{token}. Token must match APP_TELEGRAM_WEBHOOK_TOKEN.
*/
@Slf4j
@RestController
@RequestMapping("/api/telegram/webhook")
@RequiredArgsConstructor
public class TelegramWebhookController {
private static final String MINI_APP_URL = "https://testforapp.website/test/auth";
@Value("${app.telegram-webhook.token:}")
private String expectedWebhookToken;
private final UserService userService;
private final PaymentService paymentService;
private final TelegramProperties telegramProperties;
private final LocalizationService localizationService;
private final TelegramBotApiService telegramBotApiService;
private final FeatureSwitchService featureSwitchService;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Webhook endpoint for receiving updates from Telegram.
* Path token must match app.telegram-webhook.token (APP_TELEGRAM_WEBHOOK_TOKEN).
*/
@PostMapping("/{token}")
public ResponseEntity<?> handleWebhook(@PathVariable String token, @RequestBody Update update, HttpServletRequest httpRequest) {
if (expectedWebhookToken.isEmpty() || !expectedWebhookToken.equals(token)) {
log.warn("Webhook rejected: invalid or missing token (possible misconfiguration or wrong URL); update dropped");
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
try {
// Handle callback queries (button clicks)
if (update.hasCallbackQuery()) {
handleCallbackQuery(update.getCallbackQuery());
}
// Handle message updates (e.g., /start command or Reply Keyboard button clicks)
if (update.hasMessage() && update.getMessage().hasText()) {
handleMessage(update.getMessage(), httpRequest);
}
// Handle pre-checkout query (before payment confirmation)
if (update.hasPreCheckoutQuery()) {
handlePreCheckoutQuery(update.getPreCheckoutQuery());
}
// Handle successful payment
if (update.hasMessage() && update.getMessage().hasSuccessfulPayment()) {
handleSuccessfulPayment(update.getMessage().getSuccessfulPayment(), update.getMessage().getFrom().getId());
}
return ResponseEntity.ok().build();
} catch (Exception e) {
log.error("Error processing Telegram webhook: {}", e.getMessage(), e);
if (update.hasMessage() && update.getMessage().hasText() && update.getMessage().getText().startsWith("/start")) {
Long telegramId = update.getMessage().getFrom() != null ? update.getMessage().getFrom().getId() : null;
log.warn("Registration attempt failed (webhook error), update dropped: telegramId={}", telegramId);
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Handles /start command with optional referral parameter, and Reply Keyboard button clicks.
* Format: /start or /start 123 (where 123 is the referral user ID)
*/
private void handleMessage(Message message, HttpServletRequest httpRequest) {
String messageText = message.getText();
if (messageText == null) {
return;
}
User telegramUser = message.getFrom();
Long telegramId = telegramUser.getId();
Long chatId = message.getChatId();
// Handle /start command
if (messageText.startsWith("/start")) {
handleStartCommand(message, httpRequest, telegramUser, telegramId);
return;
}
// Handle Reply Keyboard button clicks
// Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN
String languageCode = "EN"; // Default
try {
var userOpt = userService.getUserByTelegramId(telegramId);
if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) {
languageCode = userOpt.get().getLanguageCode();
} else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
} catch (Exception e) {
log.warn("Could not get user language, using default: {}", e.getMessage());
// Fallback to Telegram user's language_code if available
if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
}
Locale locale = LocaleConfig.languageCodeToLocale(languageCode);
// Check if message matches Reply Keyboard button text
String startSpinningText = "🎰 " + localizationService.getMessage(locale, "bot.button.startSpinning");
String usersPayoutsText = "💸 " + localizationService.getMessage(locale, "bot.button.usersPayouts");
String infoChannelText = " " + localizationService.getMessage(locale, "bot.button.infoChannel");
if (messageText.equals(startSpinningText)) {
sendStartSpinningMessage(chatId, locale);
} else if (messageText.equals(usersPayoutsText)) {
sendUsersPayoutsMessage(chatId, locale);
} else if (messageText.equals(infoChannelText)) {
sendInfoChannelMessage(chatId, locale);
} else {
// Unknown message (e.g. old "Start Spinning" button or free text): reply and refresh keyboard
sendUnrecognizedMessageAndUpdateKeyboard(chatId, locale);
}
}
/**
* Handles /start command with optional referral parameter.
* Format: /start or /start 123 (where 123 is the referral user ID)
*/
private void handleStartCommand(Message message, HttpServletRequest httpRequest, User telegramUser, Long telegramId) {
String messageText = message.getText();
log.debug("Received /start command: telegramId={}", telegramId);
Integer referralUserId = null;
// Parse referral parameter from /start command
// Format: /start or /start 123
String[] parts = messageText.split("\\s+", 2);
if (parts.length > 1 && !parts[1].trim().isEmpty()) {
try {
referralUserId = Integer.parseInt(parts[1].trim());
log.debug("Parsed referral ID: {}", referralUserId);
} catch (NumberFormatException e) {
log.warn("Invalid referral parameter format: '{}'", parts[1]);
return;
}
}
// Check if user already exists
boolean isNewUser = userService.getUserByTelegramId(telegramId).isEmpty();
if (isNewUser) {
log.info("New user registration via bot - telegramId={}, referralUserId={}",
telegramId, referralUserId);
}
// Build tgUserData map similar to what TelegramAuthService.parseInitData returns
Map<String, Object> tgUser = new HashMap<>();
tgUser.put("id", telegramId);
tgUser.put("first_name", telegramUser.getFirstName());
tgUser.put("last_name", telegramUser.getLastName());
tgUser.put("username", telegramUser.getUserName());
tgUser.put("is_premium", telegramUser.getIsPremium() != null && telegramUser.getIsPremium());
tgUser.put("language_code", telegramUser.getLanguageCode());
// Note: Telegram Bot API User object doesn't have photo_url (only available in WebApp initData)
// AvatarService will fetch avatar from Bot API first, photo_url is only used as fallback
tgUser.put("photo_url", null);
Map<String, Object> tgUserData = new HashMap<>();
tgUserData.put("user", tgUser);
// Convert referralUserId to start parameter string (as expected by UserService)
String start = referralUserId != null ? String.valueOf(referralUserId) : null;
tgUserData.put("start", start);
try {
// Get or create user (handles registration, login update, and referral system)
UserA user = userService.getOrCreateUser(tgUserData, httpRequest);
log.debug("Bot registration completed: userId={}, telegramId={}, isNewUser={}",
user.getId(), user.getTelegramId(), isNewUser);
// Send welcome message with buttons
// Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN
String languageCode = "EN"; // Default
if (user.getLanguageCode() != null && !user.getLanguageCode().isEmpty()) {
languageCode = user.getLanguageCode();
} else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
sendWelcomeMessage(telegramId, languageCode);
} catch (Exception e) {
log.warn("Registration failed for telegramId={}, user may not receive welcome message: {}", telegramId, e.getMessage());
log.error("Error registering user via bot: telegramId={}", telegramId, e);
}
}
/**
* Handles callback queries from inline keyboard buttons.
*/
private void handleCallbackQuery(CallbackQuery callbackQuery) {
String data = callbackQuery.getData();
Long telegramUserId = callbackQuery.getFrom().getId();
Long chatId = callbackQuery.getMessage().getChatId();
log.debug("Received callback query: data={}, telegramUserId={}", data, telegramUserId);
// Get user's language for localization
// Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN
User telegramUser = callbackQuery.getFrom();
String languageCode = "EN"; // Default
try {
var userOpt = userService.getUserByTelegramId(telegramUserId);
if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) {
languageCode = userOpt.get().getLanguageCode();
} else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
} catch (Exception e) {
log.warn("Could not get user language, using default: {}", e.getMessage());
// Fallback to Telegram user's language_code if available
if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
}
Locale locale = LocaleConfig.languageCodeToLocale(languageCode);
try {
switch (data) {
case "start_spinning":
sendStartSpinningMessage(chatId, locale);
answerCallbackQuery(callbackQuery.getId(), null);
break;
case "users_payouts":
sendUsersPayoutsMessage(chatId, locale);
answerCallbackQuery(callbackQuery.getId(), null);
break;
case "info_channel":
sendInfoChannelMessage(chatId, locale);
answerCallbackQuery(callbackQuery.getId(), null);
break;
default:
log.warn("Unknown callback data: {}", data);
answerCallbackQuery(callbackQuery.getId(), "Unknown action");
}
} catch (Exception e) {
log.error("Error handling callback query: data={}", data, e);
answerCallbackQuery(callbackQuery.getId(), "Error processing request");
}
}
/**
* Builds the current Reply Keyboard (Start Game, Users payouts, Info channel) for the given locale.
*/
private ReplyKeyboardMarkup buildReplyKeyboard(Locale locale) {
ReplyKeyboardMarkup replyKeyboard = new ReplyKeyboardMarkup();
replyKeyboard.setResizeKeyboard(true);
replyKeyboard.setOneTimeKeyboard(false);
replyKeyboard.setSelective(false);
List<KeyboardRow> keyboardRows = new ArrayList<>();
KeyboardRow row1 = new KeyboardRow();
KeyboardButton startButton = new KeyboardButton();
startButton.setText("🎰 " + localizationService.getMessage(locale, "bot.button.startSpinning"));
row1.add(startButton);
keyboardRows.add(row1);
KeyboardRow row2 = new KeyboardRow();
KeyboardButton payoutsButton = new KeyboardButton();
payoutsButton.setText("💸 " + localizationService.getMessage(locale, "bot.button.usersPayouts"));
row2.add(payoutsButton);
KeyboardButton infoButton = new KeyboardButton();
infoButton.setText(" " + localizationService.getMessage(locale, "bot.button.infoChannel"));
row2.add(infoButton);
keyboardRows.add(row2);
replyKeyboard.setKeyboard(keyboardRows);
return replyKeyboard;
}
/**
* Sends welcome messages: first message with reply keyboard, second message with inline button.
*/
private void sendWelcomeMessage(Long chatId, String languageCode) {
if (telegramProperties.getBotToken() == null || telegramProperties.getBotToken().isEmpty()) {
log.warn("Bot token not configured; welcome message not sent for chatId={} (registration flow affected)", chatId);
return;
}
Locale locale = LocaleConfig.languageCodeToLocale(languageCode != null ? languageCode : "EN");
ReplyKeyboardMarkup replyKeyboard = buildReplyKeyboard(locale);
String firstMessage = localizationService.getMessage(locale, "bot.welcome.firstMessage");
sendAnimationWithReplyKeyboard(chatId, firstMessage, replyKeyboard);
String welcomeText = localizationService.getMessage(locale, "bot.welcome.message");
if (featureSwitchService.isStartGameButtonEnabled()) {
InlineKeyboardMarkup inlineKeyboard = new InlineKeyboardMarkup();
List<List<InlineKeyboardButton>> inlineRows = new ArrayList<>();
List<InlineKeyboardButton> inlineRow = new ArrayList<>();
InlineKeyboardButton startInlineButton = new InlineKeyboardButton();
String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline");
startInlineButton.setText(startSpinningButtonText);
WebAppInfo webAppInfo = new WebAppInfo();
webAppInfo.setUrl(MINI_APP_URL);
startInlineButton.setWebApp(webAppInfo);
inlineRow.add(startInlineButton);
inlineRows.add(inlineRow);
inlineKeyboard.setKeyboard(inlineRows);
sendMessage(chatId, welcomeText, inlineKeyboard);
} else {
sendMessage(chatId, welcomeText, null);
}
}
/**
* Sends message with Start Spinning button.
*/
private void sendStartSpinningMessage(Long chatId, Locale locale) {
String message = localizationService.getMessage(locale, "bot.message.startSpinning");
if (!featureSwitchService.isStartGameButtonEnabled()) {
sendMessage(chatId, message, null);
return;
}
InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup();
List<List<InlineKeyboardButton>> rows = new ArrayList<>();
List<InlineKeyboardButton> row = new ArrayList<>();
InlineKeyboardButton button = new InlineKeyboardButton();
String startSpinningButtonText = localizationService.getMessage(locale, "bot.button.startSpinningInline");
button.setText(startSpinningButtonText);
WebAppInfo webAppInfo = new WebAppInfo();
webAppInfo.setUrl(MINI_APP_URL);
button.setWebApp(webAppInfo);
row.add(button);
rows.add(row);
keyboard.setKeyboard(rows);
sendMessage(chatId, message, keyboard);
}
/**
* Sends a friendly "unrecognized message" reply and updates the user's reply keyboard to the current one.
* Used when the user sends unknown text (e.g. old "Start Spinning" button) so they get the new keyboard.
*/
private void sendUnrecognizedMessageAndUpdateKeyboard(Long chatId, Locale locale) {
String message = localizationService.getMessage(locale, "bot.message.unrecognized");
ReplyKeyboardMarkup replyKeyboard = buildReplyKeyboard(locale);
sendMessageWithReplyKeyboard(chatId, message, replyKeyboard);
}
/**
* Sends message with Users payouts button.
*/
private void sendUsersPayoutsMessage(Long chatId, Locale locale) {
String message = localizationService.getMessage(locale, "bot.message.usersPayouts");
InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup();
List<List<InlineKeyboardButton>> rows = new ArrayList<>();
List<InlineKeyboardButton> row = new ArrayList<>();
InlineKeyboardButton button = new InlineKeyboardButton();
button.setText(localizationService.getMessage(locale, "bot.button.openChannel"));
button.setUrl("https://t.me/win_spin_withdrawals");
row.add(button);
rows.add(row);
keyboard.setKeyboard(rows);
sendMessage(chatId, message, keyboard);
}
/**
* Handles /paysupport command.
*/
private void handlePaySupportCommand(Long chatId, User telegramUser, Long telegramId) {
// Get user's language for localization
// Priority: 1) saved user language, 2) Telegram user's language_code, 3) default EN
String languageCode = "EN"; // Default
try {
var userOpt = userService.getUserByTelegramId(telegramId);
if (userOpt.isPresent() && userOpt.get().getLanguageCode() != null) {
languageCode = userOpt.get().getLanguageCode();
} else if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
} catch (Exception e) {
log.warn("Could not get user language for /paysupport, using default: {}", e.getMessage());
// Fallback to Telegram user's language_code if available
if (telegramUser.getLanguageCode() != null && !telegramUser.getLanguageCode().isEmpty()) {
languageCode = telegramUser.getLanguageCode();
}
}
Locale locale = LocaleConfig.languageCodeToLocale(languageCode);
String message = localizationService.getMessage(locale, "bot.message.paySupport");
sendMessage(chatId, message, null);
}
/**
* Sends message with Info channel button.
*/
private void sendInfoChannelMessage(Long chatId, Locale locale) {
String message = localizationService.getMessage(locale, "bot.message.infoChannel");
InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup();
List<List<InlineKeyboardButton>> rows = new ArrayList<>();
List<InlineKeyboardButton> row = new ArrayList<>();
InlineKeyboardButton button = new InlineKeyboardButton();
button.setText(localizationService.getMessage(locale, "bot.button.goToChannel"));
button.setUrl("https://t.me/win_spin_news");
row.add(button);
rows.add(row);
keyboard.setKeyboard(rows);
sendMessage(chatId, message, keyboard);
}
/**
* Sends a message to a chat with inline keyboard.
*/
private void sendMessage(Long chatId, String text, InlineKeyboardMarkup keyboard) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/sendMessage";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("chat_id", chatId);
requestBody.put("text", text);
if (keyboard != null) {
// Convert InlineKeyboardMarkup to Map for JSON serialization
try {
String keyboardJson = objectMapper.writeValueAsString(keyboard);
Map<String, Object> keyboardMap = objectMapper.readValue(keyboardJson, Map.class);
requestBody.put("reply_markup", keyboardMap);
} catch (Exception e) {
log.error("Error serializing keyboard: {}", e.getMessage(), e);
return;
}
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null) {
if (!Boolean.TRUE.equals(response.getBody().getOk())) {
log.warn("Failed to send message: chatId={}, error={}",
chatId, response.getBody().getDescription());
}
} else if (response != null && !response.getStatusCode().is2xxSuccessful()) {
log.error("Failed to send message: chatId={}, status={}", chatId, response.getStatusCode());
} else if (response == null) {
log.warn("Message not sent (Telegram 429, retry scheduled): chatId={} may affect registration welcome", chatId);
}
} catch (Exception e) {
if (isTelegramUserUnavailable(e)) {
log.warn("Cannot send message: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Error sending message: chatId={}", chatId, e);
}
}
}
/**
* Returns true if the failure is due to user blocking the bot or chat being unavailable.
* These are expected and should be logged at WARN without stack trace.
*/
private boolean isTelegramUserUnavailable(Throwable t) {
if (t instanceof HttpClientErrorException e) {
if (e.getStatusCode().value() == 403) {
return true;
}
String body = e.getResponseBodyAsString();
return body != null && (
body.contains("blocked by the user") ||
body.contains("user is deactivated") ||
body.contains("chat not found")
);
}
return false;
}
private boolean isTelegramUserUnavailableDescription(String description) {
return description != null && (
description.contains("blocked by the user") ||
description.contains("user is deactivated") ||
description.contains("chat not found")
);
}
/**
* Sends a message with text and reply keyboard.
*/
private void sendMessageWithReplyKeyboard(Long chatId, String text, ReplyKeyboardMarkup replyKeyboard) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/sendMessage";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("chat_id", chatId);
requestBody.put("text", text);
try {
String keyboardJson = objectMapper.writeValueAsString(replyKeyboard);
Map<String, Object> keyboardMap = objectMapper.readValue(keyboardJson, Map.class);
requestBody.put("reply_markup", keyboardMap);
} catch (Exception e) {
log.error("Error serializing reply keyboard: {}", e.getMessage(), e);
return;
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null) {
if (!Boolean.TRUE.equals(response.getBody().getOk())) {
String desc = response.getBody().getDescription();
if (isTelegramUserUnavailableDescription(desc)) {
log.warn("Cannot send message: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Failed to send message with reply keyboard: chatId={}, error={}", chatId, desc);
}
} else {
log.info("Message with reply keyboard sent successfully: chatId={}", chatId);
}
} else if (response != null && !response.getStatusCode().is2xxSuccessful()) {
log.error("Failed to send message with reply keyboard: chatId={}, status={}",
chatId, response.getStatusCode());
}
} catch (Exception e) {
if (isTelegramUserUnavailable(e)) {
log.warn("Cannot send message: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Error sending message with reply keyboard: chatId={}", chatId, e);
}
}
}
/**
* Sends an animation (MP4 video) with caption text and reply keyboard.
* Uses MP4 format as Telegram handles silent MP4s better than GIF files.
*/
private void sendAnimationWithReplyKeyboard(Long chatId, String caption, ReplyKeyboardMarkup replyKeyboard) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/sendAnimation";
try {
// Load MP4 from resources (Telegram "GIFs" are actually silent MP4 videos)
Resource resource = new ClassPathResource("assets/winspin_5.mp4");
if (!resource.exists()) {
log.error("MP4 file not found: assets/winspin_5.mp4");
// Fallback to text message if MP4 not found
sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard);
return;
}
byte[] videoBytes = StreamUtils.copyToByteArray(resource.getInputStream());
ByteArrayResource videoResource = new ByteArrayResource(videoBytes) {
@Override
public String getFilename() {
return "winspin_5.mp4";
}
};
// Create multipart form data
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("chat_id", chatId.toString());
body.add("caption", caption);
// EXPLICITLY SET MIME TYPE FOR THE ANIMATION PART
// This is crucial - Telegram needs to know it's a video/mp4
HttpHeaders fileHeaders = new HttpHeaders();
fileHeaders.setContentType(MediaType.parseMediaType("video/mp4"));
HttpEntity<ByteArrayResource> filePart = new HttpEntity<>(videoResource, fileHeaders);
body.add("animation", filePart);
// Add reply keyboard if provided
if (replyKeyboard != null) {
try {
String keyboardJson = objectMapper.writeValueAsString(replyKeyboard);
body.add("reply_markup", keyboardJson);
} catch (Exception e) {
log.error("Error serializing reply keyboard: {}", e.getMessage(), e);
}
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null) {
if (!Boolean.TRUE.equals(response.getBody().getOk())) {
String desc = response.getBody().getDescription();
if (isTelegramUserUnavailableDescription(desc)) {
log.warn("Cannot send animation: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Failed to send animation with reply keyboard: chatId={}, error={}", chatId, desc);
sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard);
}
} else {
log.info("Animation with reply keyboard sent successfully: chatId={}", chatId);
}
} else if (response != null && !response.getStatusCode().is2xxSuccessful()) {
log.error("Failed to send animation with reply keyboard: chatId={}, status={}",
chatId, response.getStatusCode());
sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard);
} else if (response == null) {
log.warn("Welcome animation delayed (Telegram 429, retry scheduled): chatId={} registration flow may appear incomplete", chatId);
}
} catch (Exception e) {
if (isTelegramUserUnavailable(e)) {
log.warn("Cannot send animation: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Error sending animation with reply keyboard: chatId={}", chatId, e);
sendMessageWithReplyKeyboard(chatId, caption, replyKeyboard);
}
}
}
/**
* Sends a message with only reply keyboard (for setting up persistent keyboard).
*/
private void sendReplyKeyboardOnly(Long chatId, ReplyKeyboardMarkup replyKeyboard) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/sendMessage";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("chat_id", chatId);
// Telegram requires non-empty text for messages with reply keyboard
// Sending with a minimal message - this message won't be visible to users
// but is required to set up the persistent keyboard
requestBody.put("text", ".");
try {
String keyboardJson = objectMapper.writeValueAsString(replyKeyboard);
Map<String, Object> keyboardMap = objectMapper.readValue(keyboardJson, Map.class);
requestBody.put("reply_markup", keyboardMap);
} catch (Exception e) {
log.error("Error serializing reply keyboard: {}", e.getMessage(), e);
return;
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null) {
if (!Boolean.TRUE.equals(response.getBody().getOk())) {
String desc = response.getBody().getDescription();
if (isTelegramUserUnavailableDescription(desc)) {
log.warn("Cannot send reply keyboard: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Failed to send reply keyboard: chatId={}, error={}", chatId, desc);
}
} else {
log.info("Reply keyboard sent successfully: chatId={}", chatId);
}
} else if (response != null && !response.getStatusCode().is2xxSuccessful()) {
log.error("Failed to send reply keyboard: chatId={}, status={}",
chatId, response.getStatusCode());
}
} catch (Exception e) {
if (isTelegramUserUnavailable(e)) {
log.warn("Cannot send reply keyboard: user blocked bot or chat unavailable: chatId={}", chatId);
} else {
log.error("Error sending reply keyboard: chatId={}", chatId, e);
}
}
}
/**
* Answers a callback query.
*/
private void answerCallbackQuery(String queryId, String text) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/answerCallbackQuery";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("callback_query_id", queryId);
if (text != null) {
requestBody.put("text", text);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null && !Boolean.TRUE.equals(response.getBody().getOk())) {
log.warn("Failed to answer callback query: queryId={}, error={}",
queryId, response.getBody().getDescription());
}
} catch (Exception e) {
log.error("Error answering callback query: queryId={}", queryId, e);
}
}
/**
* Handles pre-checkout query (before payment confirmation).
* Telegram sends this to verify the payment before the user confirms.
* We must answer it to approve the payment, otherwise it will expire.
*/
private void handlePreCheckoutQuery(PreCheckoutQuery preCheckoutQuery) {
String queryId = preCheckoutQuery.getId();
String invoicePayload = preCheckoutQuery.getInvoicePayload(); // This is our orderId
log.debug("Pre-checkout query: queryId={}, orderId={}", queryId, invoicePayload);
// Answer the pre-checkout query to approve the payment
// We always approve since we validate on successful payment
answerPreCheckoutQuery(queryId, true, null);
}
/**
* Answers a pre-checkout query to approve or reject the payment.
*
* @param queryId The pre-checkout query ID
* @param ok True to approve, false to reject
* @param errorMessage Error message if rejecting (null if approving)
*/
private void answerPreCheckoutQuery(String queryId, boolean ok, String errorMessage) {
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
return;
}
String url = "https://api.telegram.org/bot" + botToken + "/answerPreCheckoutQuery";
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("pre_checkout_query_id", queryId);
requestBody.put("ok", ok);
if (!ok && errorMessage != null) {
requestBody.put("error_message", errorMessage);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
try {
log.debug("Answering pre-checkout query: queryId={}, ok={}", queryId, ok);
ResponseEntity<TelegramApiResponse> response = telegramBotApiService.post(url, entity);
if (response != null && response.getBody() != null && !Boolean.TRUE.equals(response.getBody().getOk())) {
log.warn("Failed to answer pre-checkout query: queryId={}, error={}",
queryId, response.getBody().getDescription());
} else if (response != null && !response.getStatusCode().is2xxSuccessful()) {
log.error("Failed to answer pre-checkout query: queryId={}, status={}", queryId, response.getStatusCode());
}
} catch (Exception e) {
log.error("Error answering pre-checkout query: queryId={}", queryId, e);
}
}
/**
* Handles successful payment from Telegram.
* Processes the payment and credits the user's balance.
*/
private void handleSuccessfulPayment(SuccessfulPayment successfulPayment, Long telegramUserId) {
String invoicePayload = successfulPayment.getInvoicePayload(); // This is our orderId
// Extract stars amount from total amount
// Telegram sends amount in the smallest currency unit (for Stars, it's 1:1)
Integer starsAmount = successfulPayment.getTotalAmount().intValue();
log.info("Payment webhook received: orderId={}, telegramUserId={}, starsAmount={}", invoicePayload, telegramUserId, starsAmount);
try {
// Create webhook request and process payment
PaymentWebhookRequest request = new PaymentWebhookRequest();
request.setOrderId(invoicePayload);
request.setTelegramUserId(telegramUserId);
request.setTelegramPaymentChargeId(successfulPayment.getTelegramPaymentChargeId());
request.setTelegramProviderPaymentChargeId(successfulPayment.getProviderPaymentChargeId());
request.setStarsAmount(starsAmount);
boolean processed = paymentService.processPaymentWebhook(request);
if (!processed) {
log.warn("Payment already processed: orderId={}", invoicePayload);
}
} catch (Exception e) {
log.error("Error processing payment webhook: orderId={}", invoicePayload, e);
}
}
}

View File

@@ -0,0 +1,50 @@
package com.honey.honey.controller;
import com.honey.honey.dto.TransactionDto;
import com.honey.honey.model.UserA;
import com.honey.honey.security.UserContext;
import com.honey.honey.service.TransactionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* Controller for transaction history operations.
*/
@Slf4j
@RestController
@RequestMapping("/api/transactions")
@RequiredArgsConstructor
public class TransactionController {
private final TransactionService transactionService;
/**
* Gets transaction history for the current user.
* Returns 50 transactions per page, ordered by creation time descending (newest first).
*
* @param page Page number (0-indexed, defaults to 0)
* @param timezone Optional timezone (e.g., "Europe/London"). If not provided, uses UTC.
* @return Page of transactions
*/
@GetMapping
public ResponseEntity<Page<TransactionDto>> getTransactions(
@RequestParam(defaultValue = "0") int page,
@RequestParam(required = false) String timezone) {
UserA user = UserContext.get();
Integer userId = user.getId();
String languageCode = user.getLanguageCode();
if (languageCode == null || languageCode.isEmpty() || "XX".equals(languageCode)) {
languageCode = "EN";
}
// Note: languageCode is still used for date formatting (localized "at" word)
// Transaction type localization is now handled in the frontend
Page<TransactionDto> transactions = transactionService.getUserTransactions(userId, page, timezone, languageCode);
return ResponseEntity.ok(transactions);
}
}

View File

@@ -0,0 +1,101 @@
package com.honey.honey.controller;
import com.honey.honey.dto.UserCheckDto;
import com.honey.honey.model.UserA;
import com.honey.honey.model.UserB;
import com.honey.honey.model.UserD;
import com.honey.honey.repository.PaymentRepository;
import com.honey.honey.repository.UserARepository;
import com.honey.honey.repository.UserBRepository;
import com.honey.honey.repository.UserDRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
/**
* Controller for user check endpoint (open endpoint for external applications).
* Path token is validated against app.check-user.token (APP_CHECK_USER_TOKEN). No user auth.
*/
@Slf4j
@RestController
@RequestMapping("/api/check_user")
@RequiredArgsConstructor
public class UserCheckController {
@Value("${app.check-user.token:}")
private String expectedToken;
private final UserARepository userARepository;
private final UserBRepository userBRepository;
private final UserDRepository userDRepository;
private final PaymentRepository paymentRepository;
/**
* Gets user information by Telegram ID.
* Path: /api/check_user/{token}/{telegramId}. Token must match APP_CHECK_USER_TOKEN.
*
* @param token Secret token from path (must match config)
* @param telegramId The Telegram ID of the user
* @return 200 with user info (found=true) or 200 with found=false when user not found; 403 if token invalid
*/
@GetMapping("/{token}/{telegramId}")
public ResponseEntity<UserCheckDto> checkUser(@PathVariable String token, @PathVariable Long telegramId) {
if (expectedToken.isEmpty() || !expectedToken.equals(token)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
try {
// Find user by telegram_id
Optional<UserA> userAOpt = userARepository.findByTelegramId(telegramId);
if (userAOpt.isEmpty()) {
log.debug("User not found for telegramId={}", telegramId);
UserCheckDto notFoundResponse = UserCheckDto.builder().found(false).build();
return ResponseEntity.ok(notFoundResponse);
}
UserA userA = userAOpt.get();
Integer userId = userA.getId();
// Get balance_a from db_users_b
Optional<UserB> userBOpt = userBRepository.findById(userId);
Long balanceA = userBOpt.map(UserB::getBalanceA).orElse(0L);
// Convert to tickets (balance_a / 1,000,000)
Double tickets = balanceA / 1_000_000.0;
// Get referer_id_1 from db_users_d
Optional<UserD> userDOpt = userDRepository.findById(userId);
Integer refererId = userDOpt.map(UserD::getRefererId1).orElse(0);
// Return 0 if refererId is 0 or negative (not set)
if (refererId <= 0) {
refererId = 0;
}
// Sum completed payments stars_amount
Integer depositTotal = paymentRepository.sumCompletedStarsAmountByUserId(userId);
// Build response
UserCheckDto response = UserCheckDto.builder()
.found(true)
.dateReg(userA.getDateReg())
.tickets(tickets)
.depositTotal(depositTotal)
.refererId(refererId)
.build();
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Error checking user for telegramId={}", telegramId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

View File

@@ -0,0 +1,146 @@
package com.honey.honey.controller;
import com.honey.honey.dto.ReferralDto;
import com.honey.honey.dto.UserDto;
import com.honey.honey.model.UserA;
import com.honey.honey.model.UserB;
import com.honey.honey.repository.UserBRepository;
import com.honey.honey.security.UserContext;
import com.honey.honey.service.AvatarService;
import com.honey.honey.service.FeatureSwitchService;
import com.honey.honey.service.LocalizationService;
import com.honey.honey.service.UserService;
import com.honey.honey.util.IpUtils;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final UserBRepository userBRepository;
private final AvatarService avatarService;
private final LocalizationService localizationService;
private final FeatureSwitchService featureSwitchService;
@GetMapping("/current")
public UserDto getCurrentUser() {
UserA user = UserContext.get();
// Convert IP from byte[] to string for display
String ipAddress = IpUtils.bytesToIp(user.getIp());
// Get balance
Long balanceA = userBRepository.findById(user.getId())
.map(UserB::getBalanceA)
.orElse(0L);
// Generate avatar URL on-the-fly (deterministic from userId)
String avatarUrl = avatarService.getAvatarUrl(user.getId());
return UserDto.builder()
.id(user.getId())
.telegram_id(user.getTelegramId())
.username(user.getTelegramName())
.screenName(user.getScreenName())
.dateReg(user.getDateReg())
.ip(ipAddress)
.balanceA(balanceA)
.avatarUrl(avatarUrl)
.languageCode(user.getLanguageCode())
.paymentEnabled(featureSwitchService.isPaymentEnabled())
.payoutEnabled(featureSwitchService.isPayoutEnabled())
.promotionsEnabled(featureSwitchService.isPromotionsEnabled())
.build();
}
/**
* Updates user's language code.
* Called when user changes language in app header.
*/
@PutMapping("/language")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updateLanguage(@RequestBody UpdateLanguageRequest request) {
UserA user = UserContext.get();
userService.updateLanguageCode(user.getId(), request.getLanguageCode());
}
/**
* Adds deposit amount to user's balance_a.
* For now, this is a mock implementation that directly adds to balance.
* Will be replaced with payment integration later.
*/
@PostMapping("/deposit")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deposit(@RequestBody DepositRequest request) {
UserA user = UserContext.get();
// Frontend sends amount already in bigint format (no conversion needed)
Long depositAmount = request.getAmount();
if (depositAmount == null || depositAmount <= 0) {
throw new IllegalArgumentException(localizationService.getMessage("user.error.depositAmountInvalid"));
}
UserB userB = userBRepository.findById(user.getId())
.orElseThrow(() -> new IllegalStateException(localizationService.getMessage("user.error.balanceNotFound")));
// Add to balance
userB.setBalanceA(userB.getBalanceA() + depositAmount);
// Update deposit statistics
userB.setDepositTotal(userB.getDepositTotal() + depositAmount);
userB.setDepositCount(userB.getDepositCount() + 1);
userBRepository.save(userB);
}
@Data
public static class UpdateLanguageRequest {
private String languageCode;
}
/**
* Gets referrals for a specific level with pagination.
* Always returns 50 results per page.
*
* @param level The referral level (1, 2, or 3)
* @param page Page number (0-indexed, defaults to 0)
* @return Page of referrals with name and commission
*/
@GetMapping("/referrals")
public ReferralsResponse getReferrals(
@RequestParam Integer level,
@RequestParam(defaultValue = "0") Integer page) {
UserA user = UserContext.get();
Page<ReferralDto> referralsPage = userService.getReferrals(user.getId(), level, page);
return new ReferralsResponse(
referralsPage.getContent(),
referralsPage.getNumber(),
referralsPage.getTotalPages(),
referralsPage.getTotalElements()
);
}
@Data
public static class DepositRequest {
private Long amount; // Amount in bigint format (frontend converts before sending)
}
@Data
@RequiredArgsConstructor
public static class ReferralsResponse {
private final java.util.List<ReferralDto> referrals;
private final Integer currentPage;
private final Integer totalPages;
private final Long totalElements;
}
}

View File

@@ -0,0 +1,10 @@
package com.honey.honey.dto;
import lombok.Data;
@Data
public class AdminLoginRequest {
private String username;
private String password;
}

View File

@@ -0,0 +1,13 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class AdminLoginResponse {
private String token;
private String username;
private String role;
}

View File

@@ -0,0 +1,31 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminMasterDto {
private Integer id;
private String screenName;
/** Level 1 referrals count (from master's UserD row). */
private Integer referals1;
/** Level 2 referrals count. */
private Integer referals2;
/** Level 3 referrals count. */
private Integer referals3;
/** Total users with master_id = this master's id. */
private Long totalReferrals;
/** Sum of deposit_total of all referrals, in USD (divided by 1e9). */
private BigDecimal depositTotalUsd;
/** Sum of withdraw_total of all referrals, in USD (divided by 1e9). */
private BigDecimal withdrawTotalUsd;
/** depositTotalUsd - withdrawTotalUsd. */
private BigDecimal profitUsd;
}

View File

@@ -0,0 +1,27 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPaymentDto {
private Long id;
private Integer userId;
private String userName;
private String orderId;
private Integer starsAmount;
private Long ticketsAmount;
private String status;
private String telegramPaymentChargeId;
private String telegramProviderPaymentChargeId;
private Instant createdAt;
private Instant completedAt;
}

View File

@@ -0,0 +1,28 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPayoutDto {
private Long id;
private Integer userId;
private String userName;
private String username;
private String type;
private String giftName;
private Long total;
private Integer starsAmount;
private Integer quantity;
private String status;
private Instant createdAt;
private Instant resolvedAt;
}

View File

@@ -0,0 +1,23 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionDto {
private Integer id;
private String type;
private Instant startTime;
private Instant endTime;
private String status;
private Long totalReward;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,25 @@
package com.honey.honey.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionRequest {
@NotNull
private String type;
@NotNull
private Instant startTime;
@NotNull
private Instant endTime;
@NotNull
private String status;
private Long totalReward;
}

View File

@@ -0,0 +1,22 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionRewardDto {
private Integer id;
private Integer promoId;
private Integer place;
/** Reward in bigint (1 ticket = 1_000_000). */
private Long reward;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,18 @@
package com.honey.honey.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionRewardRequest {
@NotNull
private Integer place;
@NotNull
private Long reward; // bigint, 1 ticket = 1_000_000
}

View File

@@ -0,0 +1,21 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionUserDto {
private Integer promoId;
private Integer userId;
/** Points as ticket count, 2 decimal places (e.g. 100.25). */
private BigDecimal points;
private Instant updatedAt;
}

View File

@@ -0,0 +1,18 @@
package com.honey.honey.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPromotionUserPointsRequest {
@NotNull
private BigDecimal points; // ticket count, 2 decimal places
}

View File

@@ -0,0 +1,22 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminSupportMessageDto {
private Long id;
private Integer userId;
private String userName;
private String message;
private Instant createdAt;
private Boolean isAdmin; // true if sent by admin
}

View File

@@ -0,0 +1,25 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminSupportTicketDetailDto {
private Long id;
private Integer userId;
private String userName;
private String subject;
private String status;
private Instant createdAt;
private Instant updatedAt;
private List<AdminSupportMessageDto> messages;
}

View File

@@ -0,0 +1,26 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminSupportTicketDto {
private Long id;
private Integer userId;
private String userName;
private String subject;
private String status;
private Instant createdAt;
private Instant updatedAt;
private Long messageCount;
private String lastMessagePreview;
private Instant lastMessageAt;
}

View File

@@ -0,0 +1,21 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminTaskClaimDto {
private Long id;
private Integer taskId;
private String taskTitle;
private String taskType;
private Instant claimedAt;
}

View File

@@ -0,0 +1,21 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminTransactionDto {
private Long id;
private Long amount; // In bigint format
private String type;
private Integer taskId;
private Instant createdAt;
}

View File

@@ -0,0 +1,52 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminUserDetailDto {
// Basic Info
private Integer id;
private String screenName;
private Long telegramId;
private String telegramName;
private Integer isPremium;
private String languageCode;
private String countryCode;
private String deviceCode;
private String avatarUrl;
private Integer dateReg;
private Integer dateLogin;
private Integer banned;
/** IP address as string (e.g. xxx.xxx.xxx.xxx), converted from varbinary in DB. */
private String ipAddress;
// Balance Info
private Long balanceA;
private Long depositTotal;
private Integer depositCount;
private Long withdrawTotal;
private Integer withdrawCount;
/** Total deposits in USD (CRYPTO only). */
private java.math.BigDecimal depositTotalUsd;
/** Total withdrawals in USD (CRYPTO only). */
private java.math.BigDecimal withdrawTotalUsd;
/** When true, the user cannot create any payout request. */
private Boolean withdrawalsDisabled;
// Referral Info
private Integer referralCount;
private Long totalCommissionsEarned;
/** Total commissions earned in USD (converted from tickets). */
private java.math.BigDecimal totalCommissionsEarnedUsd;
private Integer masterId;
private List<ReferralLevelDto> referralLevels;
}

View File

@@ -0,0 +1,41 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminUserDto {
private Integer id;
private String screenName;
private Long telegramId;
private String telegramName;
private Long balanceA;
private Long balanceB;
private Long depositTotal;
private Integer depositCount;
private Long withdrawTotal;
private Integer withdrawCount;
private Integer dateReg;
private Integer dateLogin;
private Integer banned;
private String countryCode;
private String languageCode;
private Integer referralCount; // Total referrals across all levels
private Long totalCommissionsEarned; // Total commissions earned from referrals
/** Profit in tickets (bigint): depositTotal - withdrawTotal */
private Long profit;
/** USD from db_users_b: depositTotal (tickets/1000) */
private BigDecimal depositTotalUsd;
/** USD from db_users_b: withdrawTotal (tickets/1000) */
private BigDecimal withdrawTotalUsd;
/** USD from db_users_b: profit (tickets/1000) */
private BigDecimal profitUsd;
}

View File

@@ -0,0 +1,36 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BalanceAdjustmentRequest {
@NotNull(message = "Balance type is required")
private BalanceType balanceType; // A or B
@NotNull(message = "Amount is required")
private Long amount; // In bigint format (tickets * 1,000,000)
@NotNull(message = "Operation is required")
private OperationType operation; // ADD or SUBTRACT
@NotBlank(message = "Reason is required")
private String reason; // Reason for adjustment (for audit log)
public enum BalanceType {
A, B
}
public enum OperationType {
ADD, SUBTRACT
}
}

View File

@@ -0,0 +1,20 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BalanceAdjustmentResponse {
private Long newBalanceA;
private Long newBalanceB;
private Long previousBalanceA;
private Long previousBalanceB;
private Long adjustmentAmount;
private String message;
}

View File

@@ -0,0 +1,19 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BalanceUpdateDto {
private Long balanceA; // Balance in bigint format (database format)
}

View File

@@ -0,0 +1,37 @@
package com.honey.honey.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BotRegisterRequest {
@JsonProperty("telegram_id")
private Long telegramId;
@JsonProperty("first_name")
private String firstName;
@JsonProperty("last_name")
private String lastName;
private String username;
@JsonProperty("is_premium")
private Boolean isPremium;
@JsonProperty("language_code")
private String languageCode;
@JsonProperty("photo_url")
private String photoUrl;
@JsonProperty("referral_user_id")
private Integer referralUserId;
}

View File

@@ -0,0 +1,25 @@
package com.honey.honey.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BotRegisterResponse {
@JsonProperty("user_id")
private Integer userId;
@JsonProperty("is_new_user")
private Boolean isNewUser;
private String message;
}

View File

@@ -0,0 +1,19 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ClaimTaskResponse {
private boolean success;
private String message;
}

View File

@@ -0,0 +1,23 @@
package com.honey.honey.dto;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* Request body for POST /api/payments/crypto-withdrawal.
*/
@Data
public class CreateCryptoWithdrawalRequest {
@NotNull(message = "pid is required")
private Integer pid;
@NotBlank(message = "wallet is required")
private String wallet;
/** Tickets amount in bigint format (tickets * 1_000_000). */
@NotNull(message = "total is required")
private Long total;
}

Some files were not shown because too many files have changed in this diff Show More