Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,17 @@ dependencies {

implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.postgresql:postgresql:42.7.3")

implementation("com.maxmind.geoip2:geoip2:4.2.0")
implementation("com.opencsv:opencsv:5.8")

// Utilities
implementation("org.apache.commons:commons-compress:1.24.0")
implementation("org.apache.commons:commons-lang3:3.13.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// Caching
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("com.github.ben-manes.caffeine:caffeine")
Expand All @@ -43,14 +52,16 @@ dependencies {
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")



// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.projectreactor:reactor-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

configurations.all {
exclude(group = "commons-logging", module = "commons-logging")
}

tasks.withType<Test> {
useJUnitPlatform()
}
2 changes: 2 additions & 0 deletions src/main/java/io/paradaux/api/ParadauxApiApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
@EnableCaching
@EnableAsync
public class ParadauxApiApplication {

public static void main(String[] args) {
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/io/paradaux/api/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.paradaux.api.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;

@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
return template;
}
}
9 changes: 6 additions & 3 deletions src/main/java/io/paradaux/api/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
package io.paradaux.api.config;

import io.paradaux.api.interceptors.ProtectedRouteInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import io.paradaux.api.interceptors.RateLimitingInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

@Autowired
private ProtectedRouteInterceptor protectedRouteInterceptor;
private final ProtectedRouteInterceptor protectedRouteInterceptor;
private final RateLimitingInterceptor rateLimitingInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(protectedRouteInterceptor);
registry.addInterceptor(rateLimitingInterceptor).addPathPatterns("/**");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import io.paradaux.api.models.ContactFormRequest;
import io.paradaux.api.services.ContactService;
import org.springframework.beans.factory.annotation.Autowired;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand All @@ -12,10 +12,10 @@

@RestController
@RequestMapping("/api/contact")
@RequiredArgsConstructor
public class ContactController {

@Autowired
private ContactService contactService;
private final ContactService contactService;

@PostMapping
public Mono<ResponseEntity<String>> handleContactForm(@RequestBody ContactFormRequest request) {
Expand Down
42 changes: 42 additions & 0 deletions src/main/java/io/paradaux/api/controllers/GeoIPController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.paradaux.api.controllers;

import io.paradaux.api.jobs.MaxMindSyncJob;
import io.paradaux.api.models.annotations.ProtectedRoute;
import io.paradaux.api.services.GeoIPInformationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;

import static io.paradaux.api.utils.FileUtils.extractZip;

@RestController
@RequestMapping("/api/geoip")
@Validated
@Slf4j
@RequiredArgsConstructor
public class GeoIPController {

private final GeoIPInformationService geoIPInformationService;
private final MaxMindSyncJob maxMindSyncJob;

@GetMapping("/lookup/{ipAddress}")
public Map<String, Object> lookup(@PathVariable String ipAddress) {
return geoIPInformationService.lookupIP(ipAddress);
}

@PostMapping("/sync")
@ProtectedRoute
public ResponseEntity<String> syncGeoIPData() {
maxMindSyncJob.runSync();
return ResponseEntity.accepted().body("MaxMind sync started");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,56 @@
import io.paradaux.api.models.annotations.ProtectedRoute;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

@Component
public class ProtectedRouteInterceptor implements HandlerInterceptor {

private final ConcurrentMap<HandlerMethod, Boolean> protectedRouteCache = new ConcurrentHashMap<>();

@Value("${api.secret}")
private String secret;

/**
* Intercepts requests to check if they are protected routes.
* If the route is protected, it checks for a valid secret token in the request header "X-SECRET".
* If the secret is invalid or missing, it responds with HTTP 401 Unauthorized.
*/
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod method = (HandlerMethod) handler;

boolean methodProtected = method.hasMethodAnnotation(ProtectedRoute.class);
boolean classProtected = method.getBeanType().isAnnotationPresent(ProtectedRoute.class);

if (methodProtected || classProtected) {
String secretHeader = request.getHeader("X-SECRET");
if (secretHeader == null || !secretHeader.equals(secret)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Unauthorized: invalid or missing secret");
return false;
}
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) throws Exception {
if (handler instanceof HandlerMethod method) {
if (!isProtectedRoute(method)) {
return true;
}
if (!hasValidSecret(request)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Unauthorized: invalid or missing secret");
return false;
}
}
return true;
}

/**
* A route is protected if the method or its class is annotated with @ProtectedRoute.
* This requires a secret token in the request header "X-SECRET".
*/
private boolean isProtectedRoute(HandlerMethod method) {
return protectedRouteCache.computeIfAbsent(method, hm -> hm.hasMethodAnnotation(ProtectedRoute.class) || hm.getBeanType().isAnnotationPresent(ProtectedRoute.class));
}

/**
* Checks if the request has a valid secret token.
*/
public boolean hasValidSecret(HttpServletRequest request) {
String secretHeader = request.getHeader("X-SECRET");
return secretHeader != null && secretHeader.equals(secret);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.paradaux.api.interceptors;

import io.paradaux.api.services.RateLimitingService;
import io.paradaux.api.utils.IPUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class RateLimitingInterceptor implements HandlerInterceptor {

private final RateLimitingService rateLimiterService;
private final ProtectedRouteInterceptor protectedRouteInterceptor;

@Override
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) throws IOException {
String ip = IPUtils.getClientIp(request);

// Allow requests that contain a valid secret token
if (protectedRouteInterceptor.hasValidSecret(request)) {
return true;
}

if (!rateLimiterService.isAllowed(ip)) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("Rate limit exceeded");
return false;
}

return true;
}
}
43 changes: 43 additions & 0 deletions src/main/java/io/paradaux/api/jobs/MaxMindSyncJob.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.paradaux.api.jobs;

import io.paradaux.api.services.DiscordService;
import io.paradaux.api.services.GeoIPInformationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.HashMap;

@Service
@Slf4j
@RequiredArgsConstructor
public class MaxMindSyncJob {

private final GeoIPInformationService geoIPInformationService;
private final DiscordService discordService;

@Scheduled(cron = "0 0 3 * * WED")
public void refreshMaxMindDbScheduled() {
runSync();
}

@Async
public void runSync() {
log.info("Starting MaxMind GeoIP data synchronization...");
discordService.sendMessage("Starting MaxMind GeoIP data synchronization...", "", new HashMap<>());

try {
geoIPInformationService.importAllData();
} catch (IOException e) {
log.error("Failed to import MaxMind GeoIP data", e);
discordService.sendMessage("MaxMind GeoIP data synchronization failure", e.getMessage(), new HashMap<>());
return;
}

discordService.sendMessage("MaxMind GeoIP data synchronization completed successfully", "", new HashMap<>());
log.info("MaxMind GeoIP data synchronization completed successfully.");
}
}
8 changes: 4 additions & 4 deletions src/main/java/io/paradaux/api/jobs/VisitCacheRefreshJob.java
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
package io.paradaux.api.jobs;

import io.paradaux.api.mappers.VisitsMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

@Service
@Slf4j
@RequiredArgsConstructor
public class VisitCacheRefreshJob {

private final VisitsMapper visitsMapper;

public VisitCacheRefreshJob(VisitsMapper visitsMapper) {
this.visitsMapper = visitsMapper;
}

@Scheduled(fixedRate = 5 * 60 * 1000) // 5 mins
public void refreshMaterializedView() {
log.info("Starting visit cache refresh...");
visitsMapper.refreshVisitCache();
log.info("Visit cache refreshed successfully.");

}
}
24 changes: 24 additions & 0 deletions src/main/java/io/paradaux/api/mappers/GeoIPMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.paradaux.api.mappers;

import io.paradaux.api.models.geoip.ASN;
import io.paradaux.api.models.geoip.ASNBlock;
import io.paradaux.api.models.geoip.CityBlock;
import io.paradaux.api.models.geoip.IPLocation;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;

@Mapper
public interface GeoIPMapper {
void insertLocations(List<IPLocation> locations);
void insertASNs(List<ASN> asns);
void insertCityBlocks(List<CityBlock> cityBlocks);
void insertASNBlocks(List<ASNBlock> asnBlocks);

Map<String, Object> getIPInfo(String ipAddress);
CityBlock getCityBlockByIP(String ipAddress);
Map<String, Object> getASNByIP(String ipAddress);
IPLocation getLocationById(Integer geonameId);

void truncateAll();
}
Loading