Created base BE. Auth + /current endpoint

This commit is contained in:
AddictionGames
2026-01-03 15:34:33 +02:00
parent bff056e094
commit 27a261eb44
30 changed files with 1998 additions and 22 deletions

60
.gitignore vendored
View File

@@ -1,24 +1,40 @@
# Compiled class file target/
*.class !.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
# Log file ### 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 *.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# ====== 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
# Expose port (for local/docker-compose/documentation)
EXPOSE 8080
ENV JAVA_OPTS=""
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

27
Dockerfile.inferno Normal file
View File

@@ -0,0 +1,27 @@
# ====== 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
# Expose port (for internal communication with nginx)
EXPOSE 8080
ENV JAVA_OPTS=""
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

542
README.md Normal file
View File

@@ -0,0 +1,542 @@
# Honey Backend
Spring Boot backend application for Honey 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=honey_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://honey-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 `honey-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., `honey-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/honey
cd /opt/honey
```
#### Step 2: Clone Repository
```bash
cd /opt/honey
git clone https://github.com/your-username/honey-be.git
cd honey-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/honey-config.properties
```
Add the following content (replace with your actual values):
```properties
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/honey_db
SPRING_DATASOURCE_USERNAME=honey_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/honey/honey-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/honey.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/honey.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/honey.service
```
Add:
```ini
[Unit]
Description=Honey Application
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/honey/honey-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 honey.service
sudo systemctl start honey.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: honey-backend
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: [__meta_docker_container_name]
regex: honey-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/honey/honey-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 honey_user -p honey_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/honey-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
```
honey-be/
├── src/
│ ├── main/
│ │ ├── java/com/honey/honey/
│ │ │ ├── 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]

235
TELEGRAM_SETUP.md Normal file
View File

@@ -0,0 +1,235 @@
# Telegram Bot and Mini App Setup Guide
This guide explains how to set up a Telegram bot and mini app for the Honey project.
## Step 1: Create a Telegram Bot
1. **Open Telegram** and search for **@BotFather**
2. **Start a conversation** with BotFather:
```
/start
```
3. **Create a new bot**:
```
/newbot
```
4. **Follow the prompts**:
- Enter a name for your bot (e.g., "Honey Bot")
- Enter a username for your bot (must end with `bot`, e.g., "honey_bot")
5. **Save the Bot Token**:
- BotFather will provide you with a token like: `1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`
- **IMPORTANT**: Keep this token secret! It's used to authenticate your bot
- Add this token to your backend environment variables as `TELEGRAM_BOT_TOKEN`
## Step 2: Configure Bot Settings
1. **Set bot description** (optional):
```
/setdescription
```
Select your bot and enter a description.
2. **Set bot commands** (optional):
```
/setcommands
```
Select your bot and enter commands (e.g., `/start - Start the bot`)
3. **Set bot photo** (optional):
```
/setuserpic
```
Select your bot and upload a photo.
## Step 3: Create a Mini App
1. **Open BotFather** and select your bot
2. **Create a new mini app**:
```
/newapp
```
3. **Follow the prompts**:
- Select your bot
- Enter a title for your mini app (e.g., "Honey")
- Enter a short name (e.g., "honey")
- Enter a description
- Upload an icon (512x512 PNG, max 100KB)
- Upload a photo (640x360 JPG/PNG, max 5MB) - optional
- Upload a GIF (640x360, max 1MB) - optional
4. **Set the Mini App URL**:
- BotFather will ask for the Web App URL
- For **Railway deployment**: Use your Railway frontend URL
```
https://your-frontend.railway.app
```
- For **Inferno deployment**: Use your domain
```
https://your-domain.com
```
- For **local development**: Use a tunnel service like ngrok
```
https://your-ngrok-url.ngrok.io
```
5. **Save the Mini App Link**:
- BotFather will provide you with a link like: `https://t.me/your_bot/honey`
- This is the link users will use to open your mini app
## Step 4: Configure Web App Settings
### For Railway Deployment
1. **Get your frontend URL** from Railway:
- Go to your frontend service in Railway
- Go to **"Settings"** → **"Networking"**
- Copy the generated domain (e.g., `https://honey-fe-production.up.railway.app`)
2. **Update BotFather** with this URL:
```
/newapp
```
Select your bot and update the Web App URL
### For Inferno Deployment
1. **Get your domain** (e.g., `https://honey.yourdomain.com`)
2. **Update BotFather** with this URL:
```
/newapp
```
Select your bot and update the Web App URL
### For Local Development
1. **Use ngrok** to create a tunnel:
```bash
ngrok http 5173
```
2. **Copy the HTTPS URL** (e.g., `https://abc123.ngrok.io`)
3. **Update BotFather** with this URL:
```
/newapp
```
Select your bot and update the Web App URL
4. **Update your frontend** `.env` file:
```env
VITE_API_BASE_URL=https://your-backend-url.railway.app
```
## Step 5: Test the Mini App
1. **Open Telegram** and search for your bot (e.g., `@honey_bot`)
2. **Start the bot**:
```
/start
```
3. **Open the mini app**:
- Click the button in the bot chat (if you added one)
- Or use the link: `https://t.me/your_bot/honey`
- Or use the menu button (if configured)
4. **Verify authentication**:
- The mini app should load your frontend
- The frontend should successfully authenticate with the backend
- Check browser console for any errors
## Step 6: Configure Bot Menu (Optional)
You can add a menu button to your bot that opens the mini app:
1. **Open BotFather**:
```
/mybots
```
2. **Select your bot**:
```
Bot Settings → Menu Button
```
3. **Configure menu button**:
- Select "Configure Menu Button"
- Enter button text (e.g., "Open Honey")
- Enter the mini app URL or select from your apps
## Step 7: Security Considerations
1. **Keep Bot Token Secret**:
- Never commit the bot token to version control
- Use environment variables or secret files (as configured in the backend)
2. **Validate initData**:
- The backend automatically validates Telegram's `initData` signature
- This ensures requests are coming from legitimate Telegram users
3. **HTTPS Required**:
- Telegram mini apps require HTTPS
- Use Railway's automatic HTTPS or configure SSL on Inferno
## Step 8: Troubleshooting
### Mini App Not Loading
- **Check URL**: Verify the Web App URL in BotFather matches your frontend URL
- **Check HTTPS**: Ensure your frontend is served over HTTPS
- **Check CORS**: Verify backend CORS configuration includes Telegram domains
- **Check Console**: Open browser developer tools and check for errors
### Authentication Failing
- **Check Bot Token**: Verify `TELEGRAM_BOT_TOKEN` is correct in backend environment
- **Check initData**: Verify frontend is sending `Authorization: tma <initData>` header
- **Check Backend Logs**: Review backend logs for authentication errors
### Bot Not Responding
- **Check Bot Status**: Verify bot is active in BotFather
- **Check Commands**: Verify bot commands are set correctly
- **Check Token**: Verify bot token is correct
## Additional Resources
- [Telegram Bot API Documentation](https://core.telegram.org/bots/api)
- [Telegram Mini Apps Documentation](https://core.telegram.org/bots/webapps)
- [BotFather Commands](https://core.telegram.org/bots/tools#botfather)
## Quick Reference
### BotFather Commands
```
/newbot - Create a new bot
/setdescription - Set bot description
/setcommands - Set bot commands
/newapp - Create/update mini app
/mybots - Manage your bots
```
### Environment Variables
```env
TELEGRAM_BOT_TOKEN=your_bot_token_here
FRONTEND_URL=https://your-frontend-url.com
```
### Testing Locally
1. Start backend: `mvn spring-boot:run` or `docker-compose up`
2. Start frontend: `npm run dev`
3. Use ngrok: `ngrok http 5173`
4. Update BotFather with ngrok URL
5. Test mini app in Telegram

View File

@@ -0,0 +1,68 @@
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}
volumes:
# Mount secret file from tmpfs
- /run/secrets:/run/secrets:ro
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

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
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:

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

@@ -0,0 +1,76 @@
upstream honey_backend {
server app:8080;
}
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
# # ...
# }

34
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,34 @@
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;
}

88
pom.xml Normal file
View File

@@ -0,0 +1,88 @@
<?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>
</dependencies>
<build>
<plugins>
<!-- Spring Boot Plugin -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,18 @@
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;
@SpringBootApplication
@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,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,37 @@
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;
@Configuration
public class CorsConfig {
@Value("${FRONTEND_URL:https://example.com}")
private String frontendUrl;
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins(
frontendUrl,
"https://web.telegram.org",
"https://webk.telegram.org",
"https://t.me",
"https://*.t.me"
)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
};
}
}

View File

@@ -0,0 +1,13 @@
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;
}

View File

@@ -0,0 +1,20 @@
package com.honey.honey.config;
import com.honey.honey.security.AuthInterceptor;
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;
@Override
public void addInterceptors(org.springframework.web.servlet.config.annotation.InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.excludePathPatterns("/ping", "/actuator/**"); // ping and actuator don't require auth
}
}

View File

@@ -0,0 +1,19 @@
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,28 @@
package com.honey.honey.controller;
import com.honey.honey.dto.UserDto;
import com.honey.honey.model.User;
import com.honey.honey.security.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
@GetMapping("/current")
public UserDto getCurrentUser() {
User user = UserContext.get();
return UserDto.builder()
.telegram_id(user.getTelegramId())
.username(user.getUsername())
.build();
}
}

View File

@@ -0,0 +1,16 @@
package com.honey.honey.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDto {
private Long telegram_id;
private String username;
}

View File

@@ -0,0 +1,14 @@
package com.honey.honey.exception;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
private String code;
private String message;
}

View File

@@ -0,0 +1,27 @@
package com.honey.honey.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<ErrorResponse> handleUnauthorized(UnauthorizedException ex) {
log.warn("Unauthorized: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("UNAUTHORIZED", ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
}

View File

@@ -0,0 +1,8 @@
package com.honey.honey.exception;
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,39 @@
package com.honey.honey.health;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
@Slf4j
@Component
@RequiredArgsConstructor
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
@Override
public Health health() {
try (Connection connection = dataSource.getConnection()) {
if (connection.isValid(1)) {
return Health.up()
.withDetail("database", "MySQL")
.withDetail("status", "Connected")
.build();
}
} catch (SQLException e) {
log.error("Database health check failed", e);
return Health.down()
.withDetail("database", "MySQL")
.withDetail("error", e.getMessage())
.build();
}
return Health.down().withDetail("database", "MySQL").build();
}
}

View File

@@ -0,0 +1,55 @@
package com.honey.honey.logging;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
/**
* Configuration for Grafana integration.
* This class prepares the logging infrastructure for Grafana.
*
* In production (Inferno), logs will be sent to Grafana via:
* - Loki (log aggregation)
* - Prometheus (metrics)
*
* For now, this is a placeholder that ensures structured logging
* is ready for Grafana integration.
*/
@Slf4j
@Configuration
public class GrafanaLoggingConfig {
@PostConstruct
public void init() {
log.info("📊 Grafana logging configuration initialized");
log.info("📊 Logs are structured and ready for Grafana/Loki integration");
log.info("📊 Metrics will be available for Prometheus when configured");
}
/**
* Log structured data for Grafana.
* This method can be used to send custom logs to Grafana/Loki.
*
* @param level Log level (INFO, WARN, ERROR, etc.)
* @param message Log message
* @param metadata Additional metadata as key-value pairs
*/
public static void logToGrafana(String level, String message, java.util.Map<String, Object> metadata) {
// For now, just use standard logging
// In production, this will send logs to Grafana/Loki
switch (level.toUpperCase()) {
case "ERROR":
log.error("{} | Metadata: {}", message, metadata);
break;
case "WARN":
log.warn("{} | Metadata: {}", message, metadata);
break;
case "INFO":
default:
log.info("{} | Metadata: {}", message, metadata);
break;
}
}
}

View File

@@ -0,0 +1,37 @@
package com.honey.honey.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "telegram_id", unique = true, nullable = false)
private Long telegramId;
@Column(name = "username")
private String username;
@Column(name = "created_at")
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,15 @@
package com.honey.honey.repository;
import com.honey.honey.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findAllByTelegramId(Long telegramId);
Optional<User> findByTelegramId(Long telegramId);
}

View File

@@ -0,0 +1,118 @@
package com.honey.honey.security;
import com.honey.honey.model.User;
import com.honey.honey.repository.UserRepository;
import com.honey.honey.service.TelegramAuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private final TelegramAuthService telegramAuthService;
private final UserRepository userRepository;
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
// Allow CORS preflight (OPTIONS) without auth
if ("OPTIONS".equalsIgnoreCase(req.getMethod())) {
return true;
}
// Get initData from Authorization header (format: "tma <initData>")
String authHeader = req.getHeader("Authorization");
String initData = null;
if (authHeader != null && authHeader.startsWith("tma ")) {
initData = authHeader.substring(4); // Remove "tma " prefix
}
// If no initData, fail
if (initData == null || initData.isBlank()) {
log.error("❌ Missing Telegram initData in Authorization header (expected format: 'tma <initData>')");
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
// Validate Telegram digital signature
Map<String, Object> tgUser = telegramAuthService.validateAndParseInitData(initData);
Long telegramId = ((Number) tgUser.get("id")).longValue();
String username = (String) tgUser.get("username");
// Get or create user (with duplicate handling)
User user = getOrCreateUser(telegramId, username);
// Put user in context
UserContext.set(user);
log.debug("🔑 Authenticated userId={}, telegramId={}", user.getId(), telegramId);
return true;
}
/**
* Gets existing user or creates a new one. Handles race conditions and duplicate users gracefully.
*/
private synchronized User getOrCreateUser(Long telegramId, String username) {
// Try to find existing user(s)
List<User> existingUsers = userRepository.findAllByTelegramId(telegramId);
if (!existingUsers.isEmpty()) {
User user = existingUsers.get(0);
// If multiple users exist, log a warning
if (existingUsers.size() > 1) {
log.warn("⚠️ Found {} duplicate users for telegramId={}. Using the first one (id={}). " +
"Consider cleaning up duplicates.", existingUsers.size(), telegramId, user.getId());
}
// Update username if it changed
if (username != null && !username.equals(user.getUsername())) {
user.setUsername(username);
userRepository.save(user);
}
return user;
}
// No user found, create new one
try {
log.info("🆕 Creating new user for telegramId={}, username={}", telegramId, username);
return userRepository.save(
User.builder()
.telegramId(telegramId)
.username(username)
.createdAt(LocalDateTime.now())
.build()
);
} catch (DataIntegrityViolationException e) {
// Another thread created the user, fetch it
log.debug("User already exists (created by another thread), fetching...");
List<User> users = userRepository.findAllByTelegramId(telegramId);
if (users.isEmpty()) {
log.error("Failed to create user and couldn't find it after duplicate key error");
throw new RuntimeException("Failed to get or create user", e);
}
return users.get(0);
}
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) {
UserContext.clear();
}
}

View File

@@ -0,0 +1,21 @@
package com.honey.honey.security;
import com.honey.honey.model.User;
public class UserContext {
private static final ThreadLocal<User> current = new ThreadLocal<>();
public static void set(User user) {
current.set(user);
}
public static User get() {
return current.get();
}
public static void clear() {
current.remove();
}
}

View File

@@ -0,0 +1,179 @@
package com.honey.honey.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.honey.honey.config.TelegramProperties;
import com.honey.honey.exception.UnauthorizedException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import static java.nio.charset.StandardCharsets.UTF_8;
@Slf4j
@Service
@RequiredArgsConstructor
public class TelegramAuthService {
private static final String HMAC_SHA256_ALGORITHM = "HmacSHA256";
private static final String WEB_APP_DATA_CONSTANT = "WebAppData";
private final TelegramProperties telegramProperties;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Validates and parses Telegram initData string.
*/
public Map<String, Object> validateAndParseInitData(String initData) {
if (initData == null || initData.isBlank()) {
throw new UnauthorizedException("Telegram initData is missing");
}
try {
// Step 1. Parse query string into key/value pairs.
Map<String, String> parsedData = parseQueryString(initData);
String receivedHash = parsedData.remove("hash");
if (receivedHash == null) {
throw new UnauthorizedException("Missing Telegram hash");
}
// Step 2. Build data check string.
String dataCheckString = createDataCheckString(parsedData);
// Step 3. Derive secret key based on Telegram WebApp rules.
byte[] secretKey = deriveSecretKey(telegramProperties.getBotToken());
// Step 4. Calculate our own hash and compare.
String calculatedHash = calculateHmacSha256(dataCheckString, secretKey);
if (!receivedHash.equals(calculatedHash)) {
log.warn("Telegram signature mismatch. Expected={}, Received={}", calculatedHash, receivedHash);
throw new UnauthorizedException("Invalid Telegram signature");
}
// Step 5. Extract the user JSON from initData.
Map<String, String> decoded = decodeQueryParams(initData);
String userJson = decoded.get("user");
if (userJson == null) {
throw new UnauthorizedException("initData does not contain 'user' field");
}
// Step 6. Parse JSON into map.
return objectMapper.readValue(userJson, Map.class);
} catch (UnauthorizedException ex) {
throw ex;
} catch (Exception ex) {
log.error("Telegram initData validation failed: {}", ex.getMessage(), ex);
throw new UnauthorizedException("Invalid Telegram initData");
}
}
// -------------------------------------------
// Internal helper methods
// -------------------------------------------
private Map<String, String> parseQueryString(String queryString) {
Map<String, String> params = new HashMap<>();
try {
String[] pairs = queryString.split("&");
for (String pair : pairs) {
int idx = pair.indexOf("=");
if (idx <= 0) continue;
String key = URLDecoder.decode(pair.substring(0, idx), UTF_8);
String value = URLDecoder.decode(pair.substring(idx + 1), UTF_8);
params.put(key, value);
}
} catch (Exception ex) {
log.warn("Failed to parse initData query string: {}", ex.getMessage());
}
return params;
}
private String createDataCheckString(Map<String, String> data) {
List<String> sortedKeys = new ArrayList<>(data.keySet());
Collections.sort(sortedKeys);
StringBuilder sb = new StringBuilder();
for (String key : sortedKeys) {
if (sb.length() > 0) sb.append("\n");
sb.append(key).append("=").append(data.get(key));
}
return sb.toString();
}
private byte[] deriveSecretKey(String botToken) throws NoSuchAlgorithmException, InvalidKeyException {
Mac hmacSha256 = Mac.getInstance(HMAC_SHA256_ALGORITHM);
// Telegram requires using "WebAppData" as the key for deriving secret
SecretKeySpec secretKeySpec =
new SecretKeySpec(WEB_APP_DATA_CONSTANT.getBytes(StandardCharsets.UTF_8), HMAC_SHA256_ALGORITHM);
hmacSha256.init(secretKeySpec);
return hmacSha256.doFinal(botToken.getBytes(StandardCharsets.UTF_8));
}
private String calculateHmacSha256(String data, byte[] key)
throws NoSuchAlgorithmException, InvalidKeyException {
Mac hmacSha256 = Mac.getInstance(HMAC_SHA256_ALGORITHM);
SecretKeySpec secretKeySpec = new SecretKeySpec(key, HMAC_SHA256_ALGORITHM);
hmacSha256.init(secretKeySpec);
byte[] hashBytes = hmacSha256.doFinal(data.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hashBytes);
}
private String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
}
private Map<String, String> decodeQueryParams(String qs) {
Map<String, String> map = new HashMap<>();
for (String part : qs.split("&")) {
int idx = part.indexOf('=');
if (idx > 0) {
String key = URLDecoder.decode(part.substring(0, idx), UTF_8);
String val = URLDecoder.decode(part.substring(idx + 1), UTF_8);
map.put(key, val);
}
}
return map;
}
}

View File

@@ -0,0 +1,68 @@
server:
port: 8080
spring:
application:
name: honey-be
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/honey_db}
username: ${SPRING_DATASOURCE_USERNAME:root}
password: ${SPRING_DATASOURCE_PASSWORD:password}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
connection-timeout: 30000
initialization-fail-timeout: -1
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
format_sql: false
dialect: org.hibernate.dialect.MySQLDialect
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
connect-retries: 20
connect-retry-interval: 3000
validate-on-migrate: false
repair: true
telegram:
bot-token: ${TELEGRAM_BOT_TOKEN}
logging:
level:
root: INFO
org.springframework.boot.context.config: DEBUG
org.springframework.core.env: DEBUG
com.honey: DEBUG
management:
endpoints:
web:
exposure:
include: health,info
base-path: /actuator
endpoint:
health:
show-details: when-authorized
probes:
enabled: true
group:
readiness:
include: db,ping
liveness:
include: ping
health:
db:
enabled: true
diskspace:
enabled: false
ping:
enabled: true

View File

@@ -0,0 +1,9 @@
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
telegram_id BIGINT NOT NULL UNIQUE,
username VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_telegram_id (telegram_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;