diff --git a/.gitignore b/.gitignore index 524f096..2549690 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,40 @@ -# Compiled class file -*.class +target/ +!.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 - -# 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* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2f5a65d --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/Dockerfile.inferno b/Dockerfile.inferno new file mode 100644 index 0000000..92d9e19 --- /dev/null +++ b/Dockerfile.inferno @@ -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"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..53b3f2d --- /dev/null +++ b/README.md @@ -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 ` 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 ` 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] + diff --git a/TELEGRAM_SETUP.md b/TELEGRAM_SETUP.md new file mode 100644 index 0000000..69a5f49 --- /dev/null +++ b/TELEGRAM_SETUP.md @@ -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 ` 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 + diff --git a/docker-compose.inferno.yml b/docker-compose.inferno.yml new file mode 100644 index 0000000..8df3090 --- /dev/null +++ b/docker-compose.inferno.yml @@ -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 + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cf46bb4 --- /dev/null +++ b/docker-compose.yml @@ -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: + diff --git a/nginx/conf.d/honey.conf b/nginx/conf.d/honey.conf new file mode 100644 index 0000000..8a7d92e --- /dev/null +++ b/nginx/conf.d/honey.conf @@ -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 +# # ... +# } + diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..63d9fda --- /dev/null +++ b/nginx/nginx.conf @@ -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; +} + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..590b802 --- /dev/null +++ b/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + com.honey + honey-be + 1.0.0 + jar + + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + 17 + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + diff --git a/src/main/java/com/honey/honey/HoneyBackendApplication.java b/src/main/java/com/honey/honey/HoneyBackendApplication.java new file mode 100644 index 0000000..bc42733 --- /dev/null +++ b/src/main/java/com/honey/honey/HoneyBackendApplication.java @@ -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); + } +} + diff --git a/src/main/java/com/honey/honey/config/ConfigLoader.java b/src/main/java/com/honey/honey/config/ConfigLoader.java new file mode 100644 index 0000000..76b6bbb --- /dev/null +++ b/src/main/java/com/honey/honey/config/ConfigLoader.java @@ -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 { + + 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 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; + } +} + diff --git a/src/main/java/com/honey/honey/config/CorsConfig.java b/src/main/java/com/honey/honey/config/CorsConfig.java new file mode 100644 index 0000000..1d0990e --- /dev/null +++ b/src/main/java/com/honey/honey/config/CorsConfig.java @@ -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); + } + }; + } +} + diff --git a/src/main/java/com/honey/honey/config/TelegramProperties.java b/src/main/java/com/honey/honey/config/TelegramProperties.java new file mode 100644 index 0000000..b206aab --- /dev/null +++ b/src/main/java/com/honey/honey/config/TelegramProperties.java @@ -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; +} + diff --git a/src/main/java/com/honey/honey/config/WebConfig.java b/src/main/java/com/honey/honey/config/WebConfig.java new file mode 100644 index 0000000..8852ae9 --- /dev/null +++ b/src/main/java/com/honey/honey/config/WebConfig.java @@ -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 + } +} + diff --git a/src/main/java/com/honey/honey/controller/PingController.java b/src/main/java/com/honey/honey/controller/PingController.java new file mode 100644 index 0000000..7094247 --- /dev/null +++ b/src/main/java/com/honey/honey/controller/PingController.java @@ -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 ping() { + Map response = new HashMap<>(); + response.put("status", "ok"); + return response; + } +} + diff --git a/src/main/java/com/honey/honey/controller/UserController.java b/src/main/java/com/honey/honey/controller/UserController.java new file mode 100644 index 0000000..fac05fa --- /dev/null +++ b/src/main/java/com/honey/honey/controller/UserController.java @@ -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(); + } +} + diff --git a/src/main/java/com/honey/honey/dto/UserDto.java b/src/main/java/com/honey/honey/dto/UserDto.java new file mode 100644 index 0000000..28c30f9 --- /dev/null +++ b/src/main/java/com/honey/honey/dto/UserDto.java @@ -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; +} + diff --git a/src/main/java/com/honey/honey/exception/ErrorResponse.java b/src/main/java/com/honey/honey/exception/ErrorResponse.java new file mode 100644 index 0000000..b0441ee --- /dev/null +++ b/src/main/java/com/honey/honey/exception/ErrorResponse.java @@ -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; +} + diff --git a/src/main/java/com/honey/honey/exception/GlobalExceptionHandler.java b/src/main/java/com/honey/honey/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4ca1f24 --- /dev/null +++ b/src/main/java/com/honey/honey/exception/GlobalExceptionHandler.java @@ -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 handleUnauthorized(UnauthorizedException ex) { + log.warn("Unauthorized: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("UNAUTHORIZED", ex.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneric(Exception ex) { + log.error("Unexpected error", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred")); + } +} + diff --git a/src/main/java/com/honey/honey/exception/UnauthorizedException.java b/src/main/java/com/honey/honey/exception/UnauthorizedException.java new file mode 100644 index 0000000..1b2346c --- /dev/null +++ b/src/main/java/com/honey/honey/exception/UnauthorizedException.java @@ -0,0 +1,8 @@ +package com.honey.honey.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { + super(message); + } +} + diff --git a/src/main/java/com/honey/honey/health/DatabaseHealthIndicator.java b/src/main/java/com/honey/honey/health/DatabaseHealthIndicator.java new file mode 100644 index 0000000..f4def52 --- /dev/null +++ b/src/main/java/com/honey/honey/health/DatabaseHealthIndicator.java @@ -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(); + } +} + diff --git a/src/main/java/com/honey/honey/logging/GrafanaLoggingConfig.java b/src/main/java/com/honey/honey/logging/GrafanaLoggingConfig.java new file mode 100644 index 0000000..85b21c0 --- /dev/null +++ b/src/main/java/com/honey/honey/logging/GrafanaLoggingConfig.java @@ -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 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; + } + } +} + diff --git a/src/main/java/com/honey/honey/model/User.java b/src/main/java/com/honey/honey/model/User.java new file mode 100644 index 0000000..a75a6c9 --- /dev/null +++ b/src/main/java/com/honey/honey/model/User.java @@ -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(); + } + } +} + diff --git a/src/main/java/com/honey/honey/repository/UserRepository.java b/src/main/java/com/honey/honey/repository/UserRepository.java new file mode 100644 index 0000000..c6df6e4 --- /dev/null +++ b/src/main/java/com/honey/honey/repository/UserRepository.java @@ -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 { + List findAllByTelegramId(Long telegramId); + Optional findByTelegramId(Long telegramId); +} + diff --git a/src/main/java/com/honey/honey/security/AuthInterceptor.java b/src/main/java/com/honey/honey/security/AuthInterceptor.java new file mode 100644 index 0000000..3e820c2 --- /dev/null +++ b/src/main/java/com/honey/honey/security/AuthInterceptor.java @@ -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 ") + 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 ')"); + res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + // Validate Telegram digital signature + Map 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 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 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(); + } +} + diff --git a/src/main/java/com/honey/honey/security/UserContext.java b/src/main/java/com/honey/honey/security/UserContext.java new file mode 100644 index 0000000..33f6002 --- /dev/null +++ b/src/main/java/com/honey/honey/security/UserContext.java @@ -0,0 +1,21 @@ +package com.honey.honey.security; + +import com.honey.honey.model.User; + +public class UserContext { + + private static final ThreadLocal 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(); + } +} + diff --git a/src/main/java/com/honey/honey/service/TelegramAuthService.java b/src/main/java/com/honey/honey/service/TelegramAuthService.java new file mode 100644 index 0000000..cb1426c --- /dev/null +++ b/src/main/java/com/honey/honey/service/TelegramAuthService.java @@ -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 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 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 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 parseQueryString(String queryString) { + Map 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 data) { + List 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 decodeQueryParams(String qs) { + Map 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; + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..434eba0 --- /dev/null +++ b/src/main/resources/application.yml @@ -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 + diff --git a/src/main/resources/db/migration/V1__create_users_table.sql b/src/main/resources/db/migration/V1__create_users_table.sql new file mode 100644 index 0000000..7389d6c --- /dev/null +++ b/src/main/resources/db/migration/V1__create_users_table.sql @@ -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; +