├── src ├── main │ ├── java │ │ └── com │ │ │ └── ghostchu │ │ │ └── btn │ │ │ └── sparkle │ │ │ ├── config │ │ │ ├── SpringWebSocketConfigurator.java │ │ │ ├── TableConfig.java │ │ │ ├── WebSocketConfig.java │ │ │ ├── SaTokenConfigure.java │ │ │ ├── UtilConfig.java │ │ │ ├── InetAddressDeserializer.java │ │ │ ├── SparkleRedisCacheConfig.java │ │ │ ├── SparkleRedisCacheManager.java │ │ │ ├── SparkleTomcatWebServerFactoryCustomizer.java │ │ │ ├── SpringWebSocketServerEndpointConfigurator.java │ │ │ ├── JacksonConfig.java │ │ │ └── SpringRedisConfig.java │ │ │ ├── module │ │ │ ├── ping │ │ │ │ ├── SpringConfigurator.java │ │ │ │ ├── ability │ │ │ │ │ ├── PingAbility.java │ │ │ │ │ ├── AbstractPingAbility.java │ │ │ │ │ ├── AbstractCronJobAbility.java │ │ │ │ │ ├── AbstractCronJobEndpointAbility.java │ │ │ │ │ └── impl │ │ │ │ │ │ ├── ReconfigureAbility.java │ │ │ │ │ │ ├── SubmitBansAbility.java │ │ │ │ │ │ ├── CloudRuleAbility.java │ │ │ │ │ │ ├── SubmitPeersAbility.java │ │ │ │ │ │ └── SubmitHistoriesAbility.java │ │ │ │ ├── dto │ │ │ │ │ ├── BtnPeerHistoryPing.java │ │ │ │ │ ├── BtnBanPing.java │ │ │ │ │ ├── BtnPeerPing.java │ │ │ │ │ ├── BtnBan.java │ │ │ │ │ ├── BtnPeerHistory.java │ │ │ │ │ ├── BtnPeer.java │ │ │ │ │ └── BtnRule.java │ │ │ │ ├── ClientAuthenticationCredential.java │ │ │ │ ├── PingWebSocketManager.java │ │ │ │ └── PingWebSocketSession.java │ │ │ ├── banhistory │ │ │ │ ├── internal │ │ │ │ │ ├── UntrustIpAddressProjection.java │ │ │ │ │ ├── BanHistoryRepository.java │ │ │ │ │ └── BanHistory.java │ │ │ │ ├── BanHistoryDto.java │ │ │ │ └── BanHistoryService.java │ │ │ ├── userscore │ │ │ │ ├── internal │ │ │ │ │ ├── UserScoreHistoryRepository.java │ │ │ │ │ ├── UserScoreRepository.java │ │ │ │ │ ├── UserScore.java │ │ │ │ │ └── UserScoreHistory.java │ │ │ │ └── UserScoreService.java │ │ │ ├── torrent │ │ │ │ ├── TorrentDto.java │ │ │ │ ├── internal │ │ │ │ │ ├── TorrentRepository.java │ │ │ │ │ └── Torrent.java │ │ │ │ ├── TorrentController.java │ │ │ │ └── TorrentService.java │ │ │ ├── audit │ │ │ │ ├── impl │ │ │ │ │ ├── AuditRepository.java │ │ │ │ │ └── Audit.java │ │ │ │ └── AuditService.java │ │ │ ├── rule │ │ │ │ ├── RuleDto.java │ │ │ │ ├── internal │ │ │ │ │ ├── RuleRepository.java │ │ │ │ │ └── Rule.java │ │ │ │ └── RuleService.java │ │ │ ├── tracker │ │ │ │ └── internal │ │ │ │ │ ├── PeerEvent.java │ │ │ │ │ └── TrackedPeer.java │ │ │ ├── clientdiscovery │ │ │ │ ├── ClientDiscoveryDto.java │ │ │ │ ├── internal │ │ │ │ │ ├── ClientDiscovery.java │ │ │ │ │ └── ClientDiscoveryRepository.java │ │ │ │ ├── ClientIdentity.java │ │ │ │ ├── ClientDiscoveryService.java │ │ │ │ └── ClientDiscoveryController.java │ │ │ ├── analyse │ │ │ │ ├── proto │ │ │ │ │ └── peer.proto │ │ │ │ └── impl │ │ │ │ │ ├── AnalysedRuleRepository.java │ │ │ │ │ └── AnalysedRule.java │ │ │ ├── userapp │ │ │ │ ├── UserApplicationVerboseDto.java │ │ │ │ ├── UserApplicationDto.java │ │ │ │ ├── internal │ │ │ │ │ ├── UserApplicationRepository.java │ │ │ │ │ └── UserApplication.java │ │ │ │ └── UserApplicationViewController.java │ │ │ ├── user │ │ │ │ ├── UserDto.java │ │ │ │ ├── internal │ │ │ │ │ ├── UserRepository.java │ │ │ │ │ └── User.java │ │ │ │ ├── UserViewController.java │ │ │ │ ├── UserController.java │ │ │ │ └── UserService.java │ │ │ ├── snapshot │ │ │ │ ├── internal │ │ │ │ │ ├── SnapshotRepository.java │ │ │ │ │ └── Snapshot.java │ │ │ │ ├── SnapshotDto.java │ │ │ │ └── SnapshotService.java │ │ │ ├── peerhistory │ │ │ │ ├── internal │ │ │ │ │ ├── PeerHistoryRepository.java │ │ │ │ │ └── PeerHistory.java │ │ │ │ ├── SnapshotHistoryDto.java │ │ │ │ └── PeerHistoryService.java │ │ │ ├── repository │ │ │ │ ├── SparkleCommonRepository.java │ │ │ │ └── PartitionAware.java │ │ │ ├── cleanup │ │ │ │ └── CleanupService.java │ │ │ └── IndexController.java │ │ │ ├── exception │ │ │ ├── UserBannedException.java │ │ │ ├── UserNotFoundException.java │ │ │ ├── UserApplicationNotFoundException.java │ │ │ ├── RequestPageSizeTooLargeException.java │ │ │ ├── TooManyUserApplicationException.java │ │ │ ├── AccessDeniedException.java │ │ │ └── BusinessException.java │ │ │ ├── util │ │ │ ├── BencodeUtil.java │ │ │ ├── TimeUtil.java │ │ │ ├── compare │ │ │ │ ├── CriteriaBuilderSupport.java │ │ │ │ ├── NumberCompareMethod.java │ │ │ │ └── StringCompareMethod.java │ │ │ ├── ipdb │ │ │ │ ├── GeoIPManager.java │ │ │ │ └── IPGeoData.java │ │ │ ├── WarningSender.java │ │ │ ├── InfoHashUtil.java │ │ │ ├── PeerUtil.java │ │ │ ├── paging │ │ │ │ └── SparklePage.java │ │ │ ├── MsgUtil.java │ │ │ ├── ByteUtil.java │ │ │ ├── IPUtil.java │ │ │ ├── IPMerger.java │ │ │ └── ServletUtil.java │ │ │ ├── wrapper │ │ │ ├── StructuredData.java │ │ │ └── StdResp.java │ │ │ ├── SparkleApplication.java │ │ │ ├── controller │ │ │ ├── SparkleController.java │ │ │ ├── MVCAdviceHandler.java │ │ │ └── GlobalExceptionHandler.java │ │ │ └── filter │ │ │ └── GzipBodyDecompressFilter.java │ └── resources │ │ ├── static │ │ └── favicon.ico │ │ ├── templates │ │ ├── oauth │ │ │ └── req_github_failed.html │ │ ├── modules │ │ │ └── userapp │ │ │ │ ├── create.html │ │ │ │ ├── created.html │ │ │ │ └── index.html │ │ ├── index.html │ │ ├── components │ │ │ └── common.html │ │ └── user │ │ │ └── profile.html │ │ └── application.properties └── test │ └── java │ └── com │ └── ghostchu │ └── btn │ └── sparkle │ └── SparkleApplicationTests.java ├── tools └── arthas-boot.jar ├── compose.yaml ├── Dockerfile ├── .github └── workflows │ ├── build_maven.yml │ ├── jvm-ci.yml │ └── jvm-release.yml ├── .gitignore ├── README.md ├── .mvn └── wrapper │ └── maven-wrapper.properties └── HELP.md /src/main/java/com/ghostchu/btn/sparkle/config/SpringWebSocketConfigurator.java: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/SpringConfigurator.java: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tools/arthas-boot.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PBH-BTN/Sparkle/HEAD/tools/arthas-boot.jar -------------------------------------------------------------------------------- /src/main/resources/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PBH-BTN/Sparkle/HEAD/src/main/resources/static/favicon.ico -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/ability/PingAbility.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.ability; 2 | 3 | public interface PingAbility { 4 | } 5 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/config/TableConfig.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.config; 2 | 3 | public class TableConfig { 4 | //alter table "public"."tracker_peers" set unlogged 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/banhistory/internal/UntrustIpAddressProjection.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.banhistory.internal; 2 | 3 | import java.net.InetAddress; 4 | 5 | public interface UntrustIpAddressProjection { 6 | InetAddress getPeerIp(); 7 | 8 | Integer getCount(); 9 | } 10 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: 'postgres:latest' 4 | environment: 5 | - 'POSTGRES_DB=sparkle' 6 | - 'POSTGRES_PASSWORD=sparkle' 7 | - 'POSTGRES_USER=sparkle' 8 | ports: 9 | - '5432:5432' 10 | redis: 11 | image: 'redis:latest' 12 | ports: 13 | - '6379:6379' 14 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/ability/AbstractPingAbility.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.ability; 2 | 3 | import lombok.Data; 4 | import lombok.EqualsAndHashCode; 5 | 6 | @EqualsAndHashCode(callSuper = false) 7 | @Data 8 | public class AbstractPingAbility implements PingAbility { 9 | } 10 | -------------------------------------------------------------------------------- /src/test/java/com/ghostchu/btn/sparkle/SparkleApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest 7 | class SparkleApplicationTests { 8 | 9 | @Test 10 | void contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/exception/UserBannedException.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | 5 | public class UserBannedException extends BusinessException { 6 | 7 | public UserBannedException() { 8 | super(HttpStatus.FORBIDDEN, "User is banned"); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/exception/UserNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | 5 | public class UserNotFoundException extends BusinessException { 6 | 7 | public UserNotFoundException() { 8 | super(HttpStatus.NOT_FOUND, "Unable to find requested user"); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/exception/UserApplicationNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | 5 | public class UserApplicationNotFoundException extends BusinessException { 6 | public UserApplicationNotFoundException() { 7 | super(HttpStatus.NOT_FOUND, "请求的用户应用程序不存在"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/exception/RequestPageSizeTooLargeException.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | 5 | public class RequestPageSizeTooLargeException extends BusinessException{ 6 | public RequestPageSizeTooLargeException() { 7 | super(HttpStatus.BAD_REQUEST, "请求的分页数量参数超过最大允许值"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/exception/TooManyUserApplicationException.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | 5 | public class TooManyUserApplicationException extends BusinessException{ 6 | 7 | public TooManyUserApplicationException() { 8 | super(HttpStatus.INSUFFICIENT_STORAGE, "您所拥有的用户应用程序数量已达上限"); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/BencodeUtil.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util; 2 | 3 | import com.dampcake.bencode.Bencode; 4 | 5 | import java.nio.charset.StandardCharsets; 6 | 7 | public class BencodeUtil { 8 | public static final Bencode INSTANCE = new Bencode(StandardCharsets.ISO_8859_1); 9 | private static final Bencode UTF8 = new Bencode(StandardCharsets.UTF_8); 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/TimeUtil.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util; 2 | 3 | import java.time.Instant; 4 | import java.time.OffsetDateTime; 5 | import java.time.ZoneOffset; 6 | 7 | public class TimeUtil { 8 | public static OffsetDateTime toUTC(long ts) { 9 | var instant = Instant.ofEpochMilli(ts); 10 | return OffsetDateTime.ofInstant(instant, ZoneOffset.UTC); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/userscore/internal/UserScoreHistoryRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.userscore.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import org.springframework.stereotype.Repository; 5 | 6 | @Repository 7 | public interface UserScoreHistoryRepository extends SparkleCommonRepository { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/compare/CriteriaBuilderSupport.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util.compare; 2 | 3 | import jakarta.persistence.criteria.CriteriaBuilder; 4 | import jakarta.persistence.criteria.Expression; 5 | import jakarta.persistence.criteria.Predicate; 6 | 7 | public interface CriteriaBuilderSupport { 8 | 9 | Predicate criteriaBuilder (CriteriaBuilder criteriaBuilder, Expression expression, T value); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/exception/AccessDeniedException.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | 5 | public class AccessDeniedException extends BusinessException{ 6 | public AccessDeniedException(String message) { 7 | super(HttpStatus.FORBIDDEN, "拒绝访问: " + message); 8 | } 9 | public AccessDeniedException() { 10 | super(HttpStatus.FORBIDDEN, "拒绝访问"); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/wrapper/StructuredData.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.wrapper; 2 | 3 | import java.util.LinkedHashMap; 4 | 5 | public final class StructuredData extends LinkedHashMap { 6 | public static StructuredData create(){ 7 | return new StructuredData<>(); 8 | } 9 | 10 | public StructuredData add(K key,V value){ 11 | put(key, value); 12 | return this; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/wrapper/StdResp.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.wrapper; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import java.io.Serializable; 9 | 10 | @AllArgsConstructor 11 | @NoArgsConstructor 12 | @Data 13 | @Builder 14 | public class StdResp implements Serializable { 15 | private boolean success; 16 | private String message; 17 | private T data; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/userscore/internal/UserScoreRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.userscore.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import com.ghostchu.btn.sparkle.module.user.internal.User; 5 | import org.springframework.stereotype.Repository; 6 | 7 | @Repository 8 | public interface UserScoreRepository extends SparkleCommonRepository { 9 | UserScore findByUser(User user); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/config/WebSocketConfig.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.socket.server.standard.ServerEndpointExporter; 6 | 7 | @Configuration 8 | public class WebSocketConfig { 9 | @Bean 10 | public ServerEndpointExporter serverEndpointExporter() { 11 | return new ServerEndpointExporter(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/ipdb/GeoIPManager.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util.ipdb; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | import java.io.IOException; 6 | import java.net.InetAddress; 7 | 8 | @Component 9 | public class GeoIPManager { 10 | private IPDB ipdb; 11 | 12 | public GeoIPManager() throws IOException { 13 | this.ipdb = new IPDB(true, "Sparkle/1.0"); 14 | } 15 | 16 | public IPGeoData geoData(InetAddress inet) { 17 | return this.ipdb.query(inet); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/torrent/TorrentDto.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.torrent; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import java.io.Serializable; 9 | 10 | 11 | @Data 12 | @AllArgsConstructor 13 | @NoArgsConstructor 14 | @Builder 15 | public class TorrentDto implements Serializable { 16 | private Long id; 17 | private String identifier; 18 | private Long size; 19 | private Boolean privateTorrent; 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/exception/BusinessException.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.exception; 2 | 3 | import lombok.Data; 4 | import lombok.EqualsAndHashCode; 5 | import org.springframework.http.HttpStatusCode; 6 | 7 | @EqualsAndHashCode(callSuper = true) 8 | @Data 9 | public class BusinessException extends Exception { 10 | private final HttpStatusCode statusCode; 11 | 12 | public BusinessException(HttpStatusCode statusCode, String message) { 13 | super(message); 14 | this.statusCode = statusCode; 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/audit/impl/AuditRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.audit.impl; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import org.springframework.stereotype.Repository; 5 | 6 | import java.time.OffsetDateTime; 7 | 8 | @Repository 9 | public interface AuditRepository extends SparkleCommonRepository { 10 | 11 | long deleteByTimestampBefore(OffsetDateTime offsetDateTime); 12 | 13 | long deleteAllByTimestampBefore(OffsetDateTime offsetDateTime); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/torrent/internal/TorrentRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.torrent.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import org.springframework.lang.NonNull; 5 | 6 | import java.util.Optional; 7 | 8 | public interface TorrentRepository extends SparkleCommonRepository { 9 | Optional findByIdentifierAndSize(@NonNull String identifier, @NonNull Long size); 10 | 11 | Optional findByIdentifier(@NonNull String identifier); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/dto/BtnPeerHistoryPing.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import java.util.List; 9 | 10 | @Data 11 | @AllArgsConstructor 12 | @NoArgsConstructor 13 | public class BtnPeerHistoryPing { 14 | @JsonProperty("populate_time") 15 | private long populateTime; 16 | @JsonProperty("peers") 17 | private List peers; 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/bellsoft/liberica-runtime-container:jdk-all-25-musl 2 | LABEL maintainer="https://github.com/PBH-BTN/Sparkle" 3 | USER 0 4 | ENV TZ=UTC 5 | WORKDIR /app 6 | COPY tools/arthas-boot.jar /app/arthas-boot.jar 7 | COPY target/sparkle-0.0.1-SNAPSHOT.jar /app/sparkle.jar 8 | 9 | ENTRYPOINT ["java","-XX:+UseCompactObjectHeaders", "-XX:+UseZGC", "-XX:SoftMaxHeapSize=386M", "-XX:MaxRAMPercentage=86.0", "-XX:ZUncommitDelay=1","-XX:+UseStringDeduplication", "-XX:-ShrinkHeapInSteps", "-XX:+UseContainerSupport", "-Djava.security.egd=file:/dev/./urandom", "-jar","sparkle.jar"] 10 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/config/SaTokenConfigure.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 5 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 6 | 7 | @Configuration 8 | public class SaTokenConfigure implements WebMvcConfigurer { 9 | // 注册拦截器 10 | @Override 11 | public void addInterceptors(InterceptorRegistry registry) { 12 | // 注册 Sa-Token 拦截器,校验规则为 StpUtil.checkLogin() 登录校验。 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/rule/RuleDto.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.rule; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import java.io.Serializable; 9 | 10 | @AllArgsConstructor 11 | @NoArgsConstructor 12 | @Builder 13 | @Data 14 | public class RuleDto implements Serializable { 15 | private Long id; 16 | private String category; 17 | private String content; 18 | private String type; 19 | private Long createdAt; 20 | private Long expiredAt; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/tracker/internal/PeerEvent.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.tracker.internal; 2 | 3 | import java.io.Serializable; 4 | 5 | public enum PeerEvent implements Serializable { 6 | STARTED, 7 | COMPLETED, 8 | STOPPED, 9 | EMPTY; 10 | 11 | public static PeerEvent fromString(String event) { 12 | return switch (event) { 13 | case "started" -> STARTED; 14 | case "completed" -> COMPLETED; 15 | case "stopped" -> STOPPED; 16 | default -> EMPTY; 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/tracker/internal/TrackedPeer.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.tracker.internal; 2 | 3 | import lombok.*; 4 | 5 | @AllArgsConstructor 6 | @NoArgsConstructor 7 | @Getter 8 | @Setter 9 | @Data 10 | public class TrackedPeer { 11 | private String peerId; 12 | private String reqIp; 13 | private String peerIp; 14 | private int peerPort; 15 | private boolean seeder; 16 | private String userAgent; 17 | public String toKey() { 18 | return peerId + "," + reqIp + "," + peerIp + "," + peerPort + "," + seeder + "," + userAgent; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/clientdiscovery/ClientDiscoveryDto.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.clientdiscovery; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import java.io.Serializable; 9 | import java.time.OffsetDateTime; 10 | 11 | @Data 12 | @AllArgsConstructor 13 | @NoArgsConstructor 14 | @Builder 15 | public class ClientDiscoveryDto implements Serializable { 16 | private Long hash; 17 | private String clientName; 18 | private String peerId; 19 | private OffsetDateTime foundAt; 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/rule/internal/RuleRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.rule.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import org.springframework.stereotype.Repository; 5 | 6 | import java.time.OffsetDateTime; 7 | import java.util.List; 8 | 9 | @Repository 10 | public interface RuleRepository extends SparkleCommonRepository { 11 | List findByCategory(String category); 12 | 13 | List findByType(String type); 14 | 15 | List findByExpiredAtGreaterThan(OffsetDateTime time); 16 | 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/ability/AbstractCronJobAbility.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.ability; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import lombok.EqualsAndHashCode; 7 | import lombok.NoArgsConstructor; 8 | 9 | @EqualsAndHashCode(callSuper = true) 10 | @AllArgsConstructor 11 | @NoArgsConstructor 12 | @Data 13 | public abstract class AbstractCronJobAbility extends AbstractPingAbility { 14 | private long interval; 15 | 16 | @JsonProperty("random_initial_delay") 17 | private long randomInitialDelay; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/resources/templates/oauth/req_github_failed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 | 7 |
8 |

Github OAuth 登录过程失败

9 |

Sparkle 在与 Github API 通信以验证您的身份时出现了错误。

10 |

错误信息如下:

11 |

12 |

这通常是一个暂时性的错误,您可以点击此处重试 13 |

14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/dto/BtnBanPing.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import jakarta.validation.constraints.NotNull; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | import java.io.Serializable; 10 | import java.util.List; 11 | 12 | @Data 13 | @AllArgsConstructor 14 | @NoArgsConstructor 15 | public class BtnBanPing implements Serializable { 16 | @JsonProperty("populate_time") 17 | @NotNull 18 | private long populateTime; 19 | @JsonProperty("bans") 20 | private List bans; 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/dto/BtnPeerPing.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import jakarta.validation.constraints.NotNull; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | import java.io.Serializable; 10 | import java.util.List; 11 | 12 | @Data 13 | @AllArgsConstructor 14 | @NoArgsConstructor 15 | public class BtnPeerPing implements Serializable { 16 | @JsonProperty("populate_time") 17 | @NotNull 18 | private long populateTime; 19 | @JsonProperty("peers") 20 | private List peers; 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/config/UtilConfig.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.config; 2 | 3 | import kong.unirest.core.Unirest; 4 | import kong.unirest.core.UnirestInstance; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | 8 | @Configuration 9 | public class UtilConfig { 10 | @Bean 11 | public UnirestInstance unirest() { 12 | UnirestInstance instance = Unirest.spawnInstance(); 13 | instance.config() 14 | .addDefaultHeader("User-Agent", "Sparkle(BTN-Server)/1.0") 15 | .enableCookieManagement(true); 16 | return instance; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/build_maven.yml: -------------------------------------------------------------------------------- 1 | name: Maven Build 2 | 3 | on: 4 | workflow_call: 5 | jobs: 6 | Maven: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-java@v4 11 | with: 12 | distribution: "temurin" 13 | java-version: "21" 14 | cache: 'maven' 15 | cache-dependency-path: '**/pom.xml' 16 | - name: Build with Maven 17 | run: mvn -B clean package --file pom.xml 18 | - name: Upload build artifacts 19 | uses: actions/upload-artifact@v4 20 | with: 21 | name: maven-dist 22 | path: | 23 | target/*.jar 24 | id: project -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/analyse/proto/peer.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | option go_package = "biz/services/peer/mux_local"; 3 | 4 | message IPInfo { 5 | bytes client_ip = 1; 6 | bytes report_ip = 2; 7 | bytes report_v4 = 3; 8 | bytes report_v6 = 4; 9 | } 10 | 11 | enum PeerEvent { 12 | Unknown = 0; 13 | Started = 1; 14 | Stopped = 2; 15 | Completed = 3; 16 | } 17 | 18 | message PeerInfo { 19 | bytes info_hash = 1; 20 | IPInfo ip = 2; 21 | int32 port = 3; 22 | uint64 left = 4; 23 | uint64 downloaded = 5; 24 | uint64 uploaded = 6; 25 | int64 lastSeen = 7; 26 | string user_agent = 8; 27 | bytes peer_id = 9; 28 | PeerEvent event = 10; 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/WarningSender.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util; 2 | 3 | public class WarningSender { 4 | // A class that sends warnings to the console, but only once in period 5 | private final long minInterval; 6 | private long lastWarningTime = 0; 7 | 8 | public WarningSender(long minInterval) { 9 | this.minInterval = minInterval; 10 | } 11 | 12 | public boolean sendIfPossible() { 13 | long currentTime = System.currentTimeMillis(); 14 | if (currentTime - lastWarningTime > minInterval) { 15 | lastWarningTime = currentTime; 16 | return true; 17 | } 18 | return false; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/ability/AbstractCronJobEndpointAbility.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.ability; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.EqualsAndHashCode; 6 | import lombok.NoArgsConstructor; 7 | 8 | @EqualsAndHashCode(callSuper = true) 9 | @AllArgsConstructor 10 | @NoArgsConstructor 11 | @Data 12 | public abstract class AbstractCronJobEndpointAbility extends AbstractCronJobAbility { 13 | private String endpoint; 14 | 15 | public AbstractCronJobEndpointAbility(long interval, long randomInitialDelay, String endpoint) { 16 | super(interval, randomInitialDelay); 17 | this.endpoint = endpoint; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/userapp/UserApplicationVerboseDto.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.userapp; 2 | 3 | import com.ghostchu.btn.sparkle.module.user.UserDto; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Builder; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | import java.io.Serializable; 10 | import java.time.OffsetDateTime; 11 | 12 | @Data 13 | @AllArgsConstructor 14 | @NoArgsConstructor 15 | @Builder 16 | public class UserApplicationVerboseDto implements Serializable { 17 | private Long id; 18 | private String appId; 19 | private String appSecret; 20 | private String comment; 21 | private OffsetDateTime createdAt; 22 | private UserDto user; 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/InfoHashUtil.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util; 2 | 3 | import com.google.common.hash.Hashing; 4 | 5 | import java.nio.charset.StandardCharsets; 6 | import java.util.Locale; 7 | 8 | public class InfoHashUtil { 9 | public static String getHashedIdentifier(String torrentInfoHash) { 10 | String torrentInfoHandled = torrentInfoHash.toLowerCase(Locale.ROOT); // 转小写处理 11 | String salt = Hashing.crc32().hashString(torrentInfoHandled, StandardCharsets.UTF_8).toString(); // 使用 crc32 计算 info_hash 的哈希作为盐 12 | return Hashing.sha256().hashString(torrentInfoHandled + salt, StandardCharsets.UTF_8).toString(); // 在 info_hash 的明文后面追加盐后,计算 SHA256 的哈希值,结果应转全小写 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/resources/templates/modules/userapp/create.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 | 7 |
8 |

创建新的用户应用程序

9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 |
17 |
18 |
19 | 20 |
21 | 22 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/clientdiscovery/internal/ClientDiscovery.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.clientdiscovery.internal; 2 | 3 | import jakarta.persistence.*; 4 | import lombok.*; 5 | 6 | import java.time.OffsetDateTime; 7 | 8 | @Entity 9 | @Table(name = "clientdiscovery") 10 | @AllArgsConstructor 11 | @NoArgsConstructor 12 | @Getter 13 | @Setter 14 | @Builder 15 | public class ClientDiscovery { 16 | @Id 17 | @GeneratedValue 18 | @Column(nullable = false, unique = true) 19 | private Long hash; 20 | @Column(nullable = false) 21 | private String clientName; 22 | @Column(nullable = false) 23 | private String peerId; 24 | @Column(nullable = false) 25 | private OffsetDateTime foundAt; 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/ipdb/IPGeoData.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util.ipdb; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.io.Serializable; 8 | 9 | @AllArgsConstructor 10 | @NoArgsConstructor 11 | @Data 12 | public class IPGeoData implements Serializable { 13 | private String cityName; 14 | private Long cityIso; 15 | private String cityCnProvince; 16 | private String cityCnCity; 17 | private String cityCnDistricts; 18 | private String countryIso; 19 | private Long asNumber; 20 | private String asNetworkIpAddress; 21 | private Integer asNetworkPrefixLength; 22 | private String netType; 23 | private String isp; 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/user/UserDto.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.user; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import java.io.Serializable; 9 | import java.time.OffsetDateTime; 10 | 11 | @Data 12 | @AllArgsConstructor 13 | @NoArgsConstructor 14 | @Builder 15 | /** 16 | * UserDto 可能会暴露给外部接口,禁止包含敏感数据 17 | */ 18 | public class UserDto implements Serializable { 19 | private Long id; 20 | private String avatar; 21 | private String nickname; 22 | private String email; 23 | private OffsetDateTime registerAt; 24 | private OffsetDateTime lastSeenAt; 25 | private OffsetDateTime bannedAt; 26 | private String bannedReason; 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/PeerUtil.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util; 2 | 3 | import org.apache.commons.lang3.StringUtils; 4 | 5 | public class PeerUtil { 6 | /** 7 | * 处理PeerId,截断为前 8 个字符,如果为空(空串/null),返回 N/A 8 | * @param peerId 原始 PeerId 9 | * @return 截断后的 PeerId 或 N/A(如果为空) 10 | */ 11 | public static String cutPeerId(String peerId){ 12 | return StringUtils.left(StringUtils.defaultIfEmpty(peerId, "N/A"), 8); 13 | } 14 | 15 | /** 16 | * 处理ClientName,如果为空(空串/null),返回 N/A 17 | * @param clientName ClientName 18 | * @return ClientName 或 N/A(如果为空) 19 | */ 20 | public static String cutClientName(String clientName){ 21 | return StringUtils.defaultIfEmpty(clientName, "N/A"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/resources/templates/modules/userapp/created.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 |
7 |

用户应用程序创建成功

8 |

请妥善保存下面的凭据信息,这些信息只会显示一次!

9 |

请注意:每个客户端都必须创建一个单独的用户应用程序,复用 AppId 和 AppSecret 将可能导致您的 BTN 账号遭到封禁! 10 |

11 |
12 |
AppId
13 |
14 |
AppSecret
15 |
16 |
17 |
18 |
19 | 20 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/userapp/UserApplicationDto.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.userapp; 2 | 3 | import com.ghostchu.btn.sparkle.module.user.UserDto; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Builder; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | import java.io.Serializable; 10 | import java.time.OffsetDateTime; 11 | 12 | @Data 13 | @AllArgsConstructor 14 | @NoArgsConstructor 15 | @Builder 16 | public class UserApplicationDto implements Serializable { 17 | private Long id; 18 | private String appId; 19 | private String comment; 20 | private OffsetDateTime createdAt; 21 | private UserDto user; 22 | private OffsetDateTime bannedAt; 23 | private OffsetDateTime lastAccessAt; 24 | private String bannedReason; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/user/internal/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.user.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import org.springframework.lang.NonNull; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.time.OffsetDateTime; 8 | import java.util.List; 9 | import java.util.Optional; 10 | 11 | @Repository 12 | public interface UserRepository extends SparkleCommonRepository { 13 | Optional findByGithubLogin(@NonNull String githubLogin); 14 | Optional findByGithubUserId(@NonNull Long githubUserId); 15 | 16 | long countUserByLastSeenAtAfter(OffsetDateTime time); 17 | List findByNickname(String nickname); 18 | 19 | Optional findByEmail(String email); 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/**/target/ 4 | !**/src/test/**/target/ 5 | 6 | ### IntelliJ IDEA ### 7 | .idea/modules.xml 8 | .idea/jarRepositories.xml 9 | .idea/compiler.xml 10 | .idea/libraries/ 11 | .idea 12 | *.iws 13 | *.iml 14 | *.ipr 15 | 16 | ### Eclipse ### 17 | .apt_generated 18 | .classpath 19 | .factorypath 20 | .project 21 | .settings 22 | .springBeans 23 | .sts4-cache 24 | 25 | ### NetBeans ### 26 | /nbproject/private/ 27 | /nbbuild/ 28 | /dist/ 29 | /nbdist/ 30 | /.nb-gradle/ 31 | build/ 32 | !**/src/main/**/build/ 33 | !**/src/test/**/build/ 34 | 35 | ### VS Code ### 36 | .vscode/ 37 | 38 | ### Mac OS ### 39 | .DS_Store 40 | /logs/ 41 | /config.yml 42 | /profile.yml 43 | 44 | /.idea/ 45 | /config/ 46 | /data/ 47 | 48 | dependency-reduced-pom.xml 49 | /application.properties 50 | /application*.properties -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/snapshot/internal/SnapshotRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.snapshot.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import org.springframework.data.domain.Page; 5 | import org.springframework.data.domain.Pageable; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import java.time.OffsetDateTime; 9 | 10 | @Repository 11 | public interface SnapshotRepository extends SparkleCommonRepository { 12 | Page findByOrderByInsertTimeDesc(Pageable pageable); 13 | 14 | long countByInsertTimeBetween(OffsetDateTime insertTimeStart, OffsetDateTime insertTimeEnd); 15 | 16 | long deleteByInsertTimeBefore(OffsetDateTime offsetDateTime); 17 | 18 | long deleteAllByInsertTimeBefore(OffsetDateTime offsetDateTime); 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/peerhistory/internal/PeerHistoryRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.peerhistory.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import org.springframework.data.domain.Page; 5 | import org.springframework.data.domain.Pageable; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import java.time.OffsetDateTime; 9 | 10 | @Repository 11 | public interface PeerHistoryRepository extends SparkleCommonRepository { 12 | Page findByOrderByInsertTimeDesc(Pageable pageable); 13 | 14 | long countByInsertTimeBetween(OffsetDateTime insertTimeStart, OffsetDateTime insertTimeEnd); 15 | 16 | // 删除 N 天前的数据 17 | long deleteByInsertTimeBefore(OffsetDateTime insertTime); 18 | 19 | long deleteAllByInsertTimeBefore(OffsetDateTime offsetDateTime); 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/SparkleApplication.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories; 6 | import org.springframework.retry.annotation.EnableRetry; 7 | import org.springframework.scheduling.annotation.EnableAsync; 8 | import org.springframework.scheduling.annotation.EnableScheduling; 9 | import org.springframework.transaction.annotation.EnableTransactionManagement; 10 | 11 | @SpringBootApplication 12 | @EnableAsync 13 | @EnableScheduling 14 | @EnableTransactionManagement 15 | @EnableJpaRepositories 16 | @EnableRetry 17 | public class SparkleApplication { 18 | 19 | public static void main(String[] args) { 20 | SpringApplication.run(SparkleApplication.class, args); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/ClientAuthenticationCredential.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping; 2 | 3 | import java.io.Serializable; 4 | 5 | public class ClientAuthenticationCredential implements Serializable { 6 | private String appId; 7 | private String appSecret; 8 | 9 | public ClientAuthenticationCredential(String appId, String appSecret) { 10 | this.appId = appId; 11 | this.appSecret = appSecret; 12 | } 13 | 14 | public String appId() { 15 | return appId.trim(); 16 | } 17 | 18 | public String appSecret() { 19 | return appSecret.trim(); 20 | } 21 | 22 | public boolean isValid() { 23 | return appId != null && appSecret != null; 24 | } 25 | 26 | public void verifyOrThrow() { 27 | if (!isValid()) { 28 | throw new IllegalArgumentException("请求未鉴权,客户端实现必须进行登录鉴权:https://github.com/PBH-BTN/BTN-Spec"); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sparkle - 花火 2 | 3 | 一个使用 Java 语言的 BTN 实现。 4 | 5 | > [!WARNING] 6 | > Sparkle 目前处于实验性状态,不建议部署生产环境使用 7 | 8 | ## 日程表 9 | 10 | 目前仍有部分功能缺失,正在努力完成 11 | 12 | * [ ] 高频操作内存缓存,目前仍在直接查询 PostgreSQL 数据库 13 | * [ ] 前端页面 14 | * [ ] 高级检索功能 15 | * ... 16 | 17 | ## 简述 18 | 19 | Sparkle 是一个遵循 [BTN 规范](https://github.com/PBH-BTN/BTN-Spec) 的官方 Java 实现。能够接收 PBH 等 BTN 20 | 兼容客户端的数据上报,并下发云规则。 21 | 22 | ## 需要环境 23 | 24 | * Java 21 或者更高版本 25 | * PostgreSQL 26 | * Redis 27 | * Github OAuth Application 28 | 29 | ## 部署 30 | 31 | 目前 Sparkle 仍处于早期开发阶段,我们暂时不提供部署教程。 32 | 33 | ## 功能 34 | 35 | * [x] BTN: Submit Peers Ability (Async) 36 | * [x] BTN: Submit Bans Ability (Async) 37 | * [x] BTN: Rules Ability (Async) 38 | * [x] BTN: Reconfigure Ability (Async) 39 | * [x] 客户端特征发现 40 | * [x] 操作与行为审计 41 | * [x] 自动生成不可信 IP 规则 (从 BanHistory) 42 | * [x] 自动生成过量下载规则 (从 Snapshot) 43 | * [x] 与 Github 仓库同步生成的规则 44 | * [x] Snapshot/Ban 记录搜索 45 | 46 | ## API 文档 47 | 48 | https://btn-sparkle.apifox.cn 49 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/config/InetAddressDeserializer.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.config; 2 | 3 | import com.fasterxml.jackson.core.JsonParser; 4 | import com.fasterxml.jackson.databind.DeserializationContext; 5 | import com.fasterxml.jackson.databind.JsonDeserializer; 6 | 7 | import java.io.IOException; 8 | import java.net.InetAddress; 9 | import java.net.InetSocketAddress; 10 | 11 | public class InetAddressDeserializer extends JsonDeserializer { 12 | @Override 13 | public InetAddress deserialize(JsonParser parser, DeserializationContext ctxt) throws IOException { 14 | String ip = parser.getText(); 15 | try { 16 | // 使用 createUnresolved 创建未解析的 InetAddress 17 | return InetSocketAddress.createUnresolved(ip, 0).getAddress(); 18 | } catch (IllegalArgumentException e) { 19 | return InetSocketAddress.createUnresolved("127.123.123.123", 0).getAddress(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/ability/impl/ReconfigureAbility.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.ability.impl; 2 | 3 | import com.ghostchu.btn.sparkle.module.ping.ability.AbstractCronJobAbility; 4 | import lombok.Data; 5 | import lombok.EqualsAndHashCode; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.util.UUID; 10 | 11 | @EqualsAndHashCode(callSuper = true) 12 | @Component 13 | @Data 14 | public class ReconfigureAbility extends AbstractCronJobAbility { 15 | private final String version = UUID.randomUUID().toString(); 16 | 17 | public ReconfigureAbility( 18 | @Value("${service.ping.ability.reconfigure.interval}") 19 | long interval, 20 | @Value("${service.ping.ability.reconfigure.random-initial-delay}") 21 | long randomInitialDelay 22 | ) { 23 | super(interval, randomInitialDelay); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/torrent/internal/Torrent.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.torrent.internal; 2 | 3 | import jakarta.persistence.*; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.Setter; 8 | 9 | import java.io.Serializable; 10 | 11 | @Entity 12 | @Table(name = "torrent", 13 | uniqueConstraints = {@UniqueConstraint(columnNames = {"identifier","size"})}, 14 | indexes = {@Index(columnList = "identifier"), @Index(columnList = "id, size")}) 15 | @AllArgsConstructor 16 | @NoArgsConstructor 17 | @Getter 18 | @Setter 19 | public class Torrent implements Serializable { 20 | @Id 21 | @GeneratedValue 22 | @Column(nullable = false, unique = true) 23 | private Long id; 24 | @Column(nullable = false) 25 | private String identifier; 26 | @Column(nullable = false) 27 | private Long size; 28 | @Column() 29 | private Boolean privateTorrent; 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/ability/impl/SubmitBansAbility.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.ability.impl; 2 | 3 | import com.ghostchu.btn.sparkle.module.ping.ability.AbstractCronJobEndpointAbility; 4 | import lombok.Data; 5 | import lombok.EqualsAndHashCode; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.stereotype.Component; 8 | @EqualsAndHashCode(callSuper = true) 9 | @Data 10 | @Component 11 | public class SubmitBansAbility extends AbstractCronJobEndpointAbility { 12 | public SubmitBansAbility( 13 | @Value("${service.ping.ability.submitbans.interval}") 14 | long interval, 15 | @Value("${service.ping.ability.submitbans.endpoint}") 16 | String endpoint, 17 | @Value("${service.ping.ability.submitbans.random-initial-delay}") 18 | long randomInitialDelay 19 | ) { 20 | super(interval, randomInitialDelay, endpoint); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/ability/impl/CloudRuleAbility.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.ability.impl; 2 | 3 | import com.ghostchu.btn.sparkle.module.ping.ability.AbstractCronJobEndpointAbility; 4 | import lombok.Data; 5 | import lombok.EqualsAndHashCode; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.stereotype.Component; 8 | 9 | @EqualsAndHashCode(callSuper = true) 10 | @Data 11 | @Component 12 | public class CloudRuleAbility extends AbstractCronJobEndpointAbility { 13 | 14 | public CloudRuleAbility( 15 | @Value("${service.ping.ability.cloudrule.interval}") 16 | long interval, 17 | @Value("${service.ping.ability.cloudrule.endpoint}") 18 | String endpoint, 19 | @Value("${service.ping.ability.cloudrule.random-initial-delay}") 20 | long randomInitialDelay 21 | ) { 22 | super(interval, randomInitialDelay, endpoint); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/ability/impl/SubmitPeersAbility.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.ability.impl; 2 | 3 | import com.ghostchu.btn.sparkle.module.ping.ability.AbstractCronJobEndpointAbility; 4 | import lombok.Data; 5 | import lombok.EqualsAndHashCode; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.stereotype.Component; 8 | @EqualsAndHashCode(callSuper = true) 9 | @Data 10 | @Component 11 | public class SubmitPeersAbility extends AbstractCronJobEndpointAbility { 12 | public SubmitPeersAbility( 13 | @Value("${service.ping.ability.submitpeers.interval}") 14 | long interval, 15 | @Value("${service.ping.ability.submitpeers.endpoint}") 16 | String endpoint, 17 | @Value("${service.ping.ability.submitpeers.random-initial-delay}") 18 | long randomInitialDelay 19 | ) { 20 | super(interval, randomInitialDelay, endpoint); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/analyse/impl/AnalysedRuleRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.analyse.impl; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import org.springframework.data.jpa.repository.Query; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.util.Collection; 8 | import java.util.List; 9 | 10 | @Repository 11 | public interface AnalysedRuleRepository extends SparkleCommonRepository { 12 | //List findByModule(String module); 13 | List findByModuleOrderByIpAsc(String module); 14 | 15 | @Query("SELECT DISTINCT module FROM AnalysedRule") 16 | List getAllModules(); 17 | 18 | long deleteAllByModule(String module); 19 | 20 | List findAll(); 21 | 22 | default void replaceAll(String module, Collection rules) { 23 | deleteAllByModule(module); 24 | saveAll(rules); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/clientdiscovery/ClientIdentity.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.clientdiscovery; 2 | 3 | import com.google.common.hash.Hashing; 4 | import lombok.Data; 5 | import lombok.EqualsAndHashCode; 6 | import lombok.NoArgsConstructor; 7 | 8 | import java.io.Serializable; 9 | import java.nio.charset.StandardCharsets; 10 | @NoArgsConstructor 11 | @Data 12 | @EqualsAndHashCode 13 | public class ClientIdentity implements Serializable { 14 | private String peerId; 15 | private String clientName; 16 | private transient Long hash = null; 17 | 18 | public ClientIdentity(String peerId, String clientName) { 19 | this.peerId = peerId; 20 | this.clientName = clientName; 21 | } 22 | 23 | public long hash() { 24 | if(hash != null){ 25 | return hash; 26 | } 27 | hash = Hashing.sha256().hashString(peerId + '@' + clientName, StandardCharsets.UTF_8).padToLong(); 28 | return hash; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/snapshot/SnapshotDto.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.snapshot; 2 | 3 | import com.ghostchu.btn.sparkle.module.torrent.TorrentDto; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Builder; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | import java.io.Serializable; 10 | 11 | @Data 12 | @AllArgsConstructor 13 | @NoArgsConstructor 14 | @Builder 15 | public class SnapshotDto implements Serializable { 16 | private Long id; 17 | private String appId; 18 | private String submitId; 19 | private String peerIp; 20 | private Integer peerPort; 21 | private String peerId; 22 | private String peerClientName; 23 | private TorrentDto torrent; 24 | private Long fromPeerTraffic; 25 | private Long fromPeerTrafficSpeed; 26 | private Long toPeerTraffic; 27 | private Long toPeerTrafficSpeed; 28 | private Double peerProgress; 29 | private Double downloaderProgress; 30 | private String flags; 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/ability/impl/SubmitHistoriesAbility.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.ability.impl; 2 | 3 | import com.ghostchu.btn.sparkle.module.ping.ability.AbstractCronJobEndpointAbility; 4 | import lombok.Data; 5 | import lombok.EqualsAndHashCode; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.stereotype.Component; 8 | 9 | @EqualsAndHashCode(callSuper = true) 10 | @Data 11 | @Component 12 | public class SubmitHistoriesAbility extends AbstractCronJobEndpointAbility { 13 | public SubmitHistoriesAbility( 14 | @Value("${service.ping.ability.submithistories.interval}") 15 | long interval, 16 | @Value("${service.ping.ability.submithistories.endpoint}") 17 | String endpoint, 18 | @Value("${service.ping.ability.submithistories.random-initial-delay}") 19 | long randomInitialDelay 20 | ) { 21 | super(interval, randomInitialDelay, endpoint); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/userscore/internal/UserScore.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.userscore.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.user.internal.User; 4 | import jakarta.persistence.*; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | import lombok.Setter; 9 | 10 | @Entity 11 | @Table(name = "user_score", 12 | uniqueConstraints = {@UniqueConstraint(columnNames = "user")}, 13 | indexes = {@Index(columnList = "user")}) 14 | @AllArgsConstructor 15 | @NoArgsConstructor 16 | @Getter 17 | @Setter 18 | public class UserScore { 19 | @Id 20 | @GeneratedValue 21 | @Column(nullable = false, unique = true) 22 | private Long id; 23 | @JoinColumn(nullable = false, name = "user") 24 | @ManyToOne(fetch = FetchType.EAGER) 25 | private User user; 26 | @Column(nullable = false) 27 | private Long scoreBytes; 28 | //@Version 29 | @Column(nullable = false) 30 | private long version = 0; 31 | } 32 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.7/apache-maven-3.9.7-bin.zip 20 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/paging/SparklePage.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util.paging; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | import org.springframework.data.domain.Page; 8 | 9 | import java.io.Serializable; 10 | import java.util.List; 11 | import java.util.function.Function; 12 | import java.util.stream.Stream; 13 | 14 | @AllArgsConstructor 15 | @NoArgsConstructor 16 | @Data 17 | @Builder 18 | public class SparklePage implements Serializable { 19 | private int page; 20 | private int size; 21 | private long total; 22 | private List results; 23 | 24 | public SparklePage(Page page, Function, Stream> mapper){ 25 | this.page = page.getPageable().getPageNumber(); 26 | this.size = page.getPageable().getPageSize(); 27 | this.total = page.getTotalElements(); 28 | this.results = mapper.apply(page.getContent().stream()).toList(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/controller/SparkleController.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.controller; 2 | 3 | import com.ghostchu.btn.sparkle.exception.RequestPageSizeTooLargeException; 4 | import com.ghostchu.btn.sparkle.util.ServletUtil; 5 | import jakarta.servlet.http.HttpServletRequest; 6 | 7 | public abstract class SparkleController { 8 | 9 | public String ua(HttpServletRequest req){ 10 | return req.getHeader("User-Agent") != null ? req.getHeader("User-Agent") : "Unknown"; 11 | } 12 | 13 | public String ip(HttpServletRequest req){ 14 | return ServletUtil.getIP(req); 15 | } 16 | 17 | public Paging paging(Integer page, Integer pageSize) throws RequestPageSizeTooLargeException { 18 | if (page == null) page = 0; 19 | if (pageSize == null) pageSize = 100; 20 | if (pageSize > 3000) { 21 | throw new RequestPageSizeTooLargeException(); 22 | } 23 | return new Paging(page,pageSize); 24 | } 25 | 26 | 27 | public record Paging( int page,int pageSize){ 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/rule/internal/Rule.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.rule.internal; 2 | 3 | import jakarta.persistence.*; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.Setter; 8 | 9 | import java.time.OffsetDateTime; 10 | 11 | @Entity 12 | @Table(name = "rule", 13 | indexes = {@Index(columnList = "category"), @Index(columnList = "type"), @Index(columnList = "expiredAt DESC")}) 14 | @AllArgsConstructor 15 | @NoArgsConstructor 16 | @Getter 17 | @Setter 18 | public class Rule { 19 | @Id 20 | @GeneratedValue 21 | @Column(nullable = false, unique = true) 22 | private Long id; 23 | @Column(nullable = false) 24 | private String category; 25 | @Column(nullable = false, columnDefinition = "TEXT") 26 | private String content; 27 | @Column(nullable = false) 28 | private String type; 29 | @Column(nullable = false) 30 | private OffsetDateTime createdAt; 31 | @Column(nullable = false) 32 | private OffsetDateTime expiredAt; 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/repository/SparkleCommonRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.repository; 2 | 3 | import org.springframework.data.domain.Page; 4 | import org.springframework.data.domain.PageRequest; 5 | import org.springframework.data.jpa.domain.Specification; 6 | import org.springframework.data.jpa.repository.JpaSpecificationExecutor; 7 | import org.springframework.data.repository.CrudRepository; 8 | 9 | import java.util.function.Consumer; 10 | 11 | public interface SparkleCommonRepository extends CrudRepository, JpaSpecificationExecutor { 12 | default void findAllByPaging(Specification specification, Consumer> consumer) { 13 | PageRequest request = PageRequest.of(0, 500); 14 | Page page; 15 | while (true) { 16 | page = findAll(specification, request); 17 | consumer.accept(page); 18 | if (page.hasNext()) { 19 | request = request.next(); 20 | } else { 21 | break; 22 | } 23 | } 24 | } 25 | 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/config/SparkleRedisCacheConfig.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.config; 2 | 3 | import org.springframework.cache.CacheManager; 4 | import org.springframework.cache.annotation.EnableCaching; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.data.redis.cache.RedisCacheConfiguration; 8 | import org.springframework.data.redis.cache.RedisCacheWriter; 9 | import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; 10 | 11 | import java.time.Duration; 12 | 13 | @Configuration 14 | @EnableCaching 15 | public class SparkleRedisCacheConfig { 16 | @Bean 17 | public CacheManager cacheManager(LettuceConnectionFactory redisConnectionFactory) { 18 | RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig() 19 | .entryTtl(Duration.ofMillis(5)); 20 | 21 | return new SparkleRedisCacheManager(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory), defaultCacheConfig); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/peerhistory/SnapshotHistoryDto.java: -------------------------------------------------------------------------------- 1 | //package com.ghostchu.btn.sparkle.module.snapshothistory.internal.snapshot; 2 | // 3 | //import com.ghostchu.btn.sparkle.module.torrent.TorrentDto; 4 | //import lombok.AllArgsConstructor; 5 | //import lombok.Builder; 6 | //import lombok.Data; 7 | //import lombok.NoArgsConstructor; 8 | // 9 | //import java.io.Serializable; 10 | // 11 | //@Data 12 | //@AllArgsConstructor 13 | //@NoArgsConstructor 14 | //@Builder 15 | //public class SnapshotHistoryDto implements Serializable { 16 | // private Long id; 17 | // private String appId; 18 | // private String submitId; 19 | // private String peerIp; 20 | // private Integer peerPort; 21 | // private String peerId; 22 | // private String peerClientName; 23 | // private TorrentDto torrent; 24 | // private Long fromPeerTraffic; 25 | // private Long fromPeerTrafficSpeed; 26 | // private Long toPeerTraffic; 27 | // private Long toPeerTrafficSpeed; 28 | // private Double peerProgress; 29 | // private Double downloaderProgress; 30 | // private String flags; 31 | //} 32 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/compare/NumberCompareMethod.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util.compare; 2 | 3 | import jakarta.persistence.criteria.CriteriaBuilder; 4 | import jakarta.persistence.criteria.Expression; 5 | import jakarta.persistence.criteria.Predicate; 6 | 7 | import java.io.Serializable; 8 | 9 | public enum NumberCompareMethod implements CriteriaBuilderSupport, Serializable { 10 | LESS_THAN, 11 | LESS_THAN_EQUAL, 12 | GREATER_THAN, 13 | GREATER_THAN_EQUAL, 14 | EQUAL; 15 | 16 | @Override 17 | public Predicate criteriaBuilder(CriteriaBuilder criteriaBuilder, Expression expression, Number value) { 18 | return switch (this){ 19 | case LESS_THAN -> criteriaBuilder.lt(expression, value); 20 | case LESS_THAN_EQUAL -> criteriaBuilder.le(expression, value); 21 | case GREATER_THAN -> criteriaBuilder.gt(expression, value); 22 | case GREATER_THAN_EQUAL -> criteriaBuilder.ge(expression, value); 23 | case EQUAL -> criteriaBuilder.equal(expression, value); 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/dto/BtnBan.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import com.ghostchu.btn.sparkle.wrapper.StructuredData; 5 | import jakarta.validation.constraints.NotNull; 6 | import jakarta.validation.constraints.Null; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | import java.io.Serializable; 12 | 13 | @Data 14 | @AllArgsConstructor 15 | @NoArgsConstructor 16 | public class BtnBan implements Serializable { 17 | 18 | @NotNull 19 | @JsonProperty("btn_ban") 20 | private boolean btnBan; 21 | 22 | @NotNull 23 | @JsonProperty("ban_unique_id") 24 | private String banUniqueId; 25 | 26 | @NotNull 27 | @JsonProperty("module") 28 | private String module; 29 | 30 | @NotNull 31 | @JsonProperty("rule") 32 | private String rule; 33 | 34 | @NotNull 35 | @JsonProperty("peer") 36 | private BtnPeer peer; 37 | 38 | @JsonProperty("structured_data") 39 | private StructuredData structuredData; 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/userscore/internal/UserScoreHistory.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.userscore.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.user.internal.User; 4 | import jakarta.persistence.*; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | import lombok.Setter; 9 | 10 | import java.time.OffsetDateTime; 11 | 12 | @Entity 13 | @Table(name = "user_score_history", 14 | indexes = {@Index(columnList = "user")}) 15 | @AllArgsConstructor 16 | @NoArgsConstructor 17 | @Getter 18 | @Setter 19 | public class UserScoreHistory { 20 | @Id 21 | @GeneratedValue 22 | @Column(nullable = false, unique = true) 23 | private Long id; 24 | @Column(nullable = false) 25 | private OffsetDateTime time; 26 | @JoinColumn(nullable = false, name = "user") 27 | @ManyToOne(fetch = FetchType.EAGER) 28 | private User user; 29 | @Column(nullable = false) 30 | private Long scoreBytesChanges; 31 | @Column(nullable = false) 32 | private Long scoreBytesNow; 33 | @Column() 34 | private String reason; 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/banhistory/BanHistoryDto.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.banhistory; 2 | 3 | import com.ghostchu.btn.sparkle.module.torrent.TorrentDto; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Builder; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | import java.io.Serializable; 10 | 11 | @Data 12 | @AllArgsConstructor 13 | @NoArgsConstructor 14 | @Builder 15 | public class BanHistoryDto implements Serializable { 16 | private Long id; 17 | private String appId; 18 | private String submitId; 19 | private String peerIp; 20 | private Integer peerPort; 21 | private String peerId; 22 | private String peerClientName; 23 | private TorrentDto torrent; 24 | private Long fromPeerTraffic; 25 | private Long fromPeerTrafficSpeed; 26 | private Long toPeerTraffic; 27 | private Long toPeerTrafficSpeed; 28 | private Double peerProgress; 29 | private Double downloaderProgress; 30 | private String flags; 31 | private Boolean btnBan; 32 | private String module; 33 | private String rule; 34 | private String banUniqueId; 35 | private String structuredData; 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/config/SparkleRedisCacheManager.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.config; 2 | 3 | import org.springframework.data.redis.cache.RedisCache; 4 | import org.springframework.data.redis.cache.RedisCacheConfiguration; 5 | import org.springframework.data.redis.cache.RedisCacheManager; 6 | import org.springframework.data.redis.cache.RedisCacheWriter; 7 | import org.springframework.util.StringUtils; 8 | 9 | import java.time.Duration; 10 | 11 | public class SparkleRedisCacheManager extends RedisCacheManager{ 12 | public SparkleRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) { 13 | super(cacheWriter, defaultCacheConfiguration); 14 | } 15 | 16 | @Override 17 | protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) { 18 | String[] array = StringUtils.delimitedListToStringArray(name, "#"); 19 | name = array[0]; 20 | if (array.length > 1) { 21 | long ttl = Long.parseLong(array[1]); 22 | cacheConfig = cacheConfig.entryTtl(Duration.ofMillis(ttl)); 23 | } 24 | return super.createRedisCache(name, cacheConfig); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/config/SparkleTomcatWebServerFactoryCustomizer.java: -------------------------------------------------------------------------------- 1 | //package com.ghostchu.btn.sparkle.config; 2 | // 3 | //import org.apache.commons.lang3.StringUtils; 4 | //import org.springframework.beans.factory.annotation.Value; 5 | //import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; 6 | //import org.springframework.boot.web.server.WebServerFactoryCustomizer; 7 | //import org.springframework.stereotype.Component; 8 | // 9 | //import java.io.File; 10 | // 11 | //@Component 12 | //public class SparkleTomcatWebServerFactoryCustomizer implements WebServerFactoryCustomizer { 13 | // @Value("${server.unixsocketpath}") 14 | // private String unixSocketPath; 15 | // 16 | // @Override 17 | // public void customize(TomcatServletWebServerFactory factory) { 18 | // if (StringUtils.isEmpty(unixSocketPath)) { 19 | // return; 20 | // } 21 | // File file = new File(unixSocketPath); 22 | // if (file.exists()) { 23 | // file.delete(); 24 | // } 25 | // factory.addConnectorCustomizers(connector -> connector.setProperty("unixDomainSocketPath", unixSocketPath)); 26 | // } 27 | //} 28 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/config/SpringWebSocketServerEndpointConfigurator.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.config; 2 | 3 | import jakarta.websocket.server.ServerEndpointConfig; 4 | import org.springframework.beans.BeansException; 5 | import org.springframework.beans.factory.BeanFactory; 6 | import org.springframework.context.ApplicationContext; 7 | import org.springframework.context.ApplicationContextAware; 8 | import org.springframework.stereotype.Component; 9 | 10 | /** 11 | * Custom ServerEndpointConfigurator to enable Spring dependency injection in WebSocket endpoints 12 | */ 13 | @Component 14 | public class SpringWebSocketServerEndpointConfigurator extends ServerEndpointConfig.Configurator implements ApplicationContextAware { 15 | 16 | private static volatile BeanFactory beanFactory; 17 | 18 | @Override 19 | public T getEndpointInstance(Class endpointClass) throws InstantiationException { 20 | return beanFactory.getBean(endpointClass); 21 | } 22 | 23 | @Override 24 | public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { 25 | SpringWebSocketServerEndpointConfigurator.beanFactory = applicationContext.getAutowireCapableBeanFactory(); 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/repository/PartitionAware.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.repository; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.MappedSuperclass; 5 | import org.hibernate.annotations.Filter; 6 | import org.hibernate.annotations.FilterDef; 7 | import org.hibernate.annotations.ParamDef; 8 | import org.hibernate.annotations.PartitionKey; 9 | 10 | @MappedSuperclass 11 | @FilterDef( 12 | name = PartitionAware.PARTITION_KEY, 13 | parameters = @ParamDef( 14 | name = PartitionAware.PARTITION_KEY, 15 | type = String.class 16 | ) 17 | ) 18 | @Filter( 19 | name = PartitionAware.PARTITION_KEY, 20 | condition = "partition_key = :partitionKey" 21 | ) 22 | public abstract class PartitionAware { 23 | 24 | public static final String PARTITION_KEY = "partitionKey"; 25 | 26 | @Column(name = "partition_key") 27 | @PartitionKey 28 | private String partitionKey; 29 | 30 | public String getPartitionKey() { 31 | return partitionKey; 32 | } 33 | 34 | public T setPartitionKey(String partitionKey) { 35 | this.partitionKey = partitionKey; 36 | return (T) this; 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/config/JacksonConfig.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.config; 2 | 3 | import com.fasterxml.jackson.databind.DeserializationFeature; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import com.fasterxml.jackson.databind.module.SimpleModule; 6 | import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; 7 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 8 | import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.context.annotation.Primary; 12 | 13 | import java.net.InetAddress; 14 | 15 | @Configuration 16 | public class JacksonConfig { 17 | @Bean 18 | @Primary 19 | public ObjectMapper objectMapper() { 20 | ObjectMapper mapper = new ObjectMapper(); 21 | SimpleModule module = new SimpleModule(); 22 | module.addDeserializer(InetAddress.class, new InetAddressDeserializer()); 23 | mapper.registerModule(module); 24 | mapper.registerModule(new ParameterNamesModule()); 25 | mapper.registerModule(new Jdk8Module()); 26 | mapper.registerModule(new JavaTimeModule()); 27 | mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 28 | return mapper; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/audit/impl/Audit.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.audit.impl; 2 | 3 | import io.hypersistence.utils.hibernate.type.json.JsonType; 4 | import jakarta.persistence.*; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | import lombok.Setter; 9 | import org.hibernate.annotations.Type; 10 | 11 | import java.net.InetAddress; 12 | import java.time.OffsetDateTime; 13 | import java.util.List; 14 | import java.util.Map; 15 | 16 | @Entity 17 | @Table(name = "audit", 18 | indexes = {@Index(columnList = "action"), @Index(columnList = "ip")} 19 | ) 20 | @AllArgsConstructor 21 | @NoArgsConstructor 22 | @Getter 23 | @Setter 24 | public class Audit { 25 | @Id 26 | @GeneratedValue 27 | @Column(nullable = false, unique = true) 28 | private Long id; 29 | @Column(nullable = false) 30 | private OffsetDateTime timestamp; 31 | @Column 32 | private InetAddress ip; 33 | @Column(nullable = false) 34 | private String action; 35 | @Column(nullable = false) 36 | private Boolean success; 37 | @Column(nullable = false, columnDefinition = "jsonb") 38 | @Type(JsonType.class) 39 | private Map> headers; 40 | @Column(nullable = false, columnDefinition = "jsonb") 41 | @Type(JsonType.class) 42 | private Map details; 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/userapp/internal/UserApplicationRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.userapp.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import com.ghostchu.btn.sparkle.module.user.internal.User; 5 | import org.springframework.data.jpa.repository.Query; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import java.util.List; 9 | import java.util.Optional; 10 | 11 | @Repository 12 | public interface UserApplicationRepository extends SparkleCommonRepository { 13 | @Query("select ua from UserApplication ua where ua.user.id = ?1 and ua.deletedAt is null") 14 | List findByUser_Id(Long id); 15 | 16 | @Query("select ua from UserApplication ua where ua.appId = ?1 and ua.deletedAt is null") 17 | Optional findByAppId(String appId); 18 | 19 | @Query("select ua from UserApplication ua where ua.appId = ?1 and ua.appSecret = ?2 and ua.deletedAt is null") 20 | Optional findByAppIdAndAppSecret(String appId, String appSecret); 21 | 22 | @Query("select ua from UserApplication ua where ua.user = ?1 and ua.deletedAt is null") 23 | List findByUser(User user); 24 | 25 | @Query("select count(ua) from UserApplication ua where ua.user = ?1 and ua.deletedAt is null") 26 | long countByUser(User user); 27 | 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/compare/StringCompareMethod.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util.compare; 2 | 3 | import jakarta.persistence.criteria.CriteriaBuilder; 4 | import jakarta.persistence.criteria.Expression; 5 | import jakarta.persistence.criteria.Predicate; 6 | 7 | import java.io.Serializable; 8 | 9 | public enum StringCompareMethod implements CriteriaBuilderSupport, Serializable { 10 | CONTAINS, 11 | NOT_CONTAINS, 12 | EQUALS, 13 | NOT_EQUALS, 14 | STARTS_WITH, 15 | NOT_STARTS_WITH, 16 | ENDS_WITH, 17 | NOT_ENDS_WITH; 18 | 19 | @Override 20 | public Predicate criteriaBuilder(CriteriaBuilder criteriaBuilder, Expression expression, String value) { 21 | return switch (this){ 22 | case CONTAINS -> criteriaBuilder.like(expression, "%"+value+"%"); 23 | case NOT_CONTAINS -> criteriaBuilder.notLike(expression, "%"+value+"%"); 24 | case EQUALS -> criteriaBuilder.equal(expression, value); 25 | case NOT_EQUALS -> criteriaBuilder.notEqual(expression, value); 26 | case STARTS_WITH -> criteriaBuilder.like(expression, value+"%"); 27 | case NOT_STARTS_WITH -> criteriaBuilder.notLike(expression, value+"%"); 28 | case ENDS_WITH -> criteriaBuilder.like(expression, "%"+value); 29 | case NOT_ENDS_WITH -> criteriaBuilder.notLike(expression, "%"+value); 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/dto/BtnPeerHistory.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import jakarta.annotation.Nullable; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | import java.sql.Timestamp; 10 | 11 | @Data 12 | @AllArgsConstructor 13 | @NoArgsConstructor 14 | public class BtnPeerHistory { 15 | @JsonProperty("ip_address") 16 | private String ipAddress; 17 | @JsonProperty("peer_id") 18 | private String peerId; 19 | @JsonProperty("client_name") 20 | private String clientName; 21 | @JsonProperty("torrent_identifier") 22 | private String torrentIdentifier; 23 | @JsonProperty("torrent_size") 24 | private long torrentSize; 25 | @Nullable 26 | @JsonProperty("torrent_is_private") 27 | private Boolean privateTorrent; 28 | @JsonProperty("downloaded") 29 | private long downloaded; 30 | @JsonProperty("downloaded_offset") 31 | private long downloadedOffset; 32 | @JsonProperty("uploaded") 33 | private long uploaded; 34 | @JsonProperty("uploaded_offset") 35 | private long uploadedOffset; 36 | @JsonProperty("first_time_seen") 37 | private Timestamp firstTimeSeen; 38 | @JsonProperty("last_time_seen") 39 | private Timestamp lastTimeSeen; 40 | @JsonProperty("peer_flag") 41 | private String peerFlag; 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/user/UserViewController.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.user; 2 | 3 | import cn.dev33.satoken.annotation.SaCheckLogin; 4 | import cn.dev33.satoken.stp.StpUtil; 5 | import com.ghostchu.btn.sparkle.module.userscore.UserScoreService; 6 | import org.apache.commons.io.FileUtils; 7 | import org.springframework.stereotype.Controller; 8 | import org.springframework.ui.Model; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | 12 | @Controller 13 | @SaCheckLogin 14 | @RequestMapping("/user") 15 | public class UserViewController { 16 | private final UserService userService; 17 | private final UserScoreService userScoreService; 18 | 19 | public UserViewController(UserService userService, UserScoreService userScoreService) { 20 | this.userService = userService; 21 | this.userScoreService = userScoreService; 22 | } 23 | 24 | @GetMapping("/profile") 25 | public String profile(Model model) { 26 | var dto = userService.toDto(userService.getUser((StpUtil.getLoginIdAsLong())).get()); 27 | model.addAttribute("user", dto); 28 | var userScore = userScoreService.getUserScoreBytes(userService.getUser((StpUtil.getLoginIdAsLong())).get()); 29 | model.addAttribute("userScoreBytesDisplay", FileUtils.byteCountToDisplaySize(userScore)); 30 | model.addAttribute("userScoreBytesRaw", userScore); 31 | return "user/profile"; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/userapp/internal/UserApplication.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.userapp.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.user.internal.User; 4 | import jakarta.persistence.*; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | import lombok.Setter; 9 | import org.hibernate.annotations.DynamicUpdate; 10 | 11 | import java.time.OffsetDateTime; 12 | 13 | @Entity 14 | @Table(name = "userapp", uniqueConstraints = {@UniqueConstraint(columnNames = "appId")}, 15 | indexes = {@Index(columnList = "appId, appSecret"), @Index(columnList = "user")}) 16 | @AllArgsConstructor 17 | @NoArgsConstructor 18 | @Getter 19 | @Setter 20 | @DynamicUpdate 21 | public class UserApplication { 22 | @Id 23 | @GeneratedValue 24 | @Column(nullable = false, unique = true) 25 | private Long id; 26 | @Column(nullable = false, unique = true) 27 | private String appId; 28 | @Column(nullable = false) 29 | private String appSecret; 30 | @Column(nullable = false) 31 | private String comment; 32 | @Column(nullable = false) 33 | private OffsetDateTime createdAt; 34 | @JoinColumn(nullable = false, name = "user") 35 | @ManyToOne(fetch = FetchType.LAZY) 36 | private User user; 37 | @Column() 38 | private OffsetDateTime bannedAt; 39 | @Column 40 | private String bannedReason; 41 | @Column 42 | private OffsetDateTime deletedAt; 43 | @Column(nullable = false) 44 | //@Version 45 | private long version = 0; 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/controller/MVCAdviceHandler.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.controller; 2 | 3 | import cn.dev33.satoken.exception.NotLoginException; 4 | import cn.dev33.satoken.stp.StpUtil; 5 | import com.ghostchu.btn.sparkle.module.user.UserDto; 6 | import com.ghostchu.btn.sparkle.module.user.UserService; 7 | import jakarta.servlet.http.HttpServletRequest; 8 | import org.springframework.web.bind.annotation.ControllerAdvice; 9 | import org.springframework.web.bind.annotation.ModelAttribute; 10 | 11 | import java.time.OffsetDateTime; 12 | 13 | @ControllerAdvice 14 | public class MVCAdviceHandler { 15 | private final UserService userService; 16 | 17 | public MVCAdviceHandler(UserService userService) { 18 | this.userService = userService; 19 | } 20 | 21 | @ModelAttribute("user") 22 | public UserDto addUserToModel(HttpServletRequest request) { 23 | if (!StpUtil.isLogin()) return null; 24 | try { 25 | var optional = userService.getUser((StpUtil.getLoginIdAsLong())); 26 | // 同时更新最后访问时间 27 | if (optional.isPresent()) { 28 | var user = optional.get(); 29 | // If over 15 minutes 30 | if (user.getLastSeenAt().plusMinutes(15).isBefore(OffsetDateTime.now())) { 31 | user.setLastSeenAt(OffsetDateTime.now()); 32 | userService.saveUser(user); 33 | } 34 | } 35 | return optional.map(userService::toDto).orElse(null); 36 | } catch (NotLoginException e) { 37 | return null; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/clientdiscovery/internal/ClientDiscoveryRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.clientdiscovery.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import org.springframework.data.domain.Page; 5 | import org.springframework.data.domain.Pageable; 6 | import org.springframework.data.jpa.repository.Modifying; 7 | import org.springframework.data.jpa.repository.Query; 8 | import org.springframework.data.repository.query.Param; 9 | import org.springframework.stereotype.Repository; 10 | import org.springframework.transaction.annotation.Propagation; 11 | import org.springframework.transaction.annotation.Transactional; 12 | 13 | import java.time.OffsetDateTime; 14 | 15 | @Repository 16 | public interface ClientDiscoveryRepository extends SparkleCommonRepository { 17 | Page findByOrderByFoundAtDesc(Pageable pageable); 18 | 19 | long countByFoundAtBetween(OffsetDateTime foundAtStart, OffsetDateTime foundAtEnd); 20 | 21 | long deleteAllByClientNameLike(String clientName); 22 | 23 | @Modifying 24 | @Transactional(propagation = Propagation.REQUIRED) 25 | @Query(value = "INSERT into clientdiscovery (hash, client_name, peer_id, found_at) " + 26 | "values (:hash, :clientName, :peerId, :foundAt) " + 27 | "on conflict (hash) do nothing", nativeQuery = true) 28 | void saveIgnoreConflict( 29 | @Param("hash") Long hash, 30 | @Param("clientName") String clientName, 31 | @Param("peerId") String peerId, 32 | @Param("foundAt") OffsetDateTime foundAt 33 | ); 34 | 35 | } -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/config/SpringRedisConfig.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.config; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.context.annotation.Primary; 8 | import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; 9 | import org.springframework.data.redis.core.RedisTemplate; 10 | import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; 11 | import org.springframework.data.redis.serializer.StringRedisSerializer; 12 | 13 | @Configuration 14 | public class SpringRedisConfig { 15 | // @Bean 16 | // public LettuceConnectionFactory redisConnectionFactory(@Value("${sparkle.redis.unixsocket}") String redisSocket) { 17 | // return new LettuceConnectionFactory(new RedisSocketConfiguration(redisSocket)); 18 | // } 19 | 20 | 21 | @Bean 22 | @Primary 23 | public RedisTemplate redisTemplate(LettuceConnectionFactory redisConnectionFactory, ObjectMapper mapper) { 24 | RedisTemplate redisTemplate = new RedisTemplate<>(); 25 | redisTemplate.setConnectionFactory(redisConnectionFactory); 26 | redisTemplate.setKeySerializer(new StringRedisSerializer()); 27 | redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(mapper)); 28 | redisTemplate.setHashKeySerializer(new StringRedisSerializer()); 29 | redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer(mapper)); 30 | return redisTemplate; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/MsgUtil.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util; 2 | 3 | import java.text.SimpleDateFormat; 4 | import java.util.Date; 5 | 6 | public class MsgUtil { 7 | private static final SimpleDateFormat dateTimeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); 8 | private static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); 9 | public static String getNowDateTimeString(){ 10 | return dateTimeFormat.format(new Date()); 11 | } 12 | public static String getNowDateString(){ 13 | return dateFormat.format(new Date()); 14 | } 15 | 16 | /** 17 | * Replace args in raw to args 18 | * 19 | * @param raw text 20 | * @param args args 21 | * @return filled text 22 | */ 23 | public static String fillArgs(String raw, String... args) { 24 | if (raw == null || raw.isEmpty()) { 25 | return ""; 26 | } 27 | StringBuilder result = new StringBuilder(); 28 | int start = 0; 29 | int argIndex = 0; 30 | 31 | while (start < raw.length()) { 32 | int placeholderIndex = raw.indexOf("{}", start); 33 | if (placeholderIndex == -1) { 34 | result.append(raw.substring(start)); 35 | break; 36 | } 37 | result.append(raw, start, placeholderIndex); 38 | if (args != null && argIndex < args.length) { 39 | result.append(args[argIndex] != null ? args[argIndex] : ""); 40 | argIndex++; 41 | } else { 42 | result.append("{}"); 43 | } 44 | start = placeholderIndex + 2; 45 | } 46 | return result.toString(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/torrent/TorrentController.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.torrent; 2 | 3 | import com.ghostchu.btn.sparkle.controller.SparkleController; 4 | import com.ghostchu.btn.sparkle.wrapper.StdResp; 5 | import com.google.common.hash.Hashing; 6 | import jakarta.validation.Valid; 7 | import jakarta.validation.constraints.NotEmpty; 8 | import lombok.AllArgsConstructor; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.RequestMapping; 13 | import org.springframework.web.bind.annotation.RequestParam; 14 | import org.springframework.web.bind.annotation.RestController; 15 | 16 | import java.nio.charset.StandardCharsets; 17 | import java.util.Locale; 18 | 19 | @RestController 20 | @RequestMapping("/api/torrent") 21 | public class TorrentController extends SparkleController { 22 | 23 | @GetMapping("/hash") 24 | public StdResp hashInfoHash(@RequestParam("hash") @NotEmpty @Valid String hash) { 25 | String torrentInfoHandled = hash.toLowerCase(Locale.ROOT); // 转小写处理 26 | String salt = Hashing.crc32().hashString(torrentInfoHandled, StandardCharsets.UTF_8).toString(); // 使用 crc32 计算 info_hash 的哈希作为盐 27 | String torrentIdentifier = Hashing.sha256().hashString(torrentInfoHandled + salt, StandardCharsets.UTF_8).toString(); // 在 info_hash 的明文后面追加盐后,计算 SHA256 的哈希值,结果应转全小写 28 | return new StdResp<>(true, null, new HashInfoHashResponse(torrentIdentifier)); 29 | } 30 | 31 | @Data 32 | @AllArgsConstructor 33 | @NoArgsConstructor 34 | public static class HashInfoHashResponse { 35 | private String torrentIdentifier; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/user/internal/User.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.user.internal; 2 | 3 | import jakarta.persistence.*; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.Setter; 8 | 9 | import java.io.Serializable; 10 | import java.time.OffsetDateTime; 11 | 12 | @Entity 13 | @Table(name = "user", 14 | uniqueConstraints = {@UniqueConstraint(columnNames = "githubUserId")}, 15 | indexes = {@Index(columnList = "githubLogin"),@Index(columnList = "githubUserId")}) 16 | @AllArgsConstructor 17 | @NoArgsConstructor 18 | @Getter 19 | @Setter 20 | public class User implements Serializable { 21 | @Id 22 | @GeneratedValue 23 | @Column(nullable = false, unique = true) 24 | private Long id; 25 | @Column(nullable = false) 26 | private String avatar; 27 | @Column(unique = true, nullable = false) 28 | private String email; 29 | @Column(nullable = false) 30 | private String nickname; 31 | @Column(nullable = false) 32 | private OffsetDateTime registerAt; 33 | @Column(nullable = false) 34 | private OffsetDateTime lastSeenAt; 35 | @Column(nullable = false) 36 | private OffsetDateTime lastAccessAt; 37 | @Column(nullable = false) 38 | private String githubLogin; 39 | @Column() 40 | private Long githubUserId; 41 | @Column() 42 | private OffsetDateTime bannedAt; 43 | @Column() 44 | private String bannedReason; 45 | @Column(nullable = false) 46 | private Integer randomGroup; 47 | //@Version 48 | @Column(nullable = false) 49 | private long version = 0; 50 | 51 | public boolean isSystemAccount() { 52 | return email.endsWith("@sparkle.system"); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/resources/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 | 7 | 10 | 11 | 15 | 16 | 20 | 21 |
22 |

Sparkle - BTN Instance

23 |

互联共通,连接你我。

24 |
25 |

连接到 BTN 网络,共享威胁情报,获取云端规则。

26 | 创建用户应用程序 27 |
28 | 29 |
30 |

统计数据 (BETA)

31 | 33 |
34 | 38 | 39 |
40 | 41 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/ByteUtil.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util; 2 | 3 | import java.util.HexFormat; 4 | 5 | public class ByteUtil { 6 | public static String bytesToHex(byte[] bytes) { 7 | return HexFormat.of().formatHex(bytes); 8 | // StringBuilder sb = new StringBuilder(); 9 | // for (byte aByte : bytes) { 10 | // String hex = Integer.toHexString(aByte & 0xFF); 11 | // if (hex.length() < 2) { 12 | // sb.append(0); 13 | // } 14 | // sb.append(hex); 15 | // } 16 | // return sb.toString(); 17 | } 18 | 19 | /** 20 | * hex字符串转byte数组 21 | * @param inHex 待转换的Hex字符串 22 | * @return 转换后的byte数组结果 23 | */ 24 | public static byte[] hexToByteArray(String inHex) { 25 | return HexFormat.of().parseHex(inHex); 26 | // int hexlen = inHex.length(); 27 | // byte[] result; 28 | // if (hexlen % 2 == 1) { 29 | // //奇数 30 | // hexlen++; 31 | // result = new byte[(hexlen / 2)]; 32 | // inHex = "0" + inHex; 33 | // } else { 34 | // //偶数 35 | // result = new byte[(hexlen / 2)]; 36 | // } 37 | // int j = 0; 38 | // for (int i = 0; i < hexlen; i += 2) { 39 | // result[j] = hexToByte(inHex.substring(i, i + 2)); 40 | // j++; 41 | // } 42 | // return result; 43 | } 44 | 45 | /** 46 | * Hex字符串转byte 47 | * @param inHex 待转换的Hex字符串 48 | * @return 转换后的byte 49 | */ 50 | public static byte hexToByte(String inHex) { 51 | return (byte) Integer.parseInt(inHex, 16); 52 | } 53 | 54 | public static String filterUTF8(String in) { 55 | if (in == null) return in; 56 | return in.replace("\u0000", ""); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/IPUtil.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util; 2 | 3 | import inet.ipaddr.IPAddress; 4 | import inet.ipaddr.IPAddressString; 5 | import lombok.extern.slf4j.Slf4j; 6 | 7 | import java.net.InetAddress; 8 | import java.net.UnknownHostException; 9 | 10 | @Slf4j 11 | public class IPUtil { 12 | public static final String INVALID_FALLBACK_ADDRESS = "127.0.0.128"; 13 | public static IPAddress toIPAddress(String ip) { 14 | var address = new IPAddressString(ip).getAddress(); 15 | if (address == null) { 16 | //log.error("Unable convert {} to IPAddressString (toIPAddress)", ip); 17 | return new IPAddressString(INVALID_FALLBACK_ADDRESS).getAddress(); 18 | } 19 | return address; 20 | } 21 | 22 | public static IPAddress toIPAddress(byte[] bytes) throws UnknownHostException { 23 | var address = InetAddress.getByAddress(bytes); 24 | return toIPAddress(address.getHostAddress()); 25 | } 26 | 27 | public static InetAddress toInet(String ip) { 28 | try { 29 | return InetAddress.getByName(ip); 30 | } catch (UnknownHostException e) { 31 | log.error("Unable convert {} to IPAddressString (toInet)", ip); 32 | return new IPAddressString(INVALID_FALLBACK_ADDRESS).getAddress().toInetAddress(); 33 | } 34 | // var address = new IPAddressString(ip).getAddress(); 35 | // if (address == null) { 36 | // log.error("Unable convert {} to IPAddressString (toInet)", ip); 37 | // return new IPAddressString(INVALID_FALLBACK_ADDRESS).getAddress().toInetAddress(); 38 | // } 39 | // return address.toInetAddress(); 40 | } 41 | 42 | public static String toString(InetAddress inet) { // 压缩一下 :0:0:0 43 | return inet.getHostAddress(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/analyse/impl/AnalysedRule.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.analyse.impl; 2 | 3 | import jakarta.persistence.*; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.Setter; 8 | import org.hibernate.proxy.HibernateProxy; 9 | 10 | import java.util.Objects; 11 | 12 | @Entity 13 | @Table(name = "analyse_rules", 14 | indexes = {@Index(columnList = "module"), @Index(columnList = "ip")} 15 | ) 16 | @AllArgsConstructor 17 | @NoArgsConstructor 18 | @Getter 19 | @Setter 20 | public class AnalysedRule { 21 | @Id 22 | @GeneratedValue 23 | @Column(nullable = false, unique = true) 24 | private Long id; 25 | @Column(nullable = false) 26 | private String ip; 27 | @Column(nullable = false) 28 | private String module; 29 | @Column(nullable = false, columnDefinition = "TEXT") 30 | private String comment; 31 | 32 | @Override 33 | public final boolean equals(Object o) { 34 | if (this == o) return true; 35 | if (o == null) return false; 36 | Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); 37 | Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); 38 | if (thisEffectiveClass != oEffectiveClass) return false; 39 | AnalysedRule that = (AnalysedRule) o; 40 | return getId() != null && Objects.equals(getId(), that.getId()); 41 | } 42 | 43 | @Override 44 | public final int hashCode() { 45 | return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/user/UserController.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.user; 2 | 3 | import cn.dev33.satoken.annotation.SaCheckLogin; 4 | import cn.dev33.satoken.annotation.SaCheckPermission; 5 | import cn.dev33.satoken.stp.StpUtil; 6 | import com.ghostchu.btn.sparkle.controller.SparkleController; 7 | import com.ghostchu.btn.sparkle.exception.UserNotFoundException; 8 | import com.ghostchu.btn.sparkle.wrapper.StdResp; 9 | import org.springframework.http.ResponseEntity; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.PathVariable; 12 | import org.springframework.web.bind.annotation.RequestMapping; 13 | import org.springframework.web.bind.annotation.RestController; 14 | 15 | @RestController 16 | @SaCheckLogin 17 | @RequestMapping("/api") 18 | public class UserController extends SparkleController { 19 | private final UserService userService; 20 | 21 | public UserController(UserService userService) { 22 | this.userService = userService; 23 | } 24 | 25 | @GetMapping("/user/me") 26 | public StdResp me() { 27 | return new StdResp<>(true, null, userService.toDto(userService.getUser((StpUtil.getLoginIdAsLong())).get())); 28 | } 29 | 30 | @SaCheckPermission("user:read.other") 31 | @GetMapping("/user/{id}") 32 | public StdResp other(@PathVariable("id") Long id) throws UserNotFoundException { 33 | var usrOptional = userService.getUser(id); 34 | if(usrOptional.isEmpty()){ 35 | throw new UserNotFoundException(); 36 | } 37 | return new StdResp<>(true, null, userService.toDto(usrOptional.get())); 38 | } 39 | 40 | @GetMapping("/user/logout") 41 | public ResponseEntity logout() { 42 | StpUtil.logout(); 43 | return ResponseEntity.status(200).build(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/dto/BtnPeer.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import jakarta.annotation.Nullable; 5 | import jakarta.validation.constraints.NotEmpty; 6 | import jakarta.validation.constraints.NotNull; 7 | import jakarta.validation.constraints.PositiveOrZero; 8 | import jakarta.validation.constraints.Size; 9 | import lombok.AllArgsConstructor; 10 | import lombok.Data; 11 | import lombok.NoArgsConstructor; 12 | 13 | import java.io.Serializable; 14 | 15 | @Data 16 | @AllArgsConstructor 17 | @NoArgsConstructor 18 | public class BtnPeer implements Serializable { 19 | 20 | @NotNull 21 | @NotEmpty 22 | @JsonProperty("ip_address") 23 | private String ipAddress; 24 | 25 | @NotNull 26 | @PositiveOrZero 27 | @JsonProperty("peer_port") 28 | private int peerPort; 29 | 30 | @NotNull 31 | @JsonProperty("peer_id") 32 | private String peerId; 33 | 34 | @NotNull 35 | @JsonProperty("client_name") 36 | private String clientName; 37 | 38 | @NotNull 39 | @NotEmpty 40 | @Size(min = 64, max = 64) // 固定长度 41 | @JsonProperty("torrent_identifier") 42 | private String torrentIdentifier; 43 | 44 | @NotNull 45 | @PositiveOrZero 46 | @JsonProperty("torrent_size") 47 | private long torrentSize; 48 | 49 | @Nullable 50 | @JsonProperty("torrent_is_private") 51 | private Boolean privateTorrent; 52 | 53 | @NotNull 54 | @JsonProperty("downloaded") 55 | private long downloaded; 56 | 57 | @NotNull 58 | @JsonProperty("rt_download_speed") 59 | private long rtDownloadSpeed; 60 | 61 | @NotNull 62 | @JsonProperty("uploaded") 63 | private long uploaded; 64 | 65 | @NotNull 66 | @JsonProperty("rt_upload_speed") 67 | private long rtUploadSpeed; 68 | 69 | @NotNull 70 | @JsonProperty("peer_progress") 71 | private double peerProgress; 72 | 73 | @NotNull 74 | @JsonProperty("downloader_progress") 75 | private double downloaderProgress; 76 | 77 | @NotNull 78 | @JsonProperty("peer_flag") 79 | private String peerFlag; 80 | 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/main/resources/templates/modules/userapp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 | 7 | 8 |
9 |

用户应用程序

10 | 创建新用户应用程序 11 |
12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 35 | 38 | 42 | 44 | 45 |
IDAppId备注创建时间最近访问状态操作
000000000000000测试 33 | 测试 34 | 36 | 测试 37 | 39 | 41 | 删除 重置 AppSecret
46 |
47 |
48 | 49 | -------------------------------------------------------------------------------- /.github/workflows/jvm-ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Java CI (push) 10 | 11 | on: 12 | push: 13 | workflow_dispatch: 14 | jobs: 15 | CI: 16 | uses: ./.github/workflows/build_maven.yml 17 | secrets: inherit 18 | Build_Docker: 19 | needs: CI 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/download-artifact@v4 24 | with: 25 | name: maven-dist 26 | path: target 27 | - name: Set Up QEMU 28 | uses: docker/setup-qemu-action@v3 29 | - name: Set Up Buildx 30 | uses: docker/setup-buildx-action@v3 31 | - name: Log in to Docker Hub 32 | uses: docker/login-action@v3 33 | with: 34 | username: ${{ secrets.DOCKER_USERNAME }} 35 | password: ${{ secrets.DOCKER_TOKEN }} 36 | - name: Extract metadata (tags, labels) for Docker 37 | id: meta 38 | uses: docker/metadata-action@v5.5.1 39 | with: 40 | images: ghostchu/sparkle-snapshot 41 | tags: | 42 | type=ref,event=branch 43 | type=ref,event=tag 44 | type=ref,event=pr 45 | type=semver,pattern={{version}} 46 | type=semver,pattern={{major}}.{{minor}} 47 | type=raw,ci-jvm-universal 48 | type=raw,ci 49 | type=sha 50 | - name: Build and push Docker image 51 | uses: docker/build-push-action@v6.9.0 52 | with: 53 | context: . 54 | file: ./Dockerfile 55 | push: true 56 | platforms: | 57 | linux/amd64 58 | linux/arm64/v8 59 | tags: ${{ steps.meta.outputs.tags }} 60 | labels: ${{ steps.meta.outputs.labels }}-jvm-universal 61 | cache-from: type=gha 62 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/peerhistory/internal/PeerHistory.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.peerhistory.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.torrent.internal.Torrent; 4 | import com.ghostchu.btn.sparkle.module.userapp.internal.UserApplication; 5 | import com.ghostchu.btn.sparkle.util.ipdb.IPGeoData; 6 | import io.hypersistence.utils.hibernate.type.json.JsonType; 7 | import jakarta.persistence.*; 8 | import lombok.*; 9 | import org.hibernate.annotations.Type; 10 | 11 | import java.net.InetAddress; 12 | import java.time.OffsetDateTime; 13 | 14 | @Entity 15 | @Table(name = "peer_history", 16 | indexes = {@Index(columnList = "lastTimeSeen DESC, torrent, peerIp, userApplication, toPeerTraffic")}) 17 | @AllArgsConstructor 18 | @NoArgsConstructor 19 | @Getter 20 | @Setter 21 | @Builder 22 | public class PeerHistory { 23 | @Id 24 | @GeneratedValue 25 | @Column(nullable = false, unique = true) 26 | private Long id; 27 | @Column(nullable = false) 28 | private OffsetDateTime insertTime; 29 | @Column(nullable = false) 30 | private OffsetDateTime populateTime; 31 | @JoinColumn(name = "userApplication") 32 | @ManyToOne(fetch = FetchType.LAZY) 33 | private UserApplication userApplication; 34 | @Column(nullable = false) 35 | private String submitId; 36 | @Column(nullable = false) 37 | private InetAddress peerIp; 38 | @Column 39 | private String peerId; 40 | @Column 41 | private String peerClientName; 42 | @JoinColumn(name = "torrent") 43 | @ManyToOne(fetch = FetchType.LAZY) 44 | private Torrent torrent; 45 | @Column(nullable = false) 46 | private Long fromPeerTraffic; 47 | @Column(nullable = false) 48 | private Long fromPeerTrafficOffset; 49 | @Column(nullable = false) 50 | private Long toPeerTraffic; 51 | @Column(nullable = false) 52 | private Long toPeerTrafficOffset; 53 | @Column(nullable = false) 54 | private OffsetDateTime firstTimeSeen; 55 | @Column(nullable = false) 56 | private OffsetDateTime lastTimeSeen; 57 | private String flags; 58 | @Column(nullable = false) 59 | private InetAddress submitterIp; 60 | @Column(columnDefinition = "jsonb") 61 | @Type(JsonType.class) 62 | private IPGeoData geoIP; 63 | // @Column( columnDefinition = "jsonb") 64 | // @Type(JsonType.class) 65 | // private IPGeoData geoIP; 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/userscore/UserScoreService.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.userscore; 2 | 3 | import com.ghostchu.btn.sparkle.module.user.internal.User; 4 | import com.ghostchu.btn.sparkle.module.userscore.internal.UserScore; 5 | import com.ghostchu.btn.sparkle.module.userscore.internal.UserScoreHistory; 6 | import com.ghostchu.btn.sparkle.module.userscore.internal.UserScoreHistoryRepository; 7 | import com.ghostchu.btn.sparkle.module.userscore.internal.UserScoreRepository; 8 | import jakarta.persistence.LockModeType; 9 | import org.springframework.dao.OptimisticLockingFailureException; 10 | import org.springframework.data.jpa.repository.Lock; 11 | import org.springframework.orm.ObjectOptimisticLockingFailureException; 12 | import org.springframework.retry.annotation.Backoff; 13 | import org.springframework.retry.annotation.Retryable; 14 | import org.springframework.stereotype.Service; 15 | import org.springframework.transaction.annotation.Transactional; 16 | 17 | import java.time.OffsetDateTime; 18 | 19 | @Service 20 | public class UserScoreService { 21 | private final UserScoreRepository userScoreRepository; 22 | private final UserScoreHistoryRepository userScoreHistoryRepository; 23 | 24 | public UserScoreService(UserScoreRepository userScoreRepository, UserScoreHistoryRepository userScoreHistoryRepository) { 25 | this.userScoreRepository = userScoreRepository; 26 | this.userScoreHistoryRepository = userScoreHistoryRepository; 27 | } 28 | 29 | public long getUserScoreBytes(User user) { 30 | UserScore userScore = userScoreRepository.findByUser(user); 31 | if (userScore != null) { 32 | return userScore.getScoreBytes(); 33 | } else { 34 | return 0L; 35 | } 36 | } 37 | 38 | @Retryable(retryFor = {ObjectOptimisticLockingFailureException.class, OptimisticLockingFailureException.class}, backoff = @Backoff(delay = 100, multiplier = 2)) 39 | @Transactional 40 | @Lock(value = LockModeType.PESSIMISTIC_WRITE) 41 | public void addUserScoreBytes(User user, long changes, String reason) { 42 | UserScore userScore = userScoreRepository.findByUser(user); 43 | if (userScore != null) { 44 | userScore.setScoreBytes(userScore.getScoreBytes() + changes); 45 | } else { 46 | userScore = new UserScore(null, user, changes, 0L); 47 | } 48 | userScoreRepository.save(userScore); 49 | userScoreHistoryRepository.save(new UserScoreHistory(null, OffsetDateTime.now(), user, changes, userScore.getScoreBytes(), reason)); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/IPMerger.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util; 2 | 3 | import inet.ipaddr.IPAddress; 4 | import inet.ipaddr.format.util.DualIPv4v6Tries; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.util.ArrayList; 10 | import java.util.Collection; 11 | import java.util.List; 12 | import java.util.Map; 13 | import java.util.stream.Collectors; 14 | 15 | @Component 16 | @Slf4j 17 | public class IPMerger { 18 | @Value("${util.ipmerger.merge-threshold.ipv4}") 19 | private int MERGE_TO_CIDR_AMOUNT_IPV4 = 4; 20 | @Value("${util.ipmerger.merge-threshold.ipv6}") 21 | private int MERGE_TO_CIDR_AMOUNT_IPV6 = 4; 22 | @Value("${util.ipmerger.prefix-length.ipv6}") 23 | private int IPV6_PREFIX_LENGTH = 56; 24 | @Value("${util.ipmerger.prefix-length.ipv4}") 25 | private int IPV4_PREFIX_LENGTH = 24; 26 | 27 | public List merge(DualIPv4v6Tries tries) { 28 | List list = new ArrayList<>(); 29 | mergeIP(tries.getIPv4Trie().asSet(), IPV4_PREFIX_LENGTH, MERGE_TO_CIDR_AMOUNT_IPV4).forEach(ip -> list.add(ip.toString())); 30 | mergeIP(tries.getIPv6Trie().asSet(), IPV6_PREFIX_LENGTH, MERGE_TO_CIDR_AMOUNT_IPV6).forEach(ip -> list.add(ip.toString())); 31 | return list; 32 | } 33 | 34 | public List mergeIP(Collection ips, int prefixLength, int mergeThreshold) { 35 | // ips 去重 36 | ips = ips.stream().distinct().collect(Collectors.toList()); 37 | List merged = new ArrayList<>(); 38 | // 先从 ips 中将比 IPV4_PREFIX_LENGTH 小的 IP 地址过滤出来并从集合中移除 39 | List lessThanPrefixLength = ips.stream().filter(ip -> ip.getNetworkPrefixLength() != null && ip.getNetworkPrefixLength() < prefixLength).toList(); 40 | ips.removeAll(lessThanPrefixLength); 41 | // 现在开始检查剩下的 IP 地址,如果有某个 IP 所在的 IPV4_PREFIX_LENGTH 网段中的 IP 数量大于 MERGE_TO_CIDR_AMOUNT_IPV4,则合并 42 | Map> ipMap = ips.stream().collect(Collectors.groupingBy(ip -> ip.toPrefixBlock(prefixLength))); 43 | ipMap.forEach((prefix, ipList) -> { 44 | if (ipList.size() >= mergeThreshold) { 45 | merged.add(prefix); 46 | } else { 47 | merged.addAll(ipList); 48 | } 49 | }); 50 | // 如果结果集中包含可被 lessThanPrefixLength 中的 CIDR contains 的地址,这些就从最终结果中排除 51 | merged.removeIf(ip -> lessThanPrefixLength.stream().anyMatch(less -> less.contains(ip))); 52 | merged.addAll(lessThanPrefixLength); 53 | return merged; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/banhistory/internal/BanHistoryRepository.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.banhistory.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.repository.SparkleCommonRepository; 4 | import jakarta.transaction.Transactional; 5 | import org.springframework.data.domain.Page; 6 | import org.springframework.data.domain.Pageable; 7 | import org.springframework.data.jpa.repository.Query; 8 | import org.springframework.stereotype.Repository; 9 | 10 | import java.net.InetAddress; 11 | import java.time.OffsetDateTime; 12 | import java.util.List; 13 | 14 | @Repository 15 | public interface BanHistoryRepository extends SparkleCommonRepository { 16 | @Query(""" 17 | SELECT DISTINCT ban.peerIp AS peerIp, COUNT(DISTINCT ban.userApplication) AS count 18 | FROM BanHistory ban 19 | WHERE 20 | ban.insertTime >= ?1 AND ban.insertTime <= ?2 AND ban.module = 'com.ghostchu.peerbanhelper.module.impl.rule.ProgressCheatBlocker' 21 | AND ban.userApplication.bannedAt IS NULL AND ban.userApplication.user.bannedAt IS NULL 22 | GROUP BY ban.peerIp 23 | HAVING COUNT(DISTINCT ban.userApplication) >= ?3 24 | """) 25 | @Transactional 26 | List generateUntrustedIPAddresses(OffsetDateTime from, OffsetDateTime to, int threshold); 27 | 28 | 29 | // @Query(""" 30 | // SELECT DISTINCT ban.peerIp FROM BanHistory ban 31 | // WHERE 32 | // family(ban.peerIp) = ?3 33 | // AND ban.insertTime >= ?1 AND ban.insertTime <= ?2 34 | // AND ban.module LIKE '%ProgressCheatBlocker%' 35 | // GROUP BY ban.peerIp 36 | // """) 37 | // @Transactional 38 | // List findByInsertTimeBetweenOrderByInsertTimeDescIPVx(OffsetDateTime from, OffsetDateTime to, int family); 39 | // 40 | Page findByOrderByInsertTimeDesc(Pageable pageable); 41 | 42 | long countByInsertTimeBetween(OffsetDateTime insertTimeStart, OffsetDateTime insertTimeEnd); 43 | 44 | 45 | List findDistinctByInsertTimeBetweenAndPeerClientNameLike(OffsetDateTime from, OffsetDateTime to, String peerClientName); 46 | 47 | List findDistinctByInsertTimeBetweenAndModuleAndPeerClientNameLike(OffsetDateTime from, OffsetDateTime to, String module, String peerClientName); 48 | 49 | Page findByPeerIpAndTorrent_IdentifierAndInsertTimeGreaterThanEqualOrderByInsertTimeDesc(InetAddress peerIp, String torrentIdentifier, OffsetDateTime insertTime, Pageable pageable); 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/snapshot/internal/Snapshot.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.snapshot.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.torrent.internal.Torrent; 4 | import com.ghostchu.btn.sparkle.module.userapp.internal.UserApplication; 5 | import com.ghostchu.btn.sparkle.util.ipdb.IPGeoData; 6 | import io.hypersistence.utils.hibernate.type.json.JsonType; 7 | import jakarta.persistence.*; 8 | import lombok.*; 9 | import org.hibernate.annotations.Type; 10 | 11 | import java.net.InetAddress; 12 | import java.time.OffsetDateTime; 13 | 14 | @Entity 15 | @Table(name = "snapshot", 16 | indexes = {@Index(columnList = "insertTime DESC") 17 | , @Index(columnList = "userApplication") 18 | , @Index(columnList = "peerId") 19 | , @Index(columnList = "peerClientName") 20 | , @Index(columnList = "torrent") 21 | , @Index(columnList = "peerIp") 22 | , @Index(columnList = "torrent, peerIp, userApplication, insertTime DESC")}) 23 | @AllArgsConstructor 24 | @NoArgsConstructor 25 | @Getter 26 | @Setter 27 | @Builder 28 | public class Snapshot { 29 | @Id 30 | @GeneratedValue 31 | @Column(nullable = false, unique = true) 32 | private Long id; 33 | @Column(nullable = false) 34 | private OffsetDateTime insertTime; 35 | @Column(nullable = false) 36 | private OffsetDateTime populateTime; 37 | @JoinColumn(name = "userApplication") 38 | @ManyToOne(fetch = FetchType.LAZY) 39 | private UserApplication userApplication; 40 | @Column(nullable = false) 41 | private String submitId; 42 | @Column(nullable = false) 43 | private InetAddress peerIp; 44 | @Column(nullable = false) 45 | private Integer peerPort; 46 | @Column 47 | private String peerId; 48 | @Column 49 | private String peerClientName; 50 | @JoinColumn(name = "torrent") 51 | @ManyToOne(fetch = FetchType.LAZY) 52 | private Torrent torrent; 53 | @Column(nullable = false) 54 | private Long fromPeerTraffic; 55 | @Column(nullable = false) 56 | private Long fromPeerTrafficSpeed; 57 | @Column(nullable = false) 58 | private Long toPeerTraffic; 59 | @Column(nullable = false) 60 | private Long toPeerTrafficSpeed; 61 | @Column(nullable = false) 62 | private Double peerProgress; 63 | @Column(nullable = false) 64 | private Double downloaderProgress; 65 | @Column 66 | private String flags; 67 | @Column(nullable = false) 68 | private InetAddress submitterIp; 69 | @Column(columnDefinition = "jsonb") 70 | @Type(JsonType.class) 71 | private IPGeoData geoIP; 72 | // @Column( columnDefinition = "jsonb") 73 | // @Type(JsonType.class) 74 | // private IPGeoData geoIP; 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/torrent/TorrentService.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.torrent; 2 | 3 | import com.ghostchu.btn.sparkle.module.torrent.internal.Torrent; 4 | import com.ghostchu.btn.sparkle.module.torrent.internal.TorrentRepository; 5 | import com.google.common.cache.Cache; 6 | import com.google.common.cache.CacheBuilder; 7 | import org.springframework.data.jpa.repository.Modifying; 8 | import org.springframework.stereotype.Service; 9 | import org.springframework.transaction.annotation.Propagation; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | import java.util.concurrent.TimeUnit; 13 | 14 | @Service 15 | public class TorrentService { 16 | private final Cache torrentCache = CacheBuilder 17 | .newBuilder() 18 | .concurrencyLevel(20) 19 | .expireAfterAccess(5, TimeUnit.MINUTES) 20 | .maximumSize(1000) 21 | .build(); 22 | private final TorrentRepository torrentRepository; 23 | 24 | public TorrentService(TorrentRepository torrentRepository) { 25 | this.torrentRepository = torrentRepository; 26 | } 27 | 28 | @Modifying 29 | @Transactional(propagation = Propagation.NOT_SUPPORTED) 30 | //@Cacheable(value = "torrent#600000", key = "#torrentIdentifier+'-'+#torrentSize") 31 | public Torrent createOrGetTorrent(String torrentIdentifier, long torrentSize, Boolean isPrivate) { 32 | var t = torrentCache.getIfPresent(torrentIdentifier + "@" + torrentSize); 33 | if (t == null) { 34 | var torrentOptional = torrentRepository.findByIdentifierAndSize(torrentIdentifier, torrentSize); 35 | if (torrentOptional.isPresent()) { 36 | t = torrentOptional.get(); 37 | } else { 38 | t = new Torrent(null, torrentIdentifier, torrentSize, isPrivate); 39 | t = torrentRepository.save(t); 40 | } 41 | } 42 | if (t.getSize() == -1 && torrentSize != -1) { 43 | t.setSize(torrentSize); 44 | t = torrentRepository.save(t); 45 | } 46 | if (t.getPrivateTorrent() == null && isPrivate != null) { 47 | t.setPrivateTorrent(isPrivate); 48 | t = torrentRepository.save(t); 49 | } 50 | torrentCache.put(torrentIdentifier + "@" + torrentSize, t); 51 | return t; 52 | } 53 | 54 | public TorrentDto toDto(Torrent torrent) { 55 | return TorrentDto.builder() 56 | .id(torrent.getId()) 57 | .identifier(torrent.getIdentifier()) 58 | .size(torrent.getSize()) 59 | .privateTorrent(torrent.getPrivateTorrent() != null && torrent.getPrivateTorrent()) 60 | .build(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/audit/AuditService.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.audit; 2 | 3 | import com.ghostchu.btn.sparkle.module.audit.impl.Audit; 4 | import com.ghostchu.btn.sparkle.module.audit.impl.AuditRepository; 5 | import com.ghostchu.btn.sparkle.util.IPUtil; 6 | import com.ghostchu.btn.sparkle.util.ServletUtil; 7 | import jakarta.servlet.http.HttpServletRequest; 8 | import jakarta.transaction.Transactional; 9 | import org.springframework.beans.factory.annotation.Value; 10 | import org.springframework.data.jpa.repository.Modifying; 11 | import org.springframework.scheduling.annotation.Scheduled; 12 | import org.springframework.stereotype.Service; 13 | import org.springframework.util.LinkedMultiValueMap; 14 | 15 | import java.time.OffsetDateTime; 16 | import java.util.ArrayList; 17 | import java.util.Deque; 18 | import java.util.List; 19 | import java.util.Map; 20 | import java.util.concurrent.ConcurrentLinkedDeque; 21 | import java.util.concurrent.TimeUnit; 22 | 23 | @Service 24 | public class AuditService { 25 | private final AuditRepository auditRepository; 26 | private final Deque auditQueue = new ConcurrentLinkedDeque<>(); 27 | @Value("${analyse.audit.enable}") 28 | private boolean useAudit; 29 | 30 | public AuditService(AuditRepository auditRepository) { 31 | this.auditRepository = auditRepository; 32 | } 33 | 34 | public void log(HttpServletRequest req, String action, boolean success, Map node) { 35 | if (!useAudit) { 36 | return; 37 | } 38 | auditQueue.add(new Audit(null, OffsetDateTime.now(), IPUtil.toInet(ServletUtil.getIP(req)), action, success, getHeaders(req), node)); 39 | } 40 | 41 | @Scheduled(fixedRate = 10, timeUnit = TimeUnit.SECONDS) 42 | @Transactional 43 | @Modifying 44 | public void updateTrackerMetrics() { 45 | flush(); 46 | } 47 | 48 | public void flush() { 49 | List toWrite = new ArrayList<>(); 50 | while (!auditQueue.isEmpty()) { 51 | toWrite.add(auditQueue.poll()); 52 | } 53 | auditRepository.saveAll(toWrite); 54 | } 55 | 56 | public Map> getHeaders(HttpServletRequest req) { 57 | Map> map = new LinkedMultiValueMap<>(); 58 | req.getHeaderNames().asIterator().forEachRemaining(key -> { 59 | if (key.equalsIgnoreCase("X-BTN-AppSecret")) return; 60 | if (key.equalsIgnoreCase("BTN-AppSecret")) return; 61 | if (key.equalsIgnoreCase("Authorization")) return; 62 | List list = new ArrayList<>(); 63 | req.getHeaders(key).asIterator().forEachRemaining(list::add); 64 | map.put(key, list); 65 | }); 66 | return map; 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/rule/RuleService.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.rule; 2 | 3 | import com.ghostchu.btn.sparkle.module.rule.internal.Rule; 4 | import com.ghostchu.btn.sparkle.module.rule.internal.RuleRepository; 5 | import com.ghostchu.btn.sparkle.util.TimeUtil; 6 | import org.springframework.cache.annotation.Cacheable; 7 | import org.springframework.stereotype.Service; 8 | 9 | import java.time.OffsetDateTime; 10 | import java.util.ArrayList; 11 | import java.util.List; 12 | 13 | @Service 14 | public class RuleService { 15 | private final RuleRepository ruleRepository; 16 | 17 | public RuleService(RuleRepository ruleRepository) { 18 | this.ruleRepository = ruleRepository; 19 | } 20 | 21 | /** 22 | * 获取所有仍在有效期内的规则列表 23 | * 24 | * @return 仍然处于有效期内的规则列表 25 | */ 26 | @Cacheable({"unexpiredRules#600000"}) 27 | public List getUnexpiredRules() { 28 | return ruleRepository.findByExpiredAtGreaterThan(OffsetDateTime.now()).stream().map(this::toDto).toList(); 29 | } 30 | 31 | /** 32 | * 获取所有规则,包括已过期规则 33 | * 34 | * @return 所有规则 35 | */ 36 | public List getAllRules() { 37 | List ruleDtos = new ArrayList<>(); 38 | for (Rule rule : ruleRepository.findAll()) { 39 | ruleDtos.add(toDto(rule)); 40 | } 41 | return ruleDtos; 42 | } 43 | 44 | public List getRulesMatchingCategory(String category) { 45 | return ruleRepository.findByCategory(category).stream().map(this::toDto).toList(); 46 | } 47 | 48 | public List getRulesMatchingType(String type) { 49 | return ruleRepository.findByType(type).stream().map(this::toDto).toList(); 50 | } 51 | 52 | /** 53 | * 创建/保存更改 Rule 规则 54 | * 55 | * @param ruleDto 新的/更改后的 RuleDto 56 | * @return RuleDto(已填充 Id) 57 | */ 58 | public RuleDto saveRule(RuleDto ruleDto) { 59 | Rule rule = new Rule(); 60 | rule.setId(ruleDto.getId()); 61 | rule.setCategory(ruleDto.getCategory()); 62 | rule.setType(ruleDto.getType()); 63 | rule.setContent(ruleDto.getContent()); 64 | rule.setCreatedAt(TimeUtil.toUTC(ruleDto.getCreatedAt())); 65 | rule.setExpiredAt(TimeUtil.toUTC(ruleDto.getExpiredAt())); 66 | return toDto(ruleRepository.save(rule)); 67 | } 68 | 69 | public RuleDto toDto(Rule rule) { 70 | return RuleDto.builder() 71 | .id(rule.getId()) 72 | .category(rule.getCategory()) 73 | .content(rule.getContent()) 74 | .type(rule.getType()) 75 | .createdAt(rule.getCreatedAt().toEpochSecond() * 1000) 76 | .expiredAt(rule.getExpiredAt().toEpochSecond() * 1000) 77 | .build(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/PingWebSocketManager.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import jakarta.validation.constraints.NotNull; 6 | import jakarta.websocket.Session; 7 | import lombok.SneakyThrows; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.stereotype.Component; 10 | 11 | import java.io.IOException; 12 | import java.util.concurrent.*; 13 | import java.util.concurrent.atomic.AtomicLong; 14 | 15 | /** 16 | * Manager for WebSocket sessions and message broadcasting. 17 | *

18 | * This Spring-managed component handles: 19 | * - Registration and unregistration of WebSocket sessions 20 | * - Broadcasting messages to all connected clients 21 | * - Periodic logging of connection statistics 22 | *

23 | *

24 | * Thread-safety: 25 | * - Uses {@link CopyOnWriteArraySet} for thread-safe session management 26 | * - Broadcasts are handled in virtual threads for better concurrency 27 | *

28 | */ 29 | @Slf4j 30 | @Component 31 | public class PingWebSocketManager { 32 | 33 | private final CopyOnWriteArraySet webSocketServerSet = new CopyOnWriteArraySet<>(); 34 | private final AtomicLong messageId = new AtomicLong(0); 35 | private final ObjectMapper objectMapper; 36 | private final ScheduledExecutorService pingService = Executors.newScheduledThreadPool(1); 37 | 38 | public PingWebSocketManager(ObjectMapper objectMapper) { 39 | this.objectMapper = objectMapper; 40 | pingService.scheduleWithFixedDelay(this::printConnections, 0, 5, TimeUnit.MINUTES); 41 | } 42 | 43 | private void printConnections() { 44 | log.info("Connected WebSocket sessions: {}", webSocketServerSet.size()); 45 | } 46 | 47 | public void registerSession(@NotNull Session session) { 48 | webSocketServerSet.add(session); 49 | } 50 | 51 | public void unregisterSession(@NotNull Session session) { 52 | webSocketServerSet.remove(session); 53 | } 54 | 55 | @SneakyThrows(JsonProcessingException.class) 56 | public void broadcast(@NotNull Object jsonSerializable) { 57 | if (webSocketServerSet.isEmpty()) return; 58 | WebSocketStdMsg stdMsg = new WebSocketStdMsg(messageId.incrementAndGet(), jsonSerializable); 59 | String json = objectMapper.writeValueAsString(stdMsg); 60 | webSocketServerSet.forEach(session -> { 61 | if (!session.isOpen()) return; 62 | CompletableFuture.runAsync(() -> { 63 | try { 64 | session.getBasicRemote().sendText(json); 65 | } catch (IOException ignored) { 66 | } 67 | }, Executors.newVirtualThreadPerTaskExecutor()); 68 | }); 69 | } 70 | 71 | public record WebSocketStdMsg(long msgId, Object message) { 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/banhistory/internal/BanHistory.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.banhistory.internal; 2 | 3 | import com.ghostchu.btn.sparkle.module.torrent.internal.Torrent; 4 | import com.ghostchu.btn.sparkle.module.userapp.internal.UserApplication; 5 | import com.ghostchu.btn.sparkle.util.ipdb.IPGeoData; 6 | import com.ghostchu.btn.sparkle.wrapper.StructuredData; 7 | import io.hypersistence.utils.hibernate.type.json.JsonType; 8 | import jakarta.persistence.*; 9 | import lombok.*; 10 | import org.hibernate.annotations.Type; 11 | 12 | import java.net.InetAddress; 13 | import java.time.OffsetDateTime; 14 | 15 | @Entity 16 | @Table(name = "banhistory", indexes = { 17 | @Index(columnList = "insertTime, module"), 18 | @Index(columnList = "insertTime, module, peerClientName"), 19 | @Index(columnList = "insertTime, module, peerId")}) 20 | @AllArgsConstructor 21 | @NoArgsConstructor 22 | @Getter 23 | @Setter 24 | @Builder 25 | public class BanHistory { 26 | @Id 27 | @GeneratedValue 28 | @Column(nullable = false, unique = true) 29 | private Long id; 30 | @Column(nullable = false) 31 | private OffsetDateTime insertTime; 32 | @Column(nullable = false) 33 | private OffsetDateTime populateTime; 34 | @JoinColumn(name = "userApplication") 35 | @ManyToOne(fetch = FetchType.LAZY) 36 | private UserApplication userApplication; 37 | @Column(nullable = false) 38 | private String submitId; 39 | @Column(nullable = false) 40 | private InetAddress peerIp; 41 | @Column(nullable = false) 42 | private Integer peerPort; 43 | @Column 44 | private String peerId; 45 | @Column 46 | private String peerClientName; 47 | @JoinColumn(name = "torrent") 48 | @ManyToOne(fetch = FetchType.LAZY) 49 | private Torrent torrent; 50 | @Column(nullable = false) 51 | private Long fromPeerTraffic; 52 | @Column(nullable = false) 53 | private Long fromPeerTrafficSpeed; 54 | @Column(nullable = false) 55 | private Long toPeerTraffic; 56 | @Column(nullable = false) 57 | private Long toPeerTrafficSpeed; 58 | @Column(nullable = false) 59 | private Double peerProgress; 60 | @Column(nullable = false) 61 | private Double downloaderProgress; 62 | @Column 63 | private String flags; 64 | @Column(nullable = false) 65 | private InetAddress submitterIp; 66 | @Column(nullable = false) 67 | private Boolean btnBan; 68 | @Column(nullable = false) 69 | private String module; 70 | @Column(nullable = false) 71 | private String rule; 72 | @Column(nullable = false) 73 | private String banUniqueId; 74 | // @Column(columnDefinition = "json") 75 | // @Type(JsonType.class) 76 | @Column(columnDefinition = "jsonb") 77 | @Type(JsonType.class) 78 | private IPGeoData geoIP; 79 | @Column(columnDefinition = "jsonb") 80 | @Type(JsonType.class) 81 | private StructuredData structuredData; 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/main/resources/templates/components/common.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sparkle (花火) - BTN Instance 7 | 8 | 9 | 10 | 11 | 12 | 13 | 56 | 57 |
58 |

这是一个页脚,以后也许能放点东西

59 |
60 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/cleanup/CleanupService.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.cleanup; 2 | 3 | import com.ghostchu.btn.sparkle.module.audit.impl.AuditRepository; 4 | import com.ghostchu.btn.sparkle.module.clientdiscovery.internal.ClientDiscovery; 5 | import com.ghostchu.btn.sparkle.module.clientdiscovery.internal.ClientDiscoveryRepository; 6 | import com.ghostchu.btn.sparkle.module.peerhistory.internal.PeerHistoryRepository; 7 | import com.ghostchu.btn.sparkle.module.snapshot.internal.SnapshotRepository; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.data.jpa.domain.Specification; 10 | import org.springframework.scheduling.annotation.Scheduled; 11 | import org.springframework.stereotype.Service; 12 | 13 | import java.time.OffsetDateTime; 14 | 15 | @Service 16 | @Slf4j 17 | public class CleanupService { 18 | private final PeerHistoryRepository peerHistoryRepository; 19 | private final SnapshotRepository snapshotRepository; 20 | private final AuditRepository auditRepository; 21 | private final ClientDiscoveryRepository clientDiscoveryRepository; 22 | 23 | public CleanupService(PeerHistoryRepository peerHistoryRepository, SnapshotRepository snapshotRepository, AuditRepository auditRepository, ClientDiscoveryRepository clientDiscoveryRepository) { 24 | this.peerHistoryRepository = peerHistoryRepository; 25 | this.snapshotRepository = snapshotRepository; 26 | this.auditRepository = auditRepository; 27 | this.clientDiscoveryRepository = clientDiscoveryRepository; 28 | } 29 | 30 | // 每天凌晨 3 点清理 31 | @Scheduled(cron = "0 0 3 * * ?") 32 | public void cleanup() { 33 | var deletedHistories = peerHistoryRepository.deleteAllByInsertTimeBefore(OffsetDateTime.now().minusDays(14)); 34 | log.info("[清理] 删除了 14 天前的 PeerHistory 共 {} 条", deletedHistories); 35 | var deletedSnapshots = snapshotRepository.deleteAllByInsertTimeBefore(OffsetDateTime.now().minusDays(7)); 36 | log.info("[清理] 删除了 7 天前的 Snapshot 共 {} 条", deletedSnapshots); 37 | var deletedAudits = auditRepository.deleteAllByTimestampBefore(OffsetDateTime.now().minusDays(30)); 38 | log.info("[清理] 删除了 30 天前的 Audit 共 {} 条", deletedAudits); 39 | var deletedDiscoveries = clientDiscoveryRepository.delete((Specification) (root, query, criteriaBuilder) -> criteriaBuilder.or( 40 | criteriaBuilder.like(root.get("clientName"), "FDM%"), 41 | criteriaBuilder.like(root.get("clientName"), "FD6%"), 42 | criteriaBuilder.like(root.get("clientName"), "Free Download Manager%"), 43 | criteriaBuilder.like(root.get("clientName"), "Xunlei%"), 44 | criteriaBuilder.like(root.get("clientName"), "XunLei%"), 45 | criteriaBuilder.like(root.get("clientName"), "-XL00%"), 46 | criteriaBuilder.like(root.get("clientName"), "aria2/%"), 47 | criteriaBuilder.like(root.get("clientName"), "MG-%"), 48 | criteriaBuilder.like(root.get("peerId"), "FDM%"), 49 | criteriaBuilder.like(root.get("peerId"), "FD6%"), 50 | criteriaBuilder.like(root.get("peerId"), "-XL00%"), 51 | criteriaBuilder.like(root.get("peerId"), "A2-"), 52 | criteriaBuilder.like(root.get("peerId"), "MG-%") 53 | )); 54 | log.info("[清理] 删除了垃圾客户端发现共 {} 条", deletedDiscoveries); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/jvm-release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Release 10 | 11 | on: 12 | workflow_dispatch: 13 | release: 14 | types: 15 | - published 16 | jobs: 17 | Build_Executable: 18 | permissions: 19 | contents: write 20 | checks: write 21 | actions: read 22 | issues: read 23 | packages: write 24 | pull-requests: read 25 | repository-projects: read 26 | statuses: read 27 | secrets: inherit 28 | uses: ./.github/workflows/build_maven.yml 29 | Upload_Artifacts: 30 | needs: [ Build_Executable ] 31 | permissions: 32 | contents: write 33 | checks: write 34 | actions: read 35 | issues: read 36 | packages: write 37 | pull-requests: read 38 | repository-projects: read 39 | statuses: read 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/download-artifact@v4 43 | with: 44 | name: maven-dist 45 | path: target/ 46 | - uses: alexellis/upload-assets@0.4.1 47 | env: 48 | GITHUB_TOKEN: ${{ github.token }} 49 | with: 50 | asset_paths: '["target/*.jar"]' 51 | Build_Docker: 52 | permissions: 53 | contents: write 54 | checks: write 55 | actions: read 56 | issues: read 57 | packages: write 58 | pull-requests: read 59 | repository-projects: read 60 | statuses: read 61 | needs: Build_Executable 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v4 65 | - uses: actions/download-artifact@v4 66 | with: 67 | name: maven-dist 68 | path: target 69 | - name: Set Up QEMU 70 | uses: docker/setup-qemu-action@v3 71 | - name: Set Up Buildx 72 | uses: docker/setup-buildx-action@v3 73 | - name: Log in to Docker Hub 74 | uses: docker/login-action@v3 75 | with: 76 | username: ${{ secrets.DOCKER_USERNAME }} 77 | password: ${{ secrets.DOCKER_TOKEN }} 78 | - name: Extract metadata (tags, labels) for Docker 79 | id: meta 80 | uses: docker/metadata-action@v5.5.1 81 | with: 82 | images: ghostchu/peerbanhelper 83 | tags: | 84 | type=ref,event=branch 85 | type=ref,event=tag 86 | type=ref,event=pr 87 | type=semver,pattern={{version}} 88 | type=semver,pattern={{major}}.{{minor}} 89 | type=raw,latest-jvm-universal 90 | type=raw,latest 91 | type=sha 92 | - name: Build and push Docker image 93 | uses: docker/build-push-action@v6.9.0 94 | with: 95 | context: . 96 | file: ./Dockerfile 97 | push: true 98 | platforms: | 99 | linux/amd64 100 | linux/arm64/v8 101 | tags: ${{ steps.meta.outputs.tags }} 102 | labels: ${{ steps.meta.outputs.labels }}-jvm-universal 103 | cache-from: type=gha 104 | cache-to: type=gha,mode=max 105 | build-args: | 106 | GIT_HASH=${{ github.sha }} -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/clientdiscovery/ClientDiscoveryService.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.clientdiscovery; 2 | 3 | import com.ghostchu.btn.sparkle.module.clientdiscovery.internal.ClientDiscovery; 4 | import com.ghostchu.btn.sparkle.module.clientdiscovery.internal.ClientDiscoveryRepository; 5 | import com.ghostchu.btn.sparkle.util.ByteUtil; 6 | import com.ghostchu.btn.sparkle.util.paging.SparklePage; 7 | import io.micrometer.core.instrument.MeterRegistry; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.cache.annotation.Cacheable; 11 | import org.springframework.data.domain.Pageable; 12 | import org.springframework.data.jpa.domain.Specification; 13 | import org.springframework.scheduling.annotation.Async; 14 | import org.springframework.stereotype.Service; 15 | import org.springframework.transaction.annotation.Isolation; 16 | import org.springframework.transaction.annotation.Propagation; 17 | import org.springframework.transaction.annotation.Transactional; 18 | 19 | import java.io.Serializable; 20 | import java.time.OffsetDateTime; 21 | import java.util.Collection; 22 | 23 | @Service 24 | @Slf4j 25 | public class ClientDiscoveryService { 26 | private final ClientDiscoveryRepository clientDiscoveryRepository; 27 | @Autowired 28 | private MeterRegistry meterRegistry; 29 | 30 | public ClientDiscoveryService(ClientDiscoveryRepository clientDiscoveryRepository) { 31 | this.clientDiscoveryRepository = clientDiscoveryRepository; 32 | } 33 | 34 | @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_UNCOMMITTED) 35 | @Async 36 | public void handleIdentities(OffsetDateTime timeForFoundAt, OffsetDateTime timeForLastSeenAt, Collection clientIdentities) { 37 | meterRegistry.counter("sparkle_client_discovery_processed").increment(); 38 | for (ClientIdentity ci : clientIdentities) { 39 | try { 40 | clientDiscoveryRepository.saveIgnoreConflict(ci.hash(), ByteUtil.filterUTF8(ci.getClientName()), ByteUtil.filterUTF8(ci.getPeerId()), timeForFoundAt); 41 | } catch (Exception e) { 42 | log.error("Failed to save client discovery: {}:{}", e.getClass().getName(), e.getMessage()); 43 | } 44 | } 45 | } 46 | 47 | @Cacheable(value = "clientDiscoveryMetrics#1800000", key = "#from+'-'+#to") 48 | public ClientDiscoveryMetrics getMetrics(OffsetDateTime from, OffsetDateTime to) { 49 | return new ClientDiscoveryMetrics( 50 | clientDiscoveryRepository.count(), 51 | clientDiscoveryRepository.countByFoundAtBetween(from, to) 52 | ); 53 | } 54 | 55 | public ClientDiscoveryDto toDto(ClientDiscovery clientDiscovery) { 56 | return ClientDiscoveryDto.builder() 57 | .hash(clientDiscovery.getHash()) 58 | .clientName(clientDiscovery.getClientName()) 59 | .peerId(clientDiscovery.getPeerId()) 60 | .foundAt(clientDiscovery.getFoundAt()) 61 | .build(); 62 | } 63 | 64 | public SparklePage queryRecent(Pageable of) { 65 | var page = clientDiscoveryRepository.findByOrderByFoundAtDesc(of); 66 | return new SparklePage<>(page, ct -> ct.map(this::toDto)); 67 | } 68 | 69 | public SparklePage query(Specification specification, Pageable of) { 70 | var page = clientDiscoveryRepository.findAll(specification, of); 71 | return new SparklePage<>(page, ct -> ct.map(this::toDto)); 72 | } 73 | 74 | public record ClientDiscoveryMetrics( 75 | long total, 76 | long recent 77 | ) implements Serializable { 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/dto/BtnRule.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import com.ghostchu.btn.sparkle.module.rule.RuleDto; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | import java.io.Serializable; 10 | import java.util.ArrayList; 11 | import java.util.HashMap; 12 | import java.util.List; 13 | import java.util.Map; 14 | 15 | @Data 16 | @AllArgsConstructor 17 | @NoArgsConstructor 18 | public class BtnRule implements Serializable { 19 | @JsonProperty("version") 20 | private String version; 21 | @JsonProperty("peer_id") 22 | private Map> peerIdRules; 23 | @JsonProperty("peer_id_exclude") 24 | private Map> excludePeerIdRules; 25 | @JsonProperty("client_name") 26 | private Map> clientNameRules; 27 | @JsonProperty("client_name_exclude") 28 | private Map> excludeClientNameRules; 29 | @JsonProperty("ip") 30 | private Map> ipRules; 31 | @JsonProperty("port") 32 | private Map> portRules; 33 | @JsonProperty("script") 34 | private Map scriptRules; 35 | 36 | public BtnRule(List list) { 37 | this.ipRules = new HashMap<>(); 38 | this.excludePeerIdRules = new HashMap<>(); 39 | this.excludeClientNameRules = new HashMap<>(); 40 | this.portRules = new HashMap<>(); 41 | this.peerIdRules = new HashMap<>(); 42 | this.clientNameRules = new HashMap<>(); 43 | this.scriptRules = new HashMap<>(); 44 | for (RuleDto ruleEntityDto : list) { 45 | switch (ruleEntityDto.getType()) { 46 | case "ip" -> { 47 | List cat = ipRules.getOrDefault(ruleEntityDto.getCategory(), new ArrayList<>()); 48 | cat.add(ruleEntityDto.getContent()); 49 | ipRules.put(ruleEntityDto.getCategory(), cat); 50 | } 51 | case "port" -> { 52 | List cat = portRules.getOrDefault(ruleEntityDto.getCategory(), new ArrayList<>()); 53 | cat.add(Integer.parseInt(ruleEntityDto.getContent())); 54 | portRules.put(ruleEntityDto.getCategory(), cat); 55 | } 56 | case "client_name" -> { 57 | List cat = clientNameRules.getOrDefault(ruleEntityDto.getCategory(), new ArrayList<>()); 58 | cat.add(ruleEntityDto.getContent()); 59 | clientNameRules.put(ruleEntityDto.getCategory(), cat); 60 | } 61 | case "peer_id" -> { 62 | List cat = peerIdRules.getOrDefault(ruleEntityDto.getCategory(), new ArrayList<>()); 63 | cat.add(ruleEntityDto.getContent()); 64 | peerIdRules.put(ruleEntityDto.getCategory(), cat); 65 | } 66 | case "client_name_exclude" -> { 67 | List cat = excludeClientNameRules.getOrDefault(ruleEntityDto.getCategory(), new ArrayList<>()); 68 | cat.add(ruleEntityDto.getContent()); 69 | clientNameRules.put(ruleEntityDto.getCategory(), cat); 70 | } 71 | case "exclude_peer_id" -> { 72 | List cat = excludePeerIdRules.getOrDefault(ruleEntityDto.getCategory(), new ArrayList<>()); 73 | cat.add(ruleEntityDto.getContent()); 74 | excludePeerIdRules.put(ruleEntityDto.getCategory(), cat); 75 | } 76 | case "script" -> scriptRules.put(ruleEntityDto.getCategory(), ruleEntityDto.getContent()); 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/util/ServletUtil.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.util; 2 | 3 | import com.ghostchu.btn.sparkle.module.ping.ClientAuthenticationCredential; 4 | import jakarta.servlet.http.HttpServletRequest; 5 | 6 | public class ServletUtil { 7 | 8 | public static String getIP(HttpServletRequest request) { 9 | String ip = request.getHeader("X-Rewrite-Peer-IP"); 10 | if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { 11 | ip = request.getHeader("CF-Connecting-IP"); 12 | } 13 | if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { 14 | ip = request.getHeader("X-Real-IP"); 15 | } 16 | if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { 17 | ip = request.getHeader("X-Forwarded-For"); 18 | } 19 | if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { 20 | ip = request.getHeader("Proxy-Client-IP"); 21 | } 22 | if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { 23 | ip = request.getHeader("WL-Proxy-Client-IP"); 24 | } 25 | if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { 26 | ip = request.getRemoteAddr(); 27 | } 28 | // XFF 可能是多个 IP 的列,获取最原始的用户 IP 29 | if (ip != null && ip.contains(",")) { 30 | ip = ip.split(",")[0].trim(); 31 | } 32 | return ip; 33 | } 34 | 35 | public static ClientAuthenticationCredential getAuthenticationCredential(HttpServletRequest request) { 36 | ClientAuthenticationCredential cred = readModernFromAuthentication(request); 37 | if (cred.isValid()) { 38 | return cred; 39 | } 40 | cred = readOldModernFromAuthentication(request); // 显然,BUG 变成了特性 41 | if (cred.isValid()) { 42 | return cred; 43 | } 44 | cred = readModernFromHeader(request); 45 | if (cred.isValid()) { 46 | return cred; 47 | } 48 | cred = readLegacy(request); 49 | return cred; 50 | } 51 | 52 | 53 | private static ClientAuthenticationCredential readOldModernFromAuthentication(HttpServletRequest request) { 54 | String header = request.getHeader("Authentication"); 55 | if (header == null) { 56 | return new ClientAuthenticationCredential(null, null); 57 | } 58 | header = header.substring(7); 59 | String[] parser = header.split("@", 2); 60 | if (parser.length == 2) { 61 | return new ClientAuthenticationCredential(parser[0], parser[1]); 62 | } 63 | return new ClientAuthenticationCredential(null, null); 64 | } 65 | 66 | private static ClientAuthenticationCredential readModernFromAuthentication(HttpServletRequest request) { 67 | String header = request.getHeader("Authorization"); 68 | if (header == null) { 69 | return new ClientAuthenticationCredential(null, null); 70 | } 71 | header = header.substring(7); 72 | String[] parser = header.split("@", 2); 73 | if (parser.length == 2) { 74 | return new ClientAuthenticationCredential(parser[0], parser[1]); 75 | } 76 | return new ClientAuthenticationCredential(null, null); 77 | } 78 | 79 | private static ClientAuthenticationCredential readModernFromHeader(HttpServletRequest request) { 80 | String appId = request.getHeader("X-BTN-AppID"); 81 | String appSecret = request.getHeader("X-BTN-AppSecret"); 82 | return new ClientAuthenticationCredential(appId, appSecret); 83 | } 84 | 85 | private static ClientAuthenticationCredential readLegacy(HttpServletRequest request) { 86 | String appId = request.getHeader("BTN-AppID"); 87 | String appSecret = request.getHeader("BTN-AppSecret"); 88 | return new ClientAuthenticationCredential(appId, appSecret); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/IndexController.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module; 2 | 3 | import cn.dev33.satoken.stp.StpUtil; 4 | import com.ghostchu.btn.sparkle.controller.SparkleController; 5 | import com.ghostchu.btn.sparkle.module.banhistory.BanHistoryService; 6 | import com.ghostchu.btn.sparkle.module.clientdiscovery.ClientDiscoveryService; 7 | import com.ghostchu.btn.sparkle.module.snapshot.SnapshotService; 8 | import com.ghostchu.btn.sparkle.module.user.UserService; 9 | import com.ghostchu.btn.sparkle.module.user.internal.UserRepository; 10 | import org.springframework.stereotype.Controller; 11 | import org.springframework.ui.Model; 12 | import org.springframework.web.bind.annotation.GetMapping; 13 | 14 | import java.io.Serializable; 15 | import java.time.OffsetDateTime; 16 | 17 | @Controller 18 | public class IndexController extends SparkleController { 19 | private final BanHistoryService banHistoryService; 20 | private final SnapshotService snapshotService; 21 | private final ClientDiscoveryService clientDiscoveryService; 22 | private final UserService userService; 23 | 24 | public IndexController(BanHistoryService banHistoryService, SnapshotService snapshotService, ClientDiscoveryService clientDiscoveryService, UserService userService, UserRepository userRepository) { 25 | super(); 26 | this.banHistoryService = banHistoryService; 27 | this.snapshotService = snapshotService; 28 | this.clientDiscoveryService = clientDiscoveryService; 29 | this.userService = userService; 30 | } 31 | 32 | @GetMapping("/healthcheck") 33 | public String healthCheck() { 34 | return "OK"; 35 | } 36 | 37 | @GetMapping("/") 38 | public String index(Model model) { 39 | if (!StpUtil.isLogin()) { 40 | return "redirect:/auth/oauth2/github/login"; 41 | } 42 | // Timestamp daysAgo = new Timestamp(LocalDateTime.now().minusDays(14).atOffset(ZoneOffset.UTC).toInstant().toEpochMilli()); 43 | // Timestamp timeNow = new Timestamp(System.currentTimeMillis()); 44 | // model.addAttribute("btnMetrics", btnMetrics(daysAgo, timeNow)); 45 | // var trackerMetrics = new IndexTrackerMetrics( 46 | // trackedPeerRepository.countTrackingTorrents(), 47 | // trackedPeerRepository.count(), 48 | // trackedPeerRepository.countByLeft(0L), 49 | // trackedPeerRepository.countByLeftNot(0L), 50 | // trackedPeerRepository.countUsersWhoUploadedAnyData(), 51 | // trackedPeerRepository.countUsersWhoDidntUploadAnyData() 52 | // ); 53 | // model.addAttribute("trackerMetrics", trackerMetrics); 54 | model.addAttribute("user", userService.getUser(StpUtil.getLoginIdAsLong()).get()); 55 | return "index"; 56 | } 57 | 58 | public IndexBtnMetrics btnMetrics(OffsetDateTime from, OffsetDateTime to) { 59 | var banHistory = banHistoryService.getMetrics(from, to); 60 | var snapshot = snapshotService.getMetrics(from, to); 61 | var clientDiscovery = clientDiscoveryService.getMetrics(from, to); 62 | return new IndexBtnMetrics( 63 | banHistory.total(), 64 | snapshot.total(), 65 | 14, 66 | banHistory.recent(), 67 | snapshot.recent(), 68 | clientDiscovery.total(), 69 | clientDiscovery.recent() 70 | ); 71 | } 72 | 73 | public record IndexTrackerMetrics( 74 | long torrents, 75 | long peers, 76 | long seeders, 77 | long leechers, 78 | long haveUploadPeers, 79 | long noUploadPeers 80 | ) implements Serializable { 81 | } 82 | 83 | public record IndexBtnMetrics( 84 | long allTimeBans, 85 | long allTimeSubmits, 86 | long rangeInterval, 87 | long rangeBans, 88 | long rangeSubmits, 89 | long allTimeClientDiscovery, 90 | long rangeClientDiscovery 91 | ) implements Serializable { 92 | } 93 | } -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/userapp/UserApplicationViewController.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.userapp; 2 | 3 | import cn.dev33.satoken.annotation.SaCheckLogin; 4 | import cn.dev33.satoken.stp.StpUtil; 5 | import com.ghostchu.btn.sparkle.exception.TooManyUserApplicationException; 6 | import com.ghostchu.btn.sparkle.exception.UserApplicationNotFoundException; 7 | import com.ghostchu.btn.sparkle.exception.UserNotFoundException; 8 | import com.ghostchu.btn.sparkle.module.user.UserService; 9 | import com.ghostchu.btn.sparkle.util.TimeUtil; 10 | import lombok.AllArgsConstructor; 11 | import lombok.Data; 12 | import lombok.NoArgsConstructor; 13 | import org.springframework.stereotype.Controller; 14 | import org.springframework.ui.Model; 15 | import org.springframework.web.bind.annotation.*; 16 | 17 | import java.util.Objects; 18 | 19 | @Controller 20 | @SaCheckLogin 21 | @RequestMapping("/userapp") 22 | public class UserApplicationViewController { 23 | 24 | private final UserApplicationService userApplicationService; 25 | private final UserService userService; 26 | 27 | public UserApplicationViewController(UserApplicationService userApplicationService, UserService userService) { 28 | this.userApplicationService = userApplicationService; 29 | this.userService = userService; 30 | } 31 | 32 | @GetMapping("/") 33 | public String userApplicationIndex(Model model) { 34 | var list = userApplicationService.getUserApplications( 35 | userService.getUser(StpUtil.getLoginIdAsLong()).orElseThrow()) 36 | .stream() 37 | .sorted((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt())) 38 | .map(userApplicationService::toDto).toList(); 39 | model.addAttribute("userapps", list); 40 | return "modules/userapp/index"; 41 | } 42 | 43 | @GetMapping("/{appId}/resetAppSecret") 44 | public String resetUserApplicationAppSecret(Model model, @PathVariable("appId") String appId) throws UserApplicationNotFoundException { 45 | var userApp = userApplicationService.getUserApplication(appId).orElseThrow(UserApplicationNotFoundException::new); 46 | var resetUsrApp = userApplicationService.resetUserApplicationSecret(userApp.getId()); 47 | if (!Objects.equals(userApp.getUser().getId(), StpUtil.getLoginIdAsLong())) { 48 | StpUtil.checkPermission("userapp.reset-other-appsecret"); 49 | } 50 | model.addAttribute("userapp", resetUsrApp); 51 | return "modules/userapp/created"; 52 | } 53 | 54 | 55 | @GetMapping("/{appId}/delete") 56 | public String deleteUserApplication(@PathVariable("appId") String appId) throws UserApplicationNotFoundException { 57 | var usrApp = userApplicationService.getUserApplication(appId).orElseThrow(UserApplicationNotFoundException::new); 58 | if (!Objects.equals(usrApp.getUser().getId(), StpUtil.getLoginIdAsLong())) { 59 | StpUtil.checkPermission("userapp.delete-other-app"); 60 | } 61 | userApplicationService.deleteUserApplication(usrApp.getId()); 62 | return "redirect:/userapp/"; 63 | } 64 | 65 | @GetMapping("/create") 66 | public String createUserApplication() { 67 | return "modules/userapp/create"; 68 | } 69 | 70 | @PostMapping("/create") 71 | public String createUserApplication(Model model, @RequestParam String comment) throws UserNotFoundException, TooManyUserApplicationException { 72 | var user = userService.getUser(StpUtil.getLoginIdAsLong()).orElseThrow(); 73 | var usrApp = userApplicationService.generateUserApplicationForUser(user, comment, TimeUtil.toUTC(System.currentTimeMillis())); 74 | model.addAttribute("userapp", usrApp); 75 | return "modules/userapp/created"; 76 | } 77 | 78 | @Data 79 | @AllArgsConstructor 80 | @NoArgsConstructor 81 | public static class UserApplicationCreateRequest { 82 | private String comment; 83 | } 84 | 85 | @Data 86 | @AllArgsConstructor 87 | @NoArgsConstructor 88 | public static class UserApplicationEditRequest { 89 | private String comment; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/controller/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.controller; 2 | 3 | import cn.dev33.satoken.exception.NotLoginException; 4 | import com.ghostchu.btn.sparkle.exception.BusinessException; 5 | import com.ghostchu.btn.sparkle.exception.UserBannedException; 6 | import com.ghostchu.btn.sparkle.wrapper.StdResp; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.apache.catalina.connector.ClientAbortException; 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.http.ResponseEntity; 11 | import org.springframework.http.converter.HttpMessageNotReadableException; 12 | import org.springframework.web.HttpRequestMethodNotSupportedException; 13 | import org.springframework.web.bind.annotation.ExceptionHandler; 14 | import org.springframework.web.bind.annotation.RestControllerAdvice; 15 | import org.springframework.web.context.request.async.AsyncRequestNotUsableException; 16 | import org.springframework.web.servlet.resource.NoResourceFoundException; 17 | 18 | @RestControllerAdvice 19 | @Slf4j 20 | public class GlobalExceptionHandler { 21 | @ExceptionHandler(BusinessException.class) 22 | public ResponseEntity> businessExceptionHandler(BusinessException e) { 23 | return ResponseEntity.status(e.getStatusCode()).body(new StdResp<>(false, e.getMessage(), null)); 24 | } 25 | 26 | @ExceptionHandler(UserBannedException.class) 27 | public ResponseEntity> userBannedException(UserBannedException e) { 28 | return ResponseEntity.status(403).body(new StdResp<>(false, "此用户已被管理员停用,请与系统管理员联系以获取更多信息。", null)); 29 | } 30 | 31 | @ExceptionHandler(NotLoginException.class) 32 | public ResponseEntity> businessExceptionHandler(NotLoginException e) { 33 | return ResponseEntity.status(403).body(new StdResp<>(false, "未登录或会话已过期,请转到首页登录", null)); 34 | } 35 | 36 | @ExceptionHandler(NoResourceFoundException.class) 37 | public ResponseEntity> noResourceFoundException(Exception e) { 38 | return ResponseEntity.status(HttpStatus.NOT_FOUND) 39 | .body(new StdResp<>(false, "404 Not Found - 资源未找到", null)); 40 | } 41 | 42 | // @ExceptionHandler(ClientAbortException.class) 43 | // public void clientAbort(Exception e) { 44 | // log.warn("Client abort a connection: {}", e.getMessage()); 45 | // } 46 | 47 | @ExceptionHandler(IllegalArgumentException.class) 48 | public ResponseEntity> illegalArgument(IllegalArgumentException e) { 49 | return ResponseEntity.status(HttpStatus.BAD_REQUEST) 50 | .body(new StdResp<>(false, "无效参数: " + e.getMessage(), null)); 51 | } 52 | 53 | @ExceptionHandler(HttpRequestMethodNotSupportedException.class) 54 | public ResponseEntity> methodNotAllowed(HttpRequestMethodNotSupportedException e) { 55 | return ResponseEntity.status(HttpStatus.BAD_REQUEST) 56 | .body(new StdResp<>(false, "不允许的请求方式: " + e.getMessage(), null)); 57 | } 58 | 59 | @ExceptionHandler(HttpMessageNotReadableException.class) 60 | public ResponseEntity> httpMessageNotReadable(HttpMessageNotReadableException e) { 61 | int loop = 0; 62 | Throwable exception = e; 63 | while (exception.getCause() != null) { 64 | loop++; 65 | if (loop > 30) break; 66 | exception = exception.getCause(); 67 | if (exception instanceof ClientAbortException) { 68 | return ResponseEntity.status(HttpStatus.BAD_REQUEST) 69 | .body(new StdResp<>(false, "客户端已放弃请求: " + e.getMessage(), null)); 70 | } 71 | } 72 | return ResponseEntity.status(HttpStatus.BAD_REQUEST) 73 | .body(new StdResp<>(false, "不可读的 HTTP 消息: " + e.getMessage(), null)); 74 | } 75 | 76 | @ExceptionHandler(AsyncRequestNotUsableException.class) 77 | public void asyncReqNotUsable(AsyncRequestNotUsableException e) { 78 | //log.warn("Unable to complete async request because: [{}], async request not usable.", e.getMessage()); 79 | // not my issue 80 | } 81 | 82 | 83 | @ExceptionHandler(Exception.class) 84 | public ResponseEntity> jvmExceptionHandler(Exception e) { 85 | log.error("Unexpected exception", e); 86 | return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) 87 | .body(new StdResp<>(false, "服务器内部错误,请联系服务器管理员。", null)); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /HELP.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ### Reference Documentation 4 | For further reference, please consider the following sections: 5 | 6 | * [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) 7 | * [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.3.2/maven-plugin) 8 | * [Create an OCI image](https://docs.spring.io/spring-boot/3.3.2/maven-plugin/build-image.html) 9 | * [Spring Boot DevTools](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#using.devtools) 10 | * [Spring Configuration Processor](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#appendix.configuration-metadata.annotation-processor) 11 | * [Docker Compose Support](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#features.docker-compose) 12 | * [Spring Modulith](https://docs.spring.io/spring-modulith/reference/) 13 | * [Spring Web](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#web) 14 | * [Spring Session](https://docs.spring.io/spring-session/reference/) 15 | * [Thymeleaf](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#web.servlet.spring-mvc.template-engines) 16 | * [OAuth2 Client](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#web.security.oauth2.client) 17 | * [JDBC API](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#data.sql) 18 | * [Spring Data JPA](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#data.sql.jpa-and-spring-data) 19 | * [Spring Data JDBC](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#data.sql.jdbc) 20 | * [Flyway Migration](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#howto.data-initialization.migration-tool.flyway) 21 | * [Spring Data Redis (Access+Driver)](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#data.nosql.redis) 22 | * [Spring Batch](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#howto.batch) 23 | * [Validation](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#io.validation) 24 | * [Java Mail Sender](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#io.email) 25 | * [Quartz Scheduler](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#io.quartz) 26 | * [Spring Cache Abstraction](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#io.caching) 27 | * [Prometheus](https://docs.spring.io/spring-boot/docs/3.3.2/reference/htmlsingle/index.html#actuator.metrics.export.prometheus) 28 | 29 | ### Guides 30 | The following guides illustrate how to use some features concretely: 31 | 32 | * [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) 33 | * [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) 34 | * [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) 35 | * [Handling Form Submission](https://spring.io/guides/gs/handling-form-submission/) 36 | * [Accessing Relational Data using JDBC with Spring](https://spring.io/guides/gs/relational-data-access/) 37 | * [Managing Transactions](https://spring.io/guides/gs/managing-transactions/) 38 | * [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/) 39 | * [Using Spring Data JDBC](https://github.com/spring-projects/spring-data-examples/tree/master/jdbc/basics) 40 | * [Messaging with Redis](https://spring.io/guides/gs/messaging-redis/) 41 | * [Creating a Batch Service](https://spring.io/guides/gs/batch-processing/) 42 | * [Validation](https://spring.io/guides/gs/validating-form-input/) 43 | * [Caching Data with Spring](https://spring.io/guides/gs/caching/) 44 | 45 | ### Docker Compose support 46 | This project contains a Docker Compose file named `compose.yaml`. 47 | In this file, the following services have been defined: 48 | 49 | * postgres: [`postgres:latest`](https://hub.docker.com/_/postgres) 50 | * redis: [`redis:latest`](https://hub.docker.com/_/redis) 51 | 52 | Please review the tags of the used images and set them to the same as you're running in production. 53 | 54 | ### Maven Parent overrides 55 | 56 | Due to Maven's design, elements are inherited from the parent POM to the project POM. 57 | While most of the inheritance is fine, it also inherits unwanted elements like `` and `` from the parent. 58 | To prevent this, the project POM contains empty overrides for these elements. 59 | If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides. 60 | 61 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/snapshot/SnapshotService.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.snapshot; 2 | 3 | import com.ghostchu.btn.sparkle.module.snapshot.internal.Snapshot; 4 | import com.ghostchu.btn.sparkle.module.snapshot.internal.SnapshotRepository; 5 | import com.ghostchu.btn.sparkle.module.torrent.TorrentService; 6 | import com.ghostchu.btn.sparkle.util.ipdb.GeoIPManager; 7 | import com.ghostchu.btn.sparkle.util.paging.SparklePage; 8 | import jakarta.persistence.EntityManager; 9 | import jakarta.persistence.PersistenceContext; 10 | import jakarta.transaction.Transactional; 11 | import org.springframework.cache.annotation.Cacheable; 12 | import org.springframework.data.domain.PageRequest; 13 | import org.springframework.data.domain.Pageable; 14 | import org.springframework.data.jpa.domain.Specification; 15 | import org.springframework.stereotype.Service; 16 | 17 | import java.io.Serializable; 18 | import java.net.InetAddress; 19 | import java.time.OffsetDateTime; 20 | import java.util.List; 21 | 22 | @Service 23 | public class SnapshotService { 24 | 25 | private final SnapshotRepository snapshotRepository; 26 | private final TorrentService torrentService; 27 | @PersistenceContext 28 | private EntityManager entityManager; 29 | 30 | public SnapshotService(SnapshotRepository snapshotRepository, TorrentService torrentService, GeoIPManager geoIPManager) { 31 | this.snapshotRepository = snapshotRepository; 32 | this.torrentService = torrentService; 33 | // this.geoIPManager = geoIPManager; 34 | // AtomicInteger count = new AtomicInteger(); 35 | // CompletableFuture.runAsync(() -> { 36 | // while (true) { 37 | // var list = snapshotRepository.findByGeoIPIsNull(PageRequest.of(0, 100000)); 38 | // System.out.println("Snapshot: Get " + list.getSize() + " ips"); 39 | // if (list.isEmpty()) { 40 | // System.out.println("Snapshot OK!"); 41 | // break; 42 | // } 43 | // var handled = list.stream().parallel().peek(snapshot -> snapshot.setGeoIP(geoIPManager.geoData(snapshot.getPeerIp()))) 44 | // .toList(); 45 | // System.gc(); 46 | // System.out.println("Mapped " + handled.size() + " records"); 47 | // snapshotRepository.saveAll(handled); 48 | // count.addAndGet(handled.size()); 49 | // System.out.println("Snapshot: Already successfully handled " + count.get() + " records, Execute next batch"); 50 | // } 51 | // }); 52 | } 53 | 54 | @Transactional 55 | //@Lock(LockModeType.PESSIMISTIC_WRITE) 56 | public void saveSnapshots(List snapshotList) { 57 | snapshotRepository.saveAll(snapshotList); 58 | } 59 | 60 | 61 | @Cacheable(value = "snapshotMetrics#1800000", key = "#from+'-'+#to") 62 | public SnapshotMetrics getMetrics(OffsetDateTime from, OffsetDateTime to) { 63 | return new SnapshotMetrics(snapshotRepository.count(), snapshotRepository.countByInsertTimeBetween(from, to)); 64 | } 65 | 66 | public SparklePage queryRecent(PageRequest pageable) { 67 | var page = snapshotRepository.findByOrderByInsertTimeDesc(pageable); 68 | return new SparklePage<>(page, dat -> dat.map(this::toDto)); 69 | } 70 | 71 | public SparklePage query(Specification specification, Pageable pageable) { 72 | var page = snapshotRepository.findAll(specification, pageable); 73 | return new SparklePage<>(page, ct -> ct.map(this::toDto)); 74 | } 75 | 76 | public SnapshotDto toDto(Snapshot snapshot) { 77 | return SnapshotDto.builder().id(snapshot.getId()).appId(snapshot.getUserApplication().getAppId()).submitId(snapshot.getSubmitId()).peerIp(snapshot.getPeerIp().getHostAddress()).peerPort(snapshot.getPeerPort()).peerId(snapshot.getPeerId()).peerClientName(snapshot.getPeerClientName()).torrent(torrentService.toDto(snapshot.getTorrent())).fromPeerTraffic(snapshot.getFromPeerTraffic()).fromPeerTrafficSpeed(snapshot.getFromPeerTrafficSpeed()).toPeerTraffic(snapshot.getToPeerTraffic()).toPeerTrafficSpeed(snapshot.getToPeerTrafficSpeed()).peerProgress(snapshot.getPeerProgress()).downloaderProgress(snapshot.getDownloaderProgress()).flags(snapshot.getFlags()).build(); 78 | } 79 | 80 | public record SnapshotOverDownloadResult( 81 | long torrentId, 82 | InetAddress peerIp, 83 | long totalUploaded, 84 | long torrentSize, 85 | double uploadPercentage 86 | ) { 87 | 88 | } 89 | 90 | public record SnapshotMetrics(long total, long recent) implements Serializable { 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/clientdiscovery/ClientDiscoveryController.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.clientdiscovery; 2 | 3 | import cn.dev33.satoken.annotation.SaCheckLogin; 4 | import com.ghostchu.btn.sparkle.controller.SparkleController; 5 | import com.ghostchu.btn.sparkle.exception.RequestPageSizeTooLargeException; 6 | import com.ghostchu.btn.sparkle.module.clientdiscovery.internal.ClientDiscovery; 7 | import com.ghostchu.btn.sparkle.util.compare.StringCompareMethod; 8 | import com.ghostchu.btn.sparkle.util.paging.SparklePage; 9 | import com.ghostchu.btn.sparkle.wrapper.StdResp; 10 | import jakarta.persistence.criteria.Predicate; 11 | import lombok.AllArgsConstructor; 12 | import lombok.Builder; 13 | import lombok.Data; 14 | import lombok.NoArgsConstructor; 15 | import org.apache.commons.lang3.StringUtils; 16 | import org.springframework.data.domain.PageRequest; 17 | import org.springframework.data.domain.Sort; 18 | import org.springframework.data.jpa.domain.Specification; 19 | import org.springframework.web.bind.annotation.*; 20 | 21 | import java.sql.Timestamp; 22 | import java.util.ArrayList; 23 | import java.util.List; 24 | 25 | @RestController 26 | @SaCheckLogin 27 | @RequestMapping("/api") 28 | public class ClientDiscoveryController extends SparkleController { 29 | private final ClientDiscoveryService clientDiscoveryService; 30 | 31 | public ClientDiscoveryController(ClientDiscoveryService clientDiscoveryService) { 32 | this.clientDiscoveryService = clientDiscoveryService; 33 | } 34 | 35 | @GetMapping("/clientdiscovery") 36 | public StdResp> recent(@RequestParam("page") Integer page, @RequestParam("pageSize") Integer pageSize) throws RequestPageSizeTooLargeException { 37 | var paging = paging(page,pageSize); 38 | return new StdResp<>(true, null, clientDiscoveryService.queryRecent(PageRequest.of(paging.page(), paging.pageSize()))); 39 | } 40 | 41 | @PostMapping("/clientdiscovery/query") 42 | public StdResp> complexQuery(@RequestBody ComplexDiscoverQueryRequest q) throws RequestPageSizeTooLargeException { 43 | var paging = paging(q.getPage(), q.getPageSize()); 44 | Specification specification = (root, query, cb) -> { 45 | List predicates = new ArrayList<>(); 46 | if(q.getFoundAtTimeFrom() != null){ 47 | predicates.add(cb.greaterThanOrEqualTo(root.get("foundAt"), new Timestamp(q.getFoundAtTimeFrom()))); 48 | } 49 | if(q.getFoundAtTimeTo() != null){ 50 | predicates.add(cb.lessThanOrEqualTo(root.get("foundAt"), new Timestamp(q.getFoundAtTimeTo()))); 51 | } 52 | if(q.getLastSeenAtTimeFrom() != null){ 53 | predicates.add(cb.greaterThanOrEqualTo(root.get("lastSeenAt"), new Timestamp(q.getLastSeenAtTimeFrom()))); 54 | } 55 | if(q.getLastSeenAtTimeTo() != null){ 56 | predicates.add(cb.lessThanOrEqualTo(root.get("laseSeenAt"), new Timestamp(q.getLastSeenAtTimeTo()))); 57 | } 58 | if (StringUtils.isNotBlank(q.getPeerId())) { 59 | predicates.add(q.getPeerIdCompareMethod().criteriaBuilder(cb, root.get("peerId"), q.getPeerId())); 60 | } 61 | if (StringUtils.isNotBlank(q.getPeerClientName())) { 62 | predicates.add(q.getPeerIdCompareMethod().criteriaBuilder(cb, root.get("peerClientName"), q.getPeerId())); 63 | } 64 | if (q.getOrConnector() != null && q.getOrConnector()) { 65 | return cb.or(predicates.toArray(new Predicate[0])); 66 | } else { 67 | return cb.and(predicates.toArray(new Predicate[0])); 68 | } 69 | }; 70 | Sort sort = Sort.unsorted(); 71 | if(q.getSortOrder() != null && q.getSortBy() != null){ 72 | sort = Sort.by(q.getSortOrder(), q.getSortBy()); 73 | } 74 | return new StdResp<>(true, null, clientDiscoveryService.query(specification, PageRequest.of(paging.page(), paging.pageSize(), sort))); 75 | 76 | } 77 | 78 | 79 | @Data 80 | @AllArgsConstructor 81 | @NoArgsConstructor 82 | @Builder 83 | public static class ComplexDiscoverQueryRequest { 84 | private Integer page; 85 | private Integer pageSize; 86 | private Long foundAtTimeFrom; 87 | private Long foundAtTimeTo; 88 | private Long lastSeenAtTimeFrom; 89 | private Long lastSeenAtTimeTo; 90 | private String peerId; 91 | private StringCompareMethod peerIdCompareMethod; 92 | private String peerClientName; 93 | private Boolean orConnector; 94 | private String[] sortBy; 95 | private Sort.Direction sortOrder; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/ping/PingWebSocketSession.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.ping; 2 | 3 | import com.ghostchu.btn.sparkle.config.SpringWebSocketServerEndpointConfigurator; 4 | import com.ghostchu.btn.sparkle.module.userapp.UserApplicationService; 5 | import jakarta.websocket.*; 6 | import jakarta.websocket.server.ServerEndpoint; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.context.annotation.Scope; 10 | import org.springframework.stereotype.Component; 11 | 12 | import java.io.IOException; 13 | 14 | /** 15 | * WebSocket endpoint for handling ping event streams. 16 | *

17 | * This class uses {@link SpringWebSocketServerEndpointConfigurator} to enable Spring dependency injection 18 | * in Jakarta WebSocket endpoints. The configurator ensures that Spring manages the lifecycle of this 19 | * endpoint and properly injects all required dependencies. 20 | *

21 | *

22 | * Architecture: 23 | * - {@link SpringWebSocketServerEndpointConfigurator} bridges Jakarta WebSocket and Spring container 24 | * - Dependencies are injected via constructor (constructor injection) 25 | * - {@link PingWebSocketManager} manages all active WebSocket sessions and handles broadcasting 26 | * - Uses prototype scope to create a new instance for each WebSocket connection 27 | *

28 | */ 29 | @Slf4j 30 | @Component 31 | @Scope("prototype") 32 | @ServerEndpoint(value = "/ping/eventStream", configurator = SpringWebSocketServerEndpointConfigurator.class) 33 | public class PingWebSocketSession { 34 | private final UserApplicationService userApplicationService; 35 | private final PingWebSocketManager pingWebSocketManager; 36 | private final boolean enabled; 37 | 38 | private Session session; 39 | 40 | /** 41 | * Constructor for Spring dependency injection. 42 | * 43 | * @param userApplicationService Service for user application authentication 44 | * @param pingWebSocketManager Manager for WebSocket session lifecycle and broadcasting 45 | */ 46 | public PingWebSocketSession(UserApplicationService userApplicationService, 47 | PingWebSocketManager pingWebSocketManager, @Value("${service.ping.event-stream}") boolean enabled) { 48 | this.userApplicationService = userApplicationService; 49 | this.pingWebSocketManager = pingWebSocketManager; 50 | this.enabled = enabled; 51 | } 52 | 53 | 54 | @OnOpen 55 | public void onOpen(Session session) throws IOException { 56 | if(!enabled){ 57 | session.getAsyncRemote().sendText("Feature disabled"); 58 | session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "Feature disabled")); 59 | return; 60 | } 61 | this.session = session; 62 | var queryParameters = session.getRequestParameterMap(); 63 | String appId = queryParameters.get("appId").getFirst(); 64 | String appSecret = queryParameters.get("appSecret").getFirst(); 65 | var userAppOptional = userApplicationService.getUserApplication(appId, appSecret); 66 | if (userAppOptional.isEmpty()) { 67 | session.getAsyncRemote().sendText("UserApplication authorize failed"); 68 | session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "UserApplication authorize failed")); 69 | return; 70 | } 71 | var userApp = userAppOptional.get(); 72 | if (userApp.getBannedAt() != null || userApp.getUser().getBannedAt() != null) { 73 | session.getAsyncRemote().sendText("UserApplication is banned by administrator\""); 74 | session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "UserApplication is banned by administrator")); 75 | return; 76 | } 77 | session.getContainer().setAsyncSendTimeout(5 * 1000); 78 | pingWebSocketManager.registerSession(session); 79 | log.info("Connected to WebSocket: userAppId={}, userId={}; Session: {}", userApp.getId(), userApp.getUser().getId(), session); 80 | } 81 | 82 | @OnClose 83 | public void onClose() { 84 | pingWebSocketManager.unregisterSession(session); 85 | log.info("Disconnected from WebSocket: Session: {}", session); 86 | } 87 | 88 | @OnMessage 89 | public void onMessage(String message, Session session) { 90 | log.info("Unhandled message from WebSocket: Session: {}, Message: {}", session, message); 91 | } 92 | 93 | @OnError 94 | public void onError(Session session, Throwable error) { 95 | pingWebSocketManager.unregisterSession(session); 96 | log.warn("Error occurred in WebSocket: Session: {}", session, error); 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | # 2 | # @ALERT@ 3 | # THIS FILE IS UNDER /src/main/resources directory 4 | # AND IT CAN BE PUSH TO GITHUB REMOTE FOR PUBLIC 5 | # !!!!DO NOT FILL TOKEN AND CREDENTIALS IN THIS FILE!!!! 6 | # 7 | 8 | # Sparkle Basic Settings 9 | spring.application.name=Sparkle 10 | spring.threads.virtual.enabled=true 11 | sparkle.root=https://btn-sparkle.ghostchu-services.top 12 | sparkle.root.china=https://sparkle.ghostchu.com 13 | 14 | # Http Server Settings 15 | server.address=0.0.0.0 16 | server.port=7799 17 | server.http2.enabled=true 18 | server.compression.enabled=true 19 | server.compression.min-response-size=128B 20 | server.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json 21 | server.tomcat.threads.max=500 22 | server.tomcat.accept-count=4096 23 | server.tomcat.max-connections=10240 24 | server.undertow.direct-buffers=true 25 | 26 | ## Thymeleaf Settings 27 | spring.thymeleaf.cache=true 28 | spring.thymeleaf.encoding=UTF-8 29 | 30 | # Database&ORM Settings 31 | spring.datasource.driver-class-name=org.postgresql.Driver 32 | spring.datasource.url=jdbc:postgresql://localhost:5432/sparkledb 33 | spring.datasource.username=sparkleusr 34 | spring.datasource.password=sparklepwd 35 | 36 | spring.jpa.hibernate.ddl-auto=update 37 | spring.jpa.properties.hibernate.globally_quoted_identifiers=true 38 | spring.jpa.properties.hibernate.globally_quoted_identifiers_skip_column_definitions=true 39 | spring.jpa.properties.hibernate.session_factory.stateless.enabled=true 40 | spring.jpa.properties.hibernate.order_inserts=true 41 | spring.jpa.properties.hibernate.jdbc.batch_size=2000 42 | spring.jpa.properties.hibernate.generate_statistics=false 43 | spring.data.redis.host=localhost 44 | spring.data.redis.port=6379 45 | spring.data.redis.password= 46 | spring.data.redis.database=0 47 | spring.data.redis.timeout=10s 48 | spring.data.redis.jedis.pool.enabled=true 49 | spring.task.scheduling.pool.size=20 50 | # Flyway Migration 51 | spring.flyway.enabled=true 52 | spring.flyway.validate-on-migrate=true 53 | spring.flyway.locations=classpath:db/migration/pgsql 54 | 55 | # GitHub OAuth Settings 56 | oauth2.github.client-id= 57 | oauth2.github.client-secret= 58 | oauth2.github.scope=read:user user:email 59 | 60 | # Sa-Token Settings 61 | sa-token.token-name=X-BTN-Token 62 | sa-token.timeout=2592000 63 | sa-token.is-concurrent=true 64 | sa-token.token-style=uuid 65 | sa-token.is-log=true 66 | 67 | # Metrics 68 | management.endpoints.web.exposure.include=* 69 | 70 | # Sparkle Service 71 | service.ping.protocol.max-version=10 72 | service.ping.protocol.min-version=7 73 | service.ping.ability.submitbans.interval=900000 74 | service.ping.ability.submitbans.endpoint=${sparkle.root}/ping/bans/submit 75 | service.ping.ability.submitbans.random-initial-delay=600000 76 | service.ping.ability.submitpeers.interval=900000 77 | service.ping.ability.submitpeers.endpoint=${sparkle.root}/ping/peers/submit 78 | service.ping.ability.submitpeers.random-initial-delay=600000 79 | service.ping.ability.submithistories.endpoint=${sparkle.root}/ping/histories/submit 80 | service.ping.ability.submithistories.interval=900000 81 | service.ping.ability.submithistories.random-initial-delay=600000 82 | service.ping.ability.reconfigure.interval=900000 83 | service.ping.ability.reconfigure.random-initial-delay=600000 84 | service.ping.ability.cloudrule.interval=900000 85 | service.ping.ability.cloudrule.endpoint=${sparkle.root}/ping/rules/retrieve 86 | service.ping.ability.cloudrule.random-initial-delay=600000 87 | service.ping.event-stream=true 88 | service.ping.event-stream.peer-history=true 89 | service.userapplication.user-max-apps=300 90 | service.githubruleupdate.interval=0 0 */3 * * * 91 | service.githubruleupdate.access-token=123456 92 | service.githubruleupdate.org-name=PBH-BTN 93 | service.githubruleupdate.repo-name=BTN-Collected-Rules 94 | service.githubruleupdate.branch-name=master 95 | service.githubruleupdate.past-interval=3888000000 96 | 97 | util.ipmerger.merge-threshold.ipv4=2 98 | util.ipmerger.merge-threshold.ipv6=3 99 | util.ipmerger.prefix-length.ipv4=25 100 | util.ipmerger.prefix-length.ipv6=56 101 | analyse.audit.enable=true 102 | analyse.ipv6.prefix-length=60 103 | analyse.untrustip.interval=0 0 * * * * 104 | analyse.untrustip.offset=3888000000 105 | analyse.untrustip.threshold=2 106 | analyse.overdownload.interval=- 107 | analyse.overdownload.offset=3888000000 108 | analyse.overdownload.threshold=2.5 109 | analyse.overdownload.refreshviews.interval=0 55 */2 * * 8 110 | analyse.highriskips.interval=0 0 * * * * 111 | analyse.highriskips.offset=3888000000 112 | analyse.highriskips.traffic-from-peer-less-than=50000000 113 | analyse.highriskipv6identity.interval=14400000 114 | analyse.highriskipv6identity.offset=3888000000 115 | analyse.trackerhighrisk.interval=0 0 * * * * 116 | analyse.tracker.dumpfile=/tmp/tracker.db -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/peerhistory/PeerHistoryService.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.peerhistory; 2 | 3 | import com.ghostchu.btn.sparkle.module.peerhistory.internal.PeerHistory; 4 | import com.ghostchu.btn.sparkle.module.peerhistory.internal.PeerHistoryRepository; 5 | import com.ghostchu.btn.sparkle.module.torrent.TorrentService; 6 | import com.ghostchu.btn.sparkle.util.ipdb.GeoIPManager; 7 | import jakarta.persistence.EntityManager; 8 | import jakarta.persistence.LockModeType; 9 | import jakarta.persistence.PersistenceContext; 10 | import jakarta.transaction.Transactional; 11 | import org.springframework.data.jpa.repository.Lock; 12 | import org.springframework.stereotype.Service; 13 | 14 | import java.util.List; 15 | 16 | @Service 17 | public class PeerHistoryService { 18 | 19 | private final PeerHistoryRepository peerHistoryRepository; 20 | private final TorrentService torrentService; 21 | @PersistenceContext 22 | private EntityManager entityManager; 23 | 24 | public PeerHistoryService(PeerHistoryRepository peerHistoryRepository, TorrentService torrentService, GeoIPManager geoIPManager) { 25 | this.peerHistoryRepository = peerHistoryRepository; 26 | this.torrentService = torrentService; 27 | // this.geoIPManager = geoIPManager; 28 | // AtomicInteger count = new AtomicInteger(); 29 | // CompletableFuture.runAsync(() -> { 30 | // while (true) { 31 | // var list = snapshotRepository.findByGeoIPIsNull(PageRequest.of(0, 100000)); 32 | // System.out.println("Snapshot: Get " + list.getSize() + " ips"); 33 | // if (list.isEmpty()) { 34 | // System.out.println("Snapshot OK!"); 35 | // break; 36 | // } 37 | // var handled = list.stream().parallel().peek(snapshot -> snapshot.setGeoIP(geoIPManager.geoData(snapshot.getPeerIp()))) 38 | // .toList(); 39 | // System.gc(); 40 | // System.out.println("Mapped " + handled.size() + " records"); 41 | // snapshotRepository.saveAll(handled); 42 | // count.addAndGet(handled.size()); 43 | // System.out.println("Snapshot: Already successfully handled " + count.get() + " records, Execute next batch"); 44 | // } 45 | // }); 46 | } 47 | 48 | @Transactional 49 | @Lock(LockModeType.PESSIMISTIC_WRITE) 50 | public void saveHistories(List peerHistoryList) { 51 | peerHistoryRepository.saveAll(peerHistoryList); 52 | } 53 | 54 | // 55 | // @Cacheable(value = "snapshotMetrics#1800000", key = "#from+'-'+#to") 56 | // public SnapshotMetrics getMetrics(OffsetDateTime from, OffsetDateTime to) { 57 | // return new SnapshotMetrics(snapshotRepository.count(), snapshotRepository.countByInsertTimeBetween(from, to)); 58 | // } 59 | 60 | // public SparklePage queryRecent(PageRequest pageable) { 61 | // var page = snapshotRepository.findByOrderByInsertTimeDesc(pageable); 62 | // return new SparklePage<>(page, dat -> dat.map(this::toDto)); 63 | // } 64 | // 65 | // public SparklePage query(Specification specification, Pageable pageable) { 66 | // var page = snapshotRepository.findAll(specification, pageable); 67 | // return new SparklePage<>(page, ct -> ct.map(this::toDto)); 68 | // } 69 | 70 | // public SnapshotHistoryDto toDto(SnapshotHistory snapshotHistory) { 71 | // return SnapshotHistoryDto.builder() 72 | // .id(snapshotHistory.getId()) 73 | // .appId(snapshotHistory.getUserApplication().getAppId()) 74 | // .submitId(snapshotHistory.getSubmitId()).peerIp(snapshotHistory.getPeerIp().getHostAddress()) 75 | // .peerPort(snapshotHistory.getPeerPort()).peerId(snapshotHistory.getPeerId()) 76 | // .peerClientName(snapshotHistory.getPeerClientName()) 77 | // .torrent(torrentService.toDto(snapshotHistory.getTorrent())) 78 | // .fromPeerTraffic(snapshotHistory.getFromPeerTraffic()) 79 | // .fromPeerTrafficSpeed(snapshotHistory.getFromPeerTrafficSpeed()) 80 | // .toPeerTraffic(snapshotHistory.getToPeerTraffic()) 81 | // .toPeerTrafficSpeed(snapshotHistory.getToPeerTrafficSpeed()).peerProgress(snapshotHistory.getPeerProgress()).downloaderProgress(snapshotHistory.getDownloaderProgress()).flags(snapshotHistory.getFlags()).build(); 82 | // } 83 | 84 | // public record SnapshotOverDownloadResult( 85 | // long torrentId, 86 | // InetAddress peerIp, 87 | // long totalUploaded, 88 | // long torrentSize, 89 | // double uploadPercentage 90 | // ) { 91 | // 92 | // } 93 | // 94 | // public record SnapshotMetrics(long total, long recent) implements Serializable { 95 | // } 96 | } 97 | -------------------------------------------------------------------------------- /src/main/resources/templates/user/profile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |
7 | 8 | 89 | 90 |
91 | 个人资料 92 |
93 | 94 |
95 |
96 | 用户头像 97 |
98 |

昵称

99 | 100 |
101 |
102 | 103 | 117 | 118 |
119 | 120 |
121 |
122 |

Bytes Here

123 | = 124 | bytes
125 | 我的 Bytes 积分 126 | 128 |
129 |
130 | 131 |

132 | 电子邮件: 133 | user@example.com 134 |

135 |

136 | 注册时间: 137 | 注册时间 138 |

139 |

140 | 最后访问时间: 141 | 最后访问时间 142 |

143 |

144 | 账户状态: 145 | 146 | 正常 147 | 148 | 149 | 已暂停 (暂停状态下,您将无法继续提交数据。已提交的数据将在下一次数据更新时排除在外。) 150 | 151 |

152 |
153 |
154 | 155 |
156 | 157 | 158 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/user/UserService.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.user; 2 | 3 | import com.ghostchu.btn.sparkle.exception.UserNotFoundException; 4 | import com.ghostchu.btn.sparkle.module.user.internal.User; 5 | import com.ghostchu.btn.sparkle.module.user.internal.UserRepository; 6 | import io.micrometer.core.instrument.MeterRegistry; 7 | import jakarta.persistence.LockModeType; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.dao.OptimisticLockingFailureException; 10 | import org.springframework.data.jpa.repository.Lock; 11 | import org.springframework.orm.ObjectOptimisticLockingFailureException; 12 | import org.springframework.retry.annotation.Backoff; 13 | import org.springframework.retry.annotation.Retryable; 14 | import org.springframework.stereotype.Service; 15 | import org.springframework.transaction.annotation.Propagation; 16 | import org.springframework.transaction.annotation.Transactional; 17 | 18 | import java.time.OffsetDateTime; 19 | import java.util.Locale; 20 | import java.util.Optional; 21 | import java.util.UUID; 22 | import java.util.concurrent.ThreadLocalRandom; 23 | 24 | @Service 25 | public class UserService { 26 | 27 | private final UserRepository userRepository; 28 | @Autowired 29 | private MeterRegistry meterRegistry; 30 | 31 | public UserService(UserRepository userRepository) { 32 | this.userRepository = userRepository; 33 | } 34 | 35 | // @SaCheckLogin 36 | // public UserDto me() { 37 | // return (UserDto) StpUtil.getLoginId(); 38 | // } 39 | 40 | @Transactional(propagation = Propagation.NOT_SUPPORTED) 41 | public User getSystemUser(String moduleName) { 42 | return userRepository.findByEmail(moduleName.toLowerCase(Locale.ROOT) + "@sparkle.system").orElseGet(() -> { 43 | User user = new User(); 44 | user.setEmail(moduleName.toLowerCase(Locale.ROOT) + "@sparkle.system"); 45 | user.setNickname(moduleName); 46 | user.setRegisterAt(OffsetDateTime.now()); 47 | user.setRandomGroup(ThreadLocalRandom.current().nextInt(9)); 48 | user.setGithubLogin(UUID.randomUUID().toString()); 49 | user.setGithubUserId(moduleName.hashCode() < 0 ? moduleName.hashCode() : moduleName.hashCode() * -1L); 50 | user.setAvatar("https://example.local/sparkle.jpg"); 51 | user.setNickname("[SYS] " + moduleName); 52 | user.setLastSeenAt(OffsetDateTime.now()); 53 | user.setLastAccessAt(OffsetDateTime.now()); 54 | return userRepository.save(user); 55 | }); 56 | } 57 | 58 | @Transactional(propagation = Propagation.NOT_SUPPORTED) 59 | public Optional getUserByEmail(String email) { 60 | return userRepository.findByEmail(email); 61 | } 62 | 63 | @Transactional(propagation = Propagation.NOT_SUPPORTED) 64 | public Optional getUser(long id) { 65 | return userRepository.findById(id); 66 | } 67 | 68 | @Transactional(propagation = Propagation.NOT_SUPPORTED) 69 | public Optional getUserByGithubLogin(String githubLogin) { 70 | return userRepository.findByGithubLogin(githubLogin); 71 | } 72 | 73 | @Transactional(propagation = Propagation.NOT_SUPPORTED) 74 | public Optional getUserByGithubUserId(Long githubLogin) { 75 | return userRepository.findByGithubUserId(githubLogin); 76 | } 77 | 78 | /** 79 | * 从 UserDto 还原 User 对象 80 | * 81 | * @param dto UserDto 82 | * @return User 对象 83 | * @throws UserNotFoundException 尽管一般来说这不太可能为空,但如果用户被管理员干了,我们还是丢个错误 84 | */ 85 | public User exchangeUserFromUserDto(UserDto dto) throws UserNotFoundException { 86 | var optional = userRepository.findById(dto.getId()); 87 | if (optional.isEmpty()) { 88 | throw new UserNotFoundException(); 89 | } 90 | return optional.get(); 91 | } 92 | 93 | @Transactional 94 | @Lock(LockModeType.PESSIMISTIC_WRITE) 95 | @Retryable(retryFor = {ObjectOptimisticLockingFailureException.class, OptimisticLockingFailureException.class}, backoff = @Backoff(delay = 100, multiplier = 2)) 96 | public User saveUser(User user) { 97 | if (user.isSystemAccount()) { 98 | throw new IllegalArgumentException("User email cannot ends with @sparkle.system, it's reserved by Sparkle system."); 99 | } 100 | if (user.getNickname().startsWith("[SYS]")) { 101 | throw new IllegalArgumentException("User nickname cannot start with [SYS], it's reserved by Sparkle system."); 102 | } 103 | return userRepository.save(user); 104 | } 105 | 106 | @Transactional 107 | @Lock(LockModeType.OPTIMISTIC) 108 | @Retryable(retryFor = {ObjectOptimisticLockingFailureException.class, OptimisticLockingFailureException.class}, backoff = @Backoff(delay = 100, multiplier = 2)) 109 | public User saveSystemUser(User user) { 110 | if (!user.isSystemAccount()) { 111 | throw new IllegalArgumentException("System account email must ends with @sparkle.system"); 112 | } 113 | return userRepository.save(user); 114 | } 115 | 116 | public UserDto toDto(User user) { 117 | return UserDto.builder() 118 | .id(user.getId()) 119 | .avatar(user.getAvatar()) 120 | .nickname(user.getNickname()) 121 | .registerAt(user.getRegisterAt()) 122 | .lastSeenAt(user.getLastSeenAt()) 123 | .bannedAt(user.getBannedAt()) 124 | .bannedReason(user.getBannedReason()) 125 | .email(user.getEmail()) 126 | .build(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/module/banhistory/BanHistoryService.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.module.banhistory; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import com.ghostchu.btn.sparkle.module.banhistory.internal.BanHistory; 6 | import com.ghostchu.btn.sparkle.module.banhistory.internal.BanHistoryRepository; 7 | import com.ghostchu.btn.sparkle.module.torrent.TorrentService; 8 | import com.ghostchu.btn.sparkle.util.ipdb.GeoIPManager; 9 | import com.ghostchu.btn.sparkle.util.paging.SparklePage; 10 | import jakarta.transaction.Transactional; 11 | import lombok.SneakyThrows; 12 | import org.springframework.cache.annotation.Cacheable; 13 | import org.springframework.data.domain.Page; 14 | import org.springframework.data.domain.PageRequest; 15 | import org.springframework.data.jpa.domain.Specification; 16 | import org.springframework.data.jpa.repository.Modifying; 17 | import org.springframework.stereotype.Service; 18 | 19 | import java.io.Serializable; 20 | import java.net.InetAddress; 21 | import java.time.OffsetDateTime; 22 | import java.util.List; 23 | 24 | @Service 25 | public class BanHistoryService { 26 | private final TorrentService torrentService; 27 | private final BanHistoryRepository banHistoryRepository; 28 | private final ObjectMapper objectMapper; 29 | 30 | public BanHistoryService(BanHistoryRepository banHistoryRepository, 31 | TorrentService torrentService, GeoIPManager geoIPManager, ObjectMapper objectMapper) { 32 | this.banHistoryRepository = banHistoryRepository; 33 | this.torrentService = torrentService; 34 | this.objectMapper = objectMapper; 35 | // AtomicInteger count = new AtomicInteger(); 36 | // CompletableFuture.runAsync(() -> { 37 | // while(true){ 38 | // var list = banHistoryRepository.findByGeoIPIsNull(PageRequest.of(0, 100000)); 39 | // System.out.println("BanHistory: Get "+list.getSize()+" ips"); 40 | // if(list.isEmpty()) { 41 | // System.out.println("BanHistory OK!"); 42 | // break; 43 | // } 44 | // var handled =list.stream().parallel().peek(b -> b.setGeoIP(geoIPManager.geoData(b.getPeerIp()))) 45 | // .toList(); 46 | // System.gc(); 47 | // System.out.println("Mapped "+handled.size()+" records"); 48 | // banHistoryRepository.saveAll(handled); 49 | // count.addAndGet(handled.size()); 50 | // System.out.println("BanHistory: Already successfully handled "+count.get()+" records, Execute next batch"); 51 | // } 52 | // }); 53 | } 54 | 55 | @Cacheable(value = "banHistoryMetrics#1800000", key = "#from+'-'+#to") 56 | public BanHistoryMetrics getMetrics(OffsetDateTime from, OffsetDateTime to) { 57 | return new BanHistoryMetrics( 58 | banHistoryRepository.count(), 59 | banHistoryRepository.countByInsertTimeBetween(from, to) 60 | ); 61 | } 62 | 63 | public BanHistoryDto toDto(BanHistory banHistory) { 64 | try { 65 | return BanHistoryDto.builder() 66 | .id(banHistory.getId()) 67 | .appId(banHistory.getUserApplication().getAppId()) 68 | .submitId(banHistory.getSubmitId()) 69 | .peerIp(banHistory.getPeerIp().getHostAddress()) 70 | .peerPort(banHistory.getPeerPort()) 71 | .peerId(banHistory.getPeerId()) 72 | .peerClientName(banHistory.getPeerClientName()) 73 | .torrent(torrentService.toDto(banHistory.getTorrent())) 74 | .fromPeerTraffic(banHistory.getFromPeerTraffic()) 75 | .fromPeerTrafficSpeed(banHistory.getFromPeerTrafficSpeed()) 76 | .toPeerTraffic(banHistory.getToPeerTraffic()) 77 | .toPeerTrafficSpeed(banHistory.getToPeerTrafficSpeed()) 78 | .peerProgress(banHistory.getPeerProgress()) 79 | .downloaderProgress(banHistory.getDownloaderProgress()) 80 | .flags(banHistory.getFlags()) 81 | .btnBan(banHistory.getBtnBan()) 82 | .module(banHistory.getModule()) 83 | .rule(banHistory.getRule()) 84 | .banUniqueId(banHistory.getBanUniqueId()) 85 | .structuredData(objectMapper.writeValueAsString(banHistory.getStructuredData())) 86 | .build(); 87 | } catch (JsonProcessingException e) { 88 | throw new RuntimeException(e); 89 | } 90 | } 91 | 92 | @Modifying 93 | @Transactional 94 | public void saveBanHistories(List banHistoryList) { 95 | banHistoryRepository.saveAll(banHistoryList); 96 | } 97 | 98 | public List queryRecentRelatedBanHistory(InetAddress peerIp, String torrentIdentifier, int amount) { 99 | Page banHistories = banHistoryRepository 100 | .findByPeerIpAndTorrent_IdentifierAndInsertTimeGreaterThanEqualOrderByInsertTimeDesc 101 | (peerIp, torrentIdentifier, OffsetDateTime.now().minusDays(7) 102 | , PageRequest.ofSize(amount)); 103 | return banHistories.toList(); 104 | } 105 | 106 | public SparklePage queryRecent(PageRequest pageable) { 107 | var page = banHistoryRepository.findByOrderByInsertTimeDesc(pageable); 108 | return new SparklePage<>(page, dat -> dat.map(this::toDto)); 109 | } 110 | 111 | public SparklePage complexQuery(Specification specification, PageRequest pageable) { 112 | var page = banHistoryRepository.findAll(specification, pageable); 113 | return new SparklePage<>(page, dat -> dat.map(this::toDto)); 114 | } 115 | 116 | public record BanHistoryMetrics( 117 | long total, 118 | long recent 119 | ) implements Serializable { 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/main/java/com/ghostchu/btn/sparkle/filter/GzipBodyDecompressFilter.java: -------------------------------------------------------------------------------- 1 | package com.ghostchu.btn.sparkle.filter; 2 | 3 | import jakarta.servlet.*; 4 | import jakarta.servlet.annotation.WebFilter; 5 | import jakarta.servlet.http.HttpServletRequest; 6 | import jakarta.servlet.http.HttpServletRequestWrapper; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.stereotype.Component; 9 | 10 | import java.io.*; 11 | import java.util.zip.GZIPInputStream; 12 | 13 | // https://stackoverflow.com/a/26226246/1778299 14 | @Slf4j 15 | @WebFilter 16 | @Component 17 | public class GzipBodyDecompressFilter implements Filter { 18 | @Override 19 | public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { 20 | HttpServletRequest httpServletRequest = (HttpServletRequest) request; 21 | 22 | String contentEncoding = httpServletRequest.getHeader("Content-Encoding"); 23 | if (contentEncoding != null && contentEncoding.contains("gzip")) { 24 | try { 25 | final InputStream decompressStream = StreamHelper.decompressStream(httpServletRequest.getInputStream()); 26 | httpServletRequest = new HttpServletRequestWrapper(httpServletRequest) { 27 | @Override 28 | public ServletInputStream getInputStream() { 29 | return new ServletInputStream() { 30 | 31 | private ReadListener readListener; 32 | @Override public boolean isFinished() { 33 | try { 34 | return decompressStream.available() <= 0; 35 | } catch (IOException e) { 36 | throw new RuntimeException(e); 37 | } 38 | } 39 | 40 | @Override public boolean isReady() { 41 | try { 42 | return decompressStream.available() > 0; 43 | } catch (IOException e) { 44 | throw new RuntimeException(e); 45 | } 46 | } 47 | 48 | @Override public void setReadListener(ReadListener readListener) { 49 | this.readListener = readListener; 50 | } 51 | 52 | public ReadListener getReadListener() { 53 | return readListener; 54 | } 55 | 56 | public int read() throws IOException { 57 | return decompressStream.read(); 58 | } 59 | 60 | @Override 61 | public void close() throws IOException { 62 | super.close(); 63 | decompressStream.close(); 64 | } 65 | }; 66 | } 67 | 68 | @Override 69 | public BufferedReader getReader() { 70 | return new BufferedReader(new InputStreamReader(decompressStream)); 71 | } 72 | }; 73 | } catch (IOException e) { 74 | log.error("Error while handling the request: {}: {}", e.getClass().getName(), e.getMessage()); 75 | } 76 | } 77 | chain.doFilter(httpServletRequest, response); 78 | } 79 | 80 | public static class StreamHelper 81 | { 82 | 83 | /** 84 | * Gzip magic number, fixed values in the beginning to identify the gzip 85 | * format
86 | * http://www.gzip.org/zlib/rfc-gzip.html#file-format 87 | */ 88 | private static final byte GZIP_ID1 = 0x1f; 89 | /** 90 | * Gzip magic number, fixed values in the beginning to identify the gzip 91 | * format
92 | * http://www.gzip.org/zlib/rfc-gzip.html#file-format 93 | */ 94 | private static final byte GZIP_ID2 = (byte) 0x8b; 95 | 96 | /** 97 | * Maximum uncompressed size to prevent zip bomb 98 | */ 99 | private static final long MAX_UNCOMPRESSED_SIZE = 64 * 1024 * 1024; // 64MB 100 | 101 | /** 102 | * Return decompression input stream if needed. 103 | * 104 | * @param input 105 | * original stream 106 | * @return decompression stream 107 | * @throws IOException 108 | * exception while reading the input 109 | */ 110 | public static InputStream decompressStream(InputStream input) throws IOException 111 | { 112 | PushbackInputStream pushbackInput = new PushbackInputStream(input, 2); 113 | 114 | byte[] signature = new byte[2]; 115 | pushbackInput.read(signature); 116 | pushbackInput.unread(signature); 117 | 118 | if (signature[0] == GZIP_ID1 && signature[1] == GZIP_ID2) 119 | { 120 | return new MonitoredGZIPInputStream(pushbackInput, MAX_UNCOMPRESSED_SIZE); 121 | } 122 | return pushbackInput; 123 | } 124 | 125 | /** 126 | * A GZIPInputStream that monitors the total number of bytes read 127 | * and throws an exception if the size exceeds a specified limit. 128 | */ 129 | private static class MonitoredGZIPInputStream extends GZIPInputStream { 130 | private final long maxSize; 131 | private long totalRead; 132 | 133 | public MonitoredGZIPInputStream(InputStream in, long maxSize) throws IOException { 134 | super(in); 135 | this.maxSize = maxSize; 136 | this.totalRead = 0; 137 | } 138 | 139 | @Override 140 | public int read(byte[] b, int off, int len) throws IOException { 141 | int bytesRead = super.read(b, off, len); 142 | if (bytesRead > 0) { 143 | totalRead += bytesRead; 144 | if (totalRead > maxSize) { 145 | throw new IOException("Uncompressed data exceeds the maximum allowed size of " + maxSize + " bytes."); 146 | } 147 | } 148 | return bytesRead; 149 | } 150 | 151 | @Override 152 | public int read() throws IOException { 153 | int byteRead = super.read(); 154 | if (byteRead != -1) { 155 | totalRead++; 156 | if (totalRead > maxSize) { 157 | throw new IOException("Uncompressed data exceeds the maximum allowed size of " + maxSize + " bytes."); 158 | } 159 | } 160 | return byteRead; 161 | } 162 | } 163 | } 164 | } --------------------------------------------------------------------------------