Skip to content

Commit 4f60f79

Browse files
authored
Merge pull request #1593 from booklore-app/develop
Merge develop into master for the release
2 parents 1c68185 + a0dcfe6 commit 4f60f79

File tree

134 files changed

+2816
-638
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

134 files changed

+2816
-638
lines changed

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
![Docker Pulls](https://img.shields.io/docker/pulls/booklore/booklore?color=2496ED)
77
[![Join us on Discord](https://img.shields.io/badge/Chat-Discord-5865F2?logo=discord&style=flat)](https://discord.gg/Ee5hd458Uz)
88
[![Open Collective backers and sponsors](https://img.shields.io/opencollective/all/booklore?label=Open%20Collective&logo=opencollective&color=7FADF2)](https://opencollective.com/booklore)
9-
[![Venmo](https://img.shields.io/badge/Venmo-Donate-008CFF?logo=venmo)](https://venmo.com/AdityaChandel)
109
> 🚨 **Important Announcement:**
1110
> Docker images have moved to new repositories:
1211
> - Docker Hub: `https://hub.docker.com/r/booklore/booklore`

booklore-api/src/main/java/com/adityachandel/booklore/config/security/JwtUtils.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import javax.crypto.SecretKey;
1717
import java.nio.charset.StandardCharsets;
18+
import java.time.Instant;
1819
import java.util.Date;
1920

2021
@Slf4j
@@ -25,9 +26,9 @@ public class JwtUtils {
2526

2627
private final JwtSecretService jwtSecretService;
2728
@Getter
28-
public final long accessTokenExpirationMs = 1000L * 60 * 60 * 10; // 10 hours
29+
public static final long accessTokenExpirationMs = 1000L * 60 * 60 * 10; // 10 hours
2930
@Getter
30-
public final long refreshTokenExpirationMs = 1000L * 60 * 60 * 24 * 30; // 30 days
31+
public static final long refreshTokenExpirationMs = 1000L * 60 * 60 * 24 * 30; // 30 days
3132

3233
private SecretKey getSigningKey() {
3334
String secretKey = jwtSecretService.getSecret();
@@ -36,12 +37,13 @@ private SecretKey getSigningKey() {
3637

3738
public String generateToken(BookLoreUserEntity user, boolean isRefreshToken) {
3839
long expirationTime = isRefreshToken ? refreshTokenExpirationMs : accessTokenExpirationMs;
40+
Instant now = Instant.now();
3941
return Jwts.builder()
4042
.subject(user.getUsername())
4143
.claim("userId", user.getId())
4244
.claim("isDefaultPassword", user.isDefaultPassword())
43-
.issuedAt(new Date())
44-
.expiration(new Date(System.currentTimeMillis() + expirationTime))
45+
.issuedAt(Date.from(now))
46+
.expiration(Date.from(now.plusMillis(expirationTime)))
4547
.signWith(getSigningKey(), Jwts.SIG.HS256)
4648
.compact();
4749
}

booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
import org.springframework.context.annotation.Configuration;
1212
import org.springframework.core.annotation.Order;
1313
import org.springframework.security.authentication.AuthenticationManager;
14-
import org.springframework.security.authentication.AuthenticationProvider;
15-
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
1614
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
1715
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
1816
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -154,17 +152,10 @@ public SecurityFilterChain jwtApiSecurityChain(HttpSecurity http) throws Excepti
154152

155153
@Bean
156154
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
157-
return http.getSharedObject(AuthenticationManagerBuilder.class)
158-
.authenticationProvider(authenticationProvider())
159-
.build();
160-
}
161-
162-
@Bean
163-
public AuthenticationProvider authenticationProvider() {
164-
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
165-
provider.setUserDetailsService(opdsUserDetailsService);
166-
provider.setPasswordEncoder(passwordEncoder());
167-
return provider;
155+
// Configure the shared AuthenticationManagerBuilder with the UserDetailsService and PasswordEncoder
156+
AuthenticationManagerBuilder auth = http.getSharedObject(AuthenticationManagerBuilder.class);
157+
auth.userDetailsService(opdsUserDetailsService).passwordEncoder(passwordEncoder());
158+
return auth.build();
168159
}
169160

170161
@Bean

booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/CoverJwtFilter.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import org.springframework.web.filter.OncePerRequestFilter;
2222

2323
import java.io.IOException;
24+
import java.time.Instant;
2425

2526
@AllArgsConstructor
2627
@Component
@@ -75,7 +76,7 @@ private void authenticateOidcUser(String token, HttpServletRequest request) thro
7576
var processor = dynamicOidcJwtProcessor.getProcessor();
7677
var claimsSet = processor.process(token, null);
7778

78-
if (claimsSet.getExpirationTime() == null || claimsSet.getExpirationTime().before(new java.util.Date())) {
79+
if (claimsSet.getExpirationTime() == null || claimsSet.getExpirationTime().toInstant().isBefore(Instant.now())) {
7980
throw new RuntimeException("OIDC token expired or invalid");
8081
}
8182

booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/DualJwtAuthenticationFilter.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import org.springframework.web.filter.OncePerRequestFilter;
2727

2828
import java.io.IOException;
29-
import java.util.Date;
29+
import java.time.Instant;
3030
import java.util.List;
3131
import java.util.concurrent.ConcurrentHashMap;
3232
import java.util.concurrent.ConcurrentMap;
@@ -101,8 +101,7 @@ private void authenticateOidcUser(String token, HttpServletRequest request) {
101101
OidcProviderDetails providerDetails = appSettingService.getAppSettings().getOidcProviderDetails();
102102
JWTClaimsSet claimsSet = dynamicOidcJwtProcessor.getProcessor().process(token, null);
103103

104-
Date expirationTime = claimsSet.getExpirationTime();
105-
if (expirationTime == null || expirationTime.before(new Date())) {
104+
if (claimsSet.getExpirationTime() == null || claimsSet.getExpirationTime().toInstant().isBefore(Instant.now())) {
106105
log.warn("OIDC token is expired or missing exp claim");
107106
throw ApiError.GENERIC_UNAUTHORIZED.createException("Token has expired or is invalid.");
108107
}

booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/AuthenticationService.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import org.springframework.security.crypto.password.PasswordEncoder;
2222
import org.springframework.stereotype.Service;
2323

24-
import java.util.Date;
24+
import java.time.Instant;
2525
import java.util.List;
2626
import java.util.Map;
2727
import java.util.Optional;
@@ -109,7 +109,7 @@ public ResponseEntity<Map<String, String>> loginRemote(String name, String usern
109109

110110
Optional<BookLoreUserEntity> user = userRepository.findByUsername(username);
111111
if (user.isEmpty() && appProperties.getRemoteAuth().isCreateNewUsers()) {
112-
user = Optional.of(userProvisioningService.provisionRemoteUser(name, username, email, groups));
112+
user = Optional.of(userProvisioningService.provisionRemoteUserFromHeaders(name, username, email, groups));
113113
}
114114

115115
if (user.isEmpty()) {
@@ -126,7 +126,7 @@ public ResponseEntity<Map<String, String>> loginUser(BookLoreUserEntity user) {
126126
RefreshTokenEntity refreshTokenEntity = RefreshTokenEntity.builder()
127127
.user(user)
128128
.token(refreshToken)
129-
.expiryDate(new Date(System.currentTimeMillis() + jwtUtils.getRefreshTokenExpirationMs()))
129+
.expiryDate(Instant.now().plusMillis(jwtUtils.getRefreshTokenExpirationMs()))
130130
.revoked(false)
131131
.build();
132132

@@ -142,21 +142,21 @@ public ResponseEntity<Map<String, String>> loginUser(BookLoreUserEntity user) {
142142
public ResponseEntity<Map<String, String>> refreshToken(String token) {
143143
RefreshTokenEntity storedToken = refreshTokenRepository.findByToken(token).orElseThrow(() -> ApiError.INVALID_CREDENTIALS.createException("Refresh token not found"));
144144

145-
if (storedToken.isRevoked() || storedToken.getExpiryDate().before(new Date()) || !jwtUtils.validateToken(token)) {
145+
if (storedToken.isRevoked() || storedToken.getExpiryDate().isBefore(Instant.now()) || !jwtUtils.validateToken(token)) {
146146
throw ApiError.INVALID_CREDENTIALS.createException("Invalid or expired refresh token");
147147
}
148148

149149
BookLoreUserEntity user = storedToken.getUser();
150150

151151
storedToken.setRevoked(true);
152-
storedToken.setRevocationDate(new Date());
152+
storedToken.setRevocationDate(Instant.now());
153153
refreshTokenRepository.save(storedToken);
154154

155155
String newRefreshToken = jwtUtils.generateRefreshToken(user);
156156
RefreshTokenEntity newRefreshTokenEntity = RefreshTokenEntity.builder()
157157
.user(user)
158158
.token(newRefreshToken)
159-
.expiryDate(new Date(System.currentTimeMillis() + jwtUtils.getRefreshTokenExpirationMs()))
159+
.expiryDate(Instant.now().plusMillis(jwtUtils.getRefreshTokenExpirationMs()))
160160
.revoked(false)
161161
.build();
162162

booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/DynamicOidcJwtProcessor.java

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,19 @@
33
import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails;
44
import com.adityachandel.booklore.service.appsettings.AppSettingService;
55
import com.nimbusds.jose.JWSAlgorithm;
6-
import com.nimbusds.jose.jwk.source.DefaultJWKSetCache;
7-
import com.nimbusds.jose.jwk.source.JWKSetCache;
86
import com.nimbusds.jose.jwk.source.JWKSource;
9-
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
7+
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
108
import com.nimbusds.jose.proc.JWSKeySelector;
119
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
1210
import com.nimbusds.jose.proc.SecurityContext;
13-
import com.nimbusds.jose.util.DefaultResourceRetriever;
1411
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
1512
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
1613
import lombok.RequiredArgsConstructor;
1714
import lombok.extern.slf4j.Slf4j;
1815
import org.springframework.stereotype.Component;
1916

20-
import java.net.URL;
17+
import java.net.URI;
2118
import java.time.Duration;
22-
import java.util.concurrent.TimeUnit;
2319

2420
@Slf4j
2521
@Component
@@ -49,15 +45,14 @@ private ConfigurableJWTProcessor<SecurityContext> buildProcessor(String issuerUr
4945
String discoveryUri = providerDetails.getIssuerUri() + "/.well-known/openid-configuration";
5046
log.info("Fetching OIDC discovery document from {}", discoveryUri);
5147

52-
URL jwksUrl = fetchJwksUri(discoveryUri);
53-
54-
DefaultResourceRetriever resourceRetriever = new DefaultResourceRetriever(2000, 2000);
48+
URI jwksUri = fetchJwksUri(discoveryUri);
5549

5650
Duration ttl = Duration.ofHours(6);
5751
Duration refresh = Duration.ofHours(1);
58-
JWKSetCache jwkSetCache = new DefaultJWKSetCache(ttl.toMillis(), refresh.toMillis(), TimeUnit.MILLISECONDS);
59-
60-
JWKSource<SecurityContext> jwkSource = new RemoteJWKSet<>(jwksUrl, resourceRetriever, jwkSetCache);
52+
53+
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(jwksUri.toURL())
54+
.cache(ttl.toMillis(), refresh.toMillis())
55+
.build();
6156

6257
JWSKeySelector<SecurityContext> keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource);
6358
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
@@ -66,17 +61,21 @@ private ConfigurableJWTProcessor<SecurityContext> buildProcessor(String issuerUr
6661
return jwtProcessor;
6762
}
6863

69-
private URL fetchJwksUri(String discoveryUri) throws Exception {
64+
private URI fetchJwksUri(String discoveryUri) throws Exception {
7065
var restClient = org.springframework.web.client.RestClient.create();
7166
var discoveryDoc = restClient.get()
7267
.uri(discoveryUri)
7368
.retrieve()
7469
.body(new org.springframework.core.ParameterizedTypeReference<java.util.Map<String, Object>>() {});
7570

76-
String jwksUri = (String) discoveryDoc.get("jwks_uri");
77-
if (jwksUri == null || jwksUri.isEmpty()) {
71+
if (discoveryDoc == null) {
72+
throw new IllegalStateException("Failed to fetch OIDC discovery document.");
73+
}
74+
75+
String jwksUriStr = (String) discoveryDoc.get("jwks_uri");
76+
if (jwksUriStr == null || jwksUriStr.isEmpty()) {
7877
throw new IllegalStateException("jwks_uri not found in OIDC discovery document.");
7978
}
80-
return new URL(jwksUri);
79+
return new URI(jwksUriStr);
8180
}
8281
}

booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import jakarta.servlet.http.HttpServletResponse;
1212
import lombok.AllArgsConstructor;
1313
import org.springframework.core.io.Resource;
14-
import org.springframework.http.ContentDisposition;
1514
import org.springframework.http.HttpHeaders;
1615
import org.springframework.http.MediaType;
1716
import org.springframework.http.ResponseEntity;
@@ -21,14 +20,18 @@
2120
import org.springframework.web.bind.annotation.RestController;
2221

2322
import java.io.IOException;
23+
import java.net.URLEncoder;
2424
import java.nio.charset.StandardCharsets;
25+
import java.util.regex.Pattern;
2526

2627
@Tag(name = "Book Media", description = "Endpoints for retrieving book media such as covers, thumbnails, and pages")
2728
@AllArgsConstructor
2829
@RestController
2930
@RequestMapping("/api/v1/media")
3031
public class BookMediaController {
3132

33+
private static final Pattern NON_ASCII_PATTERN = Pattern.compile("[^\\x00-\\x7F]");
34+
3235
private final BookService bookService;
3336
private final PdfReaderService pdfReaderService;
3437
private final CbxReaderService cbxReaderService;
@@ -78,10 +81,7 @@ public void getCbxPage(
7881
public ResponseEntity<Resource> getBookdropCover(
7982
@Parameter(description = "ID of the bookdrop file") @PathVariable long bookdropId) {
8083
Resource file = bookDropService.getBookdropCover(bookdropId);
81-
String contentDisposition = ContentDisposition.builder("inline")
82-
.filename("cover.jpg", StandardCharsets.UTF_8)
83-
.build()
84-
.toString();
84+
String contentDisposition = "inline; filename=\"cover.jpg\"; filename*=UTF-8''cover.jpg";
8585
return (file != null)
8686
? ResponseEntity.ok()
8787
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
@@ -105,11 +105,10 @@ public ResponseEntity<Resource> getBackgroundImage() {
105105
? MediaType.IMAGE_PNG
106106
: MediaType.IMAGE_JPEG;
107107

108-
String contentDisposition = ContentDisposition.builder("inline")
109-
.filename(filename, StandardCharsets.UTF_8)
110-
.build()
111-
.toString();
112-
108+
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
109+
String fallbackFilename = NON_ASCII_PATTERN.matcher(filename).replaceAll("_");
110+
String contentDisposition = String.format("inline; filename=\"%s\"; filename*=UTF-8''%s",
111+
fallbackFilename, encodedFilename);
113112
return ResponseEntity.ok()
114113
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
115114
.contentType(mediaType)

booklore-api/src/main/java/com/adityachandel/booklore/controller/KoboSettingsController.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,15 @@ public ResponseEntity<Void> toggleSync(
4747
koboService.setSyncEnabled(enabled);
4848
return ResponseEntity.noContent().build();
4949
}
50+
51+
@Operation(summary = "Update progress thresholds", description = "Update the progress thresholds for marking books as reading or finished. Requires sync permission or admin.")
52+
@ApiResponse(responseCode = "200", description = "Thresholds updated successfully")
53+
@PutMapping("/progress-thresholds")
54+
@PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()")
55+
public ResponseEntity<KoboSyncSettings> updateProgressThresholds(
56+
@Parameter(description = "Progress percentage to mark as reading (0-100)") @RequestParam(required = false) Float readingThreshold,
57+
@Parameter(description = "Progress percentage to mark as finished (0-100)") @RequestParam(required = false) Float finishedThreshold) {
58+
KoboSyncSettings updated = koboService.updateProgressThresholds(readingThreshold, finishedThreshold);
59+
return ResponseEntity.ok(updated);
60+
}
5061
}

booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ public ResponseEntity<BookMetadata> updateMetadata(
7272
.updateThumbnail(true)
7373
.mergeCategories(mergeCategories)
7474
.replaceMode(MetadataReplaceMode.REPLACE_ALL)
75+
.mergeMoods(true)
76+
.mergeTags(true)
7577
.build();
7678

7779
bookMetadataUpdater.setBookMetadata(context);

0 commit comments

Comments
 (0)