Skip to content

Commit 5df7add

Browse files
authored
Merge pull request #2111 from booklore-app/develop
Merge develop into master for release
2 parents 43a095e + 998b07d commit 5df7add

File tree

113 files changed

+4262
-1109
lines changed

Some content is hidden

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

113 files changed

+4262
-1109
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### *Your Personal Library, Beautifully Organized*
66

7+
**🌐 Official Website: [https://booklore.org](https://booklore.org/)**
8+
79
<p align="center">
810
<img src="assets/demo.gif" alt="BookLore Demo" width="800px" style="border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);" />
911
</p>

booklore-api/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ dependencies {
5252

5353
// --- Book & Image Processing ---
5454
implementation 'org.apache.pdfbox:pdfbox:3.0.6'
55+
implementation 'org.apache.pdfbox:pdfbox-io:3.0.6'
5556
implementation 'org.apache.pdfbox:xmpbox:3.0.6'
5657
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
5758
implementation 'com.github.jai-imageio:jai-imageio-core:1.4.0'

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,21 @@ public boolean canEditMetadata() {
6565
return user != null && user.getPermissions().isCanEditMetadata();
6666
}
6767

68+
public boolean canBulkEditMetadata() {
69+
var user = getCurrentUser();
70+
return user != null && user.getPermissions().isCanBulkEditMetadata();
71+
}
72+
73+
public boolean canBulkLockUnlockMetadata() {
74+
var user = getCurrentUser();
75+
return user != null && user.getPermissions().isCanBulkLockUnlockMetadata();
76+
}
77+
78+
public boolean canBulkRegenerateCover() {
79+
var user = getCurrentUser();
80+
return user != null && user.getPermissions().isCanBulkRegenerateCover();
81+
}
82+
6883
public boolean canEmailBook() {
6984
var user = getCurrentUser();
7085
return user != null && user.getPermissions().isCanEmailBook();

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

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
1010
import com.adityachandel.booklore.model.entity.RefreshTokenEntity;
1111
import com.adityachandel.booklore.model.enums.ProvisioningMethod;
12+
import com.adityachandel.booklore.model.enums.UserPermission;
1213
import com.adityachandel.booklore.repository.RefreshTokenRepository;
1314
import com.adityachandel.booklore.repository.UserRepository;
1415
import com.adityachandel.booklore.service.user.DefaultSettingInitializer;
@@ -60,16 +61,9 @@ public BookLoreUser getSystemUser() {
6061

6162
private BookLoreUser createSystemUser() {
6263
BookLoreUser.UserPermissions permissions = new BookLoreUser.UserPermissions();
63-
permissions.setAdmin(true);
64-
permissions.setCanUpload(true);
65-
permissions.setCanDownload(true);
66-
permissions.setCanEditMetadata(true);
67-
permissions.setCanManageLibrary(true);
68-
permissions.setCanSyncKoReader(true);
69-
permissions.setCanSyncKobo(true);
70-
permissions.setCanEmailBook(true);
71-
permissions.setCanDeleteBook(true);
72-
permissions.setCanAccessOpds(true);
64+
for (UserPermission permission : UserPermission.values()) {
65+
permission.setInDto(permissions, true);
66+
}
7367

7468
return BookLoreUser.builder()
7569
.id(-1L)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ public ResponseEntity<Resource> getBackgroundImage() {
107107
? MediaType.IMAGE_PNG
108108
: MediaType.IMAGE_JPEG;
109109

110+
if (filename == null) {
111+
return ResponseEntity.badRequest().build();
112+
}
113+
110114
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
111115
String fallbackFilename = NON_ASCII_PATTERN.matcher(filename).replaceAll("_");
112116
String contentDisposition = String.format("inline; filename=\"%s\"; filename*=UTF-8''%s",

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import com.adityachandel.booklore.model.dto.response.SuccessResponse;
44
import io.swagger.v3.oas.annotations.Operation;
5-
import io.swagger.v3.oas.annotations.Parameter;
65
import io.swagger.v3.oas.annotations.responses.ApiResponse;
76
import io.swagger.v3.oas.annotations.tags.Tag;
87
import org.springframework.http.ResponseEntity;

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public ResponseEntity<BookMetadata> updateMetadata(
8383
@Operation(summary = "Bulk edit book metadata", description = "Bulk update metadata for multiple books. Requires metadata edit permission or admin.")
8484
@ApiResponse(responseCode = "204", description = "Bulk metadata updated successfully")
8585
@PutMapping("/bulk-edit-metadata")
86-
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
86+
@PreAuthorize("@securityUtil.canBulkEditMetadata() or @securityUtil.isAdmin()")
8787
public ResponseEntity<Void> bulkEditMetadata(
8888
@Parameter(description = "Bulk metadata update request") @RequestBody BulkMetadataUpdateRequest bulkMetadataUpdateRequest) {
8989
boolean mergeCategories = bulkMetadataUpdateRequest.isMergeCategories();
@@ -120,7 +120,7 @@ public ResponseEntity<BookMetadata> uploadCoverFromUrl(
120120
@Operation(summary = "Toggle all metadata locks", description = "Toggle all metadata locks for books. Requires metadata edit permission or admin.")
121121
@ApiResponse(responseCode = "200", description = "Metadata locks toggled successfully")
122122
@PutMapping("/metadata/toggle-all-lock")
123-
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
123+
@PreAuthorize("@securityUtil.canBulkLockUnlockMetadata() or @securityUtil.isAdmin()")
124124
public ResponseEntity<List<BookMetadata>> toggleAllMetadata(
125125
@Parameter(description = "Toggle all lock request") @RequestBody ToggleAllLockRequest request) {
126126
return ResponseEntity.ok(bookMetadataService.toggleAllLock(request));
@@ -139,7 +139,7 @@ public ResponseEntity<List<BookMetadata>> toggleFieldLocks(
139139
@Operation(summary = "Regenerate all covers", description = "Regenerate covers for all books. Requires metadata edit permission or admin.")
140140
@ApiResponse(responseCode = "204", description = "Covers regenerated successfully")
141141
@PostMapping("/regenerate-covers")
142-
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
142+
@PreAuthorize("@securityUtil.canBulkRegenerateCover() or @securityUtil.isAdmin()")
143143
public void regenerateCovers() {
144144
bookMetadataService.regenerateCovers();
145145
}
@@ -154,10 +154,20 @@ public void regenerateCovers(
154154
bookMetadataService.regenerateCover(bookId);
155155
}
156156

157+
@Operation(summary = "Generate custom cover for a book", description = "Generate a custom cover for a specific book based on its metadata. Requires metadata edit permission or admin.")
158+
@ApiResponse(responseCode = "204", description = "Custom cover generated successfully")
159+
@PostMapping("/{bookId}/generate-custom-cover")
160+
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
161+
@CheckBookAccess(bookIdParam = "bookId")
162+
public void generateCustomCover(
163+
@Parameter(description = "ID of the book") @PathVariable Long bookId) {
164+
bookMetadataService.generateCustomCover(bookId);
165+
}
166+
157167
@Operation(summary = "Regenerate covers for selected books", description = "Regenerate covers for a list of books. Requires metadata edit permission or admin.")
158168
@ApiResponse(responseCode = "204", description = "Cover regeneration started successfully")
159169
@PostMapping("/bulk-regenerate-covers")
160-
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
170+
@PreAuthorize("@securityUtil.canBulkRegenerateCover() or @securityUtil.isAdmin()")
161171
public ResponseEntity<Void> regenerateCoversForBooks(
162172
@Parameter(description = "List of book IDs") @Validated @RequestBody BulkBookIdsRequest request) {
163173
bookMetadataService.regenerateCoversForBooks(request.getBookIds());
@@ -167,7 +177,7 @@ public ResponseEntity<Void> regenerateCoversForBooks(
167177
@Operation(summary = "Upload cover image for multiple books", description = "Upload a cover image to apply to multiple books. Requires metadata edit permission or admin.")
168178
@ApiResponse(responseCode = "204", description = "Cover upload started successfully")
169179
@PostMapping("/bulk-upload-cover")
170-
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
180+
@PreAuthorize("@securityUtil.canBulkEditMetadata() or @securityUtil.isAdmin()")
171181
public ResponseEntity<Void> bulkUploadCover(
172182
@Parameter(description = "Cover image file") @RequestParam("file") MultipartFile file,
173183
@Parameter(description = "Comma-separated book IDs") @RequestParam("bookIds") @jakarta.validation.constraints.NotEmpty java.util.Set<Long> bookIds) {
@@ -195,7 +205,7 @@ public ResponseEntity<List<CoverImage>> getImages(
195205
@Operation(summary = "Consolidate metadata", description = "Merge metadata values. Requires metadata edit permission or admin.")
196206
@ApiResponse(responseCode = "204", description = "Metadata consolidated successfully")
197207
@PostMapping("/metadata/manage/consolidate")
198-
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
208+
@PreAuthorize("@securityUtil.canBulkEditMetadata() or @securityUtil.isAdmin()")
199209
public ResponseEntity<Void> mergeMetadata(
200210
@Parameter(description = "Merge metadata request") @Validated @RequestBody MergeMetadataRequest request) {
201211
metadataManagementService.consolidateMetadata(request.getMetadataType(), request.getTargetValues(), request.getValuesToMerge());
@@ -205,7 +215,7 @@ public ResponseEntity<Void> mergeMetadata(
205215
@Operation(summary = "Delete metadata values", description = "Delete metadata values. Requires metadata edit permission or admin.")
206216
@ApiResponse(responseCode = "204", description = "Metadata deleted successfully")
207217
@PostMapping("/metadata/manage/delete")
208-
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
218+
@PreAuthorize("@securityUtil.canBulkEditMetadata() or @securityUtil.isAdmin()")
209219
public ResponseEntity<Void> deleteMetadata(
210220
@Parameter(description = "Delete metadata request") @Validated @RequestBody DeleteMetadataRequest request) {
211221
metadataManagementService.deleteMetadata(request.getMetadataType(), request.getValuesToDelete());

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ public ResponseEntity<List<TaskInfo>> getAvailableTasks() {
3737
}
3838

3939
@PostMapping("/start")
40-
@PreAuthorize("@securityUtil.canAccessTaskManager() or @securityUtil.isAdmin()")
4140
public ResponseEntity<TaskCreateResponse> startTask(@RequestBody TaskCreateRequest request) {
4241
TaskCreateResponse response = service.runAsUser(request);
4342
if (response.getStatus() == TaskStatus.ACCEPTED) {

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,15 @@ public ResponseEntity<List<ReadingSessionHeatmapResponse>> getHeatmapForYear(@Re
3434
@Operation(summary = "Get reading session timeline for a week", description = "Returns reading sessions grouped by book for calendar timeline view")
3535
@ApiResponses({
3636
@ApiResponse(responseCode = "200", description = "Timeline data retrieved successfully"),
37-
@ApiResponse(responseCode = "400", description = "Invalid week, month, or year"),
37+
@ApiResponse(responseCode = "400", description = "Invalid week or year"),
3838
@ApiResponse(responseCode = "401", description = "Unauthorized")
3939
})
4040
@GetMapping("/timeline")
4141
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
4242
public ResponseEntity<List<ReadingSessionTimelineResponse>> getTimelineForWeek(
4343
@RequestParam int year,
44-
@RequestParam int month,
4544
@RequestParam int week) {
46-
List<ReadingSessionTimelineResponse> timelineData = readingSessionService.getSessionTimelineForWeek(year, month, week);
45+
List<ReadingSessionTimelineResponse> timelineData = readingSessionService.getSessionTimelineForWeek(year, week);
4746
return ResponseEntity.ok(timelineData);
4847
}
4948

@@ -111,4 +110,3 @@ public ResponseEntity<List<CompletionTimelineResponse>> getCompletionTimeline(@R
111110
return ResponseEntity.ok(timeline);
112111
}
113112
}
114-

booklore-api/src/main/java/com/adityachandel/booklore/crons/CronService.java

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,47 @@
55
import com.adityachandel.booklore.model.dto.InstallationPing;
66
import com.adityachandel.booklore.service.TelemetryService;
77
import com.adityachandel.booklore.service.appsettings.AppSettingService;
8+
import jakarta.annotation.PostConstruct;
89
import lombok.AllArgsConstructor;
910
import lombok.extern.slf4j.Slf4j;
1011
import org.springframework.scheduling.annotation.Scheduled;
1112
import org.springframework.stereotype.Service;
1213
import org.springframework.web.client.RestClient;
1314

15+
import java.time.Instant;
16+
import java.time.temporal.ChronoUnit;
1417
import java.util.concurrent.TimeUnit;
1518

1619
@Service
1720
@AllArgsConstructor
1821
@Slf4j
1922
public class CronService {
2023

24+
private static final String LAST_TELEMETRY_KEY = "last_telemetry_sent";
25+
private static final String LAST_PING_KEY = "last_ping_sent";
26+
private static final long INTERVAL_HOURS = 24;
27+
2128
private final AppProperties appProperties;
2229
private final TelemetryService telemetryService;
2330
private final RestClient restClient;
2431
private final AppSettingService appSettingService;
2532

33+
@PostConstruct
34+
public void initScheduledTasks() {
35+
checkAndRunTelemetry();
36+
checkAndRunPing();
37+
}
38+
2639
@Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS, initialDelay = 24)
2740
public void sendTelemetryData() {
2841
if (appSettingService.getAppSettings().isTelemetryEnabled()) {
2942
try {
3043
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/ingest";
3144
BookloreTelemetry telemetry = telemetryService.collectTelemetry();
3245
postData(url, telemetry);
46+
appSettingService.saveSetting(LAST_TELEMETRY_KEY, Instant.now().toString());
3347
} catch (Exception e) {
34-
log.warn("Failed to send telemetry data: {}", e.getMessage());
48+
log.warn("Failed to up stats: {}", e.getMessage());
3549
}
3650
}
3751
}
@@ -42,8 +56,9 @@ public void sendPing() {
4256
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/heartbeat";
4357
InstallationPing ping = telemetryService.getInstallationPing();
4458
postData(url, ping);
59+
appSettingService.saveSetting(LAST_PING_KEY, Instant.now().toString());
4560
} catch (Exception e) {
46-
log.warn("Failed to send installation ping: {}", e.getMessage());
61+
log.warn("Failed to up ping: {}", e.getMessage());
4762
}
4863
}
4964

@@ -54,4 +69,50 @@ private void postData(String url, Object body) {
5469
.retrieve()
5570
.body(String.class);
5671
}
72+
73+
74+
private void checkAndRunTelemetry() {
75+
if (!appSettingService.getAppSettings().isTelemetryEnabled()) {
76+
return;
77+
}
78+
79+
String lastRunStr = appSettingService.getSettingValue(LAST_TELEMETRY_KEY);
80+
if (shouldRunTask(lastRunStr)) {
81+
log.info("Running stats on startup (last run: {})", lastRunStr);
82+
sendTelemetryData();
83+
}
84+
}
85+
86+
private void checkAndRunPing() {
87+
String lastRunStr = appSettingService.getSettingValue(LAST_PING_KEY);
88+
if (shouldRunTask(lastRunStr)) {
89+
log.info("Running ping on startup (last run: {})", lastRunStr);
90+
sendPing();
91+
}
92+
}
93+
94+
/**
95+
* Determines if a task should run immediately on startup.
96+
* Returns false for new installations (no last run recorded) to follow normal schedule.
97+
* Returns true if more than INTERVAL_HOURS have passed since the last run,
98+
* preventing data gaps when the server restarts close to scheduled execution time.
99+
*
100+
* Example: Telemetry normally runs at 2:00 AM daily. If the server restarts at 1:55 AM,
101+
* the scheduled task would reset and not run until 2:00 AM the next day (48 hours later).
102+
* This method checks if 24+ hours have passed since the last run and executes immediately
103+
* on startup if needed, ensuring data is sent at 1:55 AM instead of waiting another 24 hours.
104+
*/
105+
private boolean shouldRunTask(String lastRunStr) {
106+
if (lastRunStr == null || lastRunStr.isEmpty()) {
107+
return false;
108+
}
109+
try {
110+
Instant lastRun = Instant.parse(lastRunStr);
111+
Instant threshold = Instant.now().minus(INTERVAL_HOURS, ChronoUnit.HOURS);
112+
return lastRun.isBefore(threshold);
113+
} catch (Exception e) {
114+
log.warn("Failed to parse last run timestamp: {}", e.getMessage());
115+
return false;
116+
}
117+
}
57118
}

0 commit comments

Comments
 (0)