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

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;