mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Remove nginx and serve Angular directly from Spring Boot (#2662)
* feat: remove nginx and serve Angular directly from Spring Boot * fix: handle null values in EnabledFields deserialization from persisted JSON * fix(migration): auto-repair failed Flyway migrations on startup * fix(migration): replace DB triggers with app-level orphan cleanup * fix: restore default port to 6060 for backwards compatibility * fix: align all port references to 6060 and add OPDS compression MIME types * fix: resolve NG0101 recursive tick error in book browser selection --------- Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
17
Dockerfile
17
Dockerfile
@@ -26,6 +26,9 @@ RUN --mount=type=cache,target=/home/gradle/.gradle \
|
|||||||
|
|
||||||
COPY ./booklore-api/src /springboot-app/src
|
COPY ./booklore-api/src /springboot-app/src
|
||||||
|
|
||||||
|
# Copy Angular dist into Spring Boot static resources so it's embedded in the JAR
|
||||||
|
COPY --from=angular-build /angular-app/dist/booklore/browser /springboot-app/src/main/resources/static
|
||||||
|
|
||||||
# Inject version into application.yaml using yq
|
# Inject version into application.yaml using yq
|
||||||
ARG APP_VERSION
|
ARG APP_VERSION
|
||||||
RUN apk add --no-cache yq && \
|
RUN apk add --no-cache yq && \
|
||||||
@@ -53,14 +56,14 @@ LABEL org.opencontainers.image.title="BookLore" \
|
|||||||
|
|
||||||
ENV JAVA_TOOL_OPTIONS="-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+UseStringDeduplication -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
|
ENV JAVA_TOOL_OPTIONS="-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+UseStringDeduplication -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
|
||||||
|
|
||||||
RUN apk update && apk add nginx gettext su-exec
|
RUN apk update && apk add --no-cache su-exec
|
||||||
|
|
||||||
COPY ./nginx.conf /etc/nginx/nginx.conf
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
COPY --from=angular-build /angular-app/dist/booklore/browser /usr/share/nginx/html
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
COPY --from=springboot-build /springboot-app/build/libs/booklore-api-0.0.1-SNAPSHOT.jar /app/app.jar
|
COPY --from=springboot-build /springboot-app/build/libs/booklore-api-0.0.1-SNAPSHOT.jar /app/app.jar
|
||||||
COPY start.sh /start.sh
|
|
||||||
RUN chmod +x /start.sh
|
|
||||||
|
|
||||||
EXPOSE 8080 80
|
ARG BOOKLORE_PORT=6060
|
||||||
|
EXPOSE ${BOOKLORE_PORT}
|
||||||
|
|
||||||
CMD ["/start.sh"]
|
ENTRYPOINT ["entrypoint.sh"]
|
||||||
|
CMD ["java", "-jar", "/app/app.jar"]
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -214,10 +214,9 @@ Create a `.env` file in your project directory:
|
|||||||
|
|
||||||
```ini
|
```ini
|
||||||
# 🎯 BookLore Application Settings
|
# 🎯 BookLore Application Settings
|
||||||
APP_USER_ID=0
|
APP_USER_ID=1000
|
||||||
APP_GROUP_ID=0
|
APP_GROUP_ID=1000
|
||||||
TZ=Etc/UTC
|
TZ=Etc/UTC
|
||||||
BOOKLORE_PORT=6060
|
|
||||||
|
|
||||||
# 🗄️ Database Connection (BookLore)
|
# 🗄️ Database Connection (BookLore)
|
||||||
DATABASE_URL=jdbc:mariadb://mariadb:3306/booklore
|
DATABASE_URL=jdbc:mariadb://mariadb:3306/booklore
|
||||||
@@ -252,18 +251,17 @@ services:
|
|||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- DATABASE_USERNAME=${DB_USER}
|
- DATABASE_USERNAME=${DB_USER}
|
||||||
- DATABASE_PASSWORD=${DB_PASSWORD}
|
- DATABASE_PASSWORD=${DB_PASSWORD}
|
||||||
- BOOKLORE_PORT=${BOOKLORE_PORT}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
mariadb:
|
mariadb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "${BOOKLORE_PORT}:${BOOKLORE_PORT}"
|
- "6060:6060"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./books:/books
|
- ./books:/books
|
||||||
- ./bookdrop:/bookdrop
|
- ./bookdrop:/bookdrop
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: wget -q -O - http://localhost:${BOOKLORE_PORT}/api/v1/healthcheck
|
test: wget -q -O - http://localhost:6060/api/v1/healthcheck
|
||||||
interval: 60s
|
interval: 60s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.booklore.config;
|
||||||
|
|
||||||
|
import org.flywaydb.core.api.exception.FlywayValidateException;
|
||||||
|
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class FlywayConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
FlywayMigrationStrategy flywayMigrationStrategy() {
|
||||||
|
return flyway -> {
|
||||||
|
try {
|
||||||
|
flyway.migrate();
|
||||||
|
} catch (FlywayValidateException e) {
|
||||||
|
flyway.repair();
|
||||||
|
flyway.migrate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,17 @@ import org.booklore.interceptor.KomgaCleanInterceptor;
|
|||||||
import org.booklore.interceptor.KomgaEnabledInterceptor;
|
import org.booklore.interceptor.KomgaEnabledInterceptor;
|
||||||
import org.booklore.interceptor.OpdsEnabledInterceptor;
|
import org.booklore.interceptor.OpdsEnabledInterceptor;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.task.VirtualThreadTaskExecutor;
|
import org.springframework.core.task.VirtualThreadTaskExecutor;
|
||||||
import org.springframework.data.web.config.EnableSpringDataWebSupport;
|
import org.springframework.data.web.config.EnableSpringDataWebSupport;
|
||||||
import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
|
import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO;
|
import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO;
|
||||||
|
|
||||||
@@ -27,6 +33,22 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
configurer.setTaskExecutor(new VirtualThreadTaskExecutor("mvc-async-"));
|
configurer.setTaskExecutor(new VirtualThreadTaskExecutor("mvc-async-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
|
registry.addResourceHandler("/**")
|
||||||
|
.addResourceLocations("classpath:/static/")
|
||||||
|
.resourceChain(true)
|
||||||
|
.addResolver(new PathResourceResolver() {
|
||||||
|
@Override
|
||||||
|
protected Resource getResource(String resourcePath, Resource location) throws IOException {
|
||||||
|
Resource resource = location.createRelative(resourcePath);
|
||||||
|
return resource.exists() && resource.isReadable()
|
||||||
|
? resource
|
||||||
|
: new ClassPathResource("/static/index.html");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
registry.addInterceptor(opdsEnabledInterceptor)
|
registry.addInterceptor(opdsEnabledInterceptor)
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ public class SecurityConfig {
|
|||||||
publicEndpoints.addAll(Arrays.asList(SWAGGER_ENDPOINTS));
|
publicEndpoints.addAll(Arrays.asList(SWAGGER_ENDPOINTS));
|
||||||
}
|
}
|
||||||
http
|
http
|
||||||
|
.securityMatcher("/api/**", "/komga/**", "/ws/**")
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
@@ -216,6 +217,17 @@ public class SecurityConfig {
|
|||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Order(9)
|
||||||
|
public SecurityFilterChain staticResourcesSecurityChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
|
||||||
|
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
|
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
|
||||||
AuthenticationManagerBuilder auth = http.getSharedObject(AuthenticationManagerBuilder.class);
|
AuthenticationManagerBuilder auth = http.getSharedObject(AuthenticationManagerBuilder.class);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.booklore.model.dto.request;
|
package org.booklore.model.dto.request;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonSetter;
|
||||||
|
import com.fasterxml.jackson.annotation.Nulls;
|
||||||
import org.booklore.model.enums.MetadataProvider;
|
import org.booklore.model.enums.MetadataProvider;
|
||||||
import org.booklore.model.enums.MetadataReplaceMode;
|
import org.booklore.model.enums.MetadataReplaceMode;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
@@ -88,70 +90,72 @@ public class MetadataRefreshOptions {
|
|||||||
@Builder
|
@Builder
|
||||||
public static class EnabledFields {
|
public static class EnabledFields {
|
||||||
// All fields default to true so metadata fetcher updates fields by default
|
// All fields default to true so metadata fetcher updates fields by default
|
||||||
// unless explicitly disabled by the user
|
// unless explicitly disabled by the user.
|
||||||
@Builder.Default
|
// @JsonSetter(nulls = Nulls.SKIP) ensures that null values in persisted JSON
|
||||||
|
// (e.g. from older versions missing newer fields) are ignored, preserving defaults.
|
||||||
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean title = true;
|
private boolean title = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean subtitle = true;
|
private boolean subtitle = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean description = true;
|
private boolean description = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean authors = true;
|
private boolean authors = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean publisher = true;
|
private boolean publisher = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean publishedDate = true;
|
private boolean publishedDate = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean seriesName = true;
|
private boolean seriesName = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean seriesNumber = true;
|
private boolean seriesNumber = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean seriesTotal = true;
|
private boolean seriesTotal = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean isbn13 = true;
|
private boolean isbn13 = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean isbn10 = true;
|
private boolean isbn10 = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean language = true;
|
private boolean language = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean categories = true;
|
private boolean categories = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean cover = true;
|
private boolean cover = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean pageCount = true;
|
private boolean pageCount = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean asin = true;
|
private boolean asin = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean goodreadsId = true;
|
private boolean goodreadsId = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean comicvineId = true;
|
private boolean comicvineId = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean hardcoverId = true;
|
private boolean hardcoverId = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean googleId = true;
|
private boolean googleId = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean lubimyczytacId = true;
|
private boolean lubimyczytacId = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean amazonRating = true;
|
private boolean amazonRating = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean amazonReviewCount = true;
|
private boolean amazonReviewCount = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean goodreadsRating = true;
|
private boolean goodreadsRating = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean goodreadsReviewCount = true;
|
private boolean goodreadsReviewCount = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean hardcoverRating = true;
|
private boolean hardcoverRating = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean hardcoverReviewCount = true;
|
private boolean hardcoverReviewCount = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean lubimyczytacRating = true;
|
private boolean lubimyczytacRating = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean ranobedbId = true;
|
private boolean ranobedbId = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean ranobedbRating = true;
|
private boolean ranobedbRating = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean moods = true;
|
private boolean moods = true;
|
||||||
@Builder.Default
|
@Builder.Default @JsonSetter(nulls = Nulls.SKIP)
|
||||||
private boolean tags = true;
|
private boolean tags = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ package org.booklore.repository;
|
|||||||
|
|
||||||
import org.booklore.model.entity.ComicCharacterEntity;
|
import org.booklore.model.entity.ComicCharacterEntity;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ComicCharacterRepository extends JpaRepository<ComicCharacterEntity, Long> {
|
public interface ComicCharacterRepository extends JpaRepository<ComicCharacterEntity, Long> {
|
||||||
|
|
||||||
Optional<ComicCharacterEntity> findByName(String name);
|
Optional<ComicCharacterEntity> findByName(String name);
|
||||||
|
|
||||||
|
@Modifying(flushAutomatically = true)
|
||||||
|
@Query(value = "DELETE FROM comic_character WHERE id NOT IN (SELECT DISTINCT character_id FROM comic_metadata_character_mapping)", nativeQuery = true)
|
||||||
|
void deleteOrphaned();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ package org.booklore.repository;
|
|||||||
|
|
||||||
import org.booklore.model.entity.ComicCreatorEntity;
|
import org.booklore.model.entity.ComicCreatorEntity;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ComicCreatorRepository extends JpaRepository<ComicCreatorEntity, Long> {
|
public interface ComicCreatorRepository extends JpaRepository<ComicCreatorEntity, Long> {
|
||||||
|
|
||||||
Optional<ComicCreatorEntity> findByName(String name);
|
Optional<ComicCreatorEntity> findByName(String name);
|
||||||
|
|
||||||
|
@Modifying(flushAutomatically = true)
|
||||||
|
@Query(value = "DELETE FROM comic_creator WHERE id NOT IN (SELECT DISTINCT creator_id FROM comic_metadata_creator_mapping)", nativeQuery = true)
|
||||||
|
void deleteOrphaned();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ package org.booklore.repository;
|
|||||||
|
|
||||||
import org.booklore.model.entity.ComicLocationEntity;
|
import org.booklore.model.entity.ComicLocationEntity;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ComicLocationRepository extends JpaRepository<ComicLocationEntity, Long> {
|
public interface ComicLocationRepository extends JpaRepository<ComicLocationEntity, Long> {
|
||||||
|
|
||||||
Optional<ComicLocationEntity> findByName(String name);
|
Optional<ComicLocationEntity> findByName(String name);
|
||||||
|
|
||||||
|
@Modifying(flushAutomatically = true)
|
||||||
|
@Query(value = "DELETE FROM comic_location WHERE id NOT IN (SELECT DISTINCT location_id FROM comic_metadata_location_mapping)", nativeQuery = true)
|
||||||
|
void deleteOrphaned();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ package org.booklore.repository;
|
|||||||
|
|
||||||
import org.booklore.model.entity.ComicTeamEntity;
|
import org.booklore.model.entity.ComicTeamEntity;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ComicTeamRepository extends JpaRepository<ComicTeamEntity, Long> {
|
public interface ComicTeamRepository extends JpaRepository<ComicTeamEntity, Long> {
|
||||||
|
|
||||||
Optional<ComicTeamEntity> findByName(String name);
|
Optional<ComicTeamEntity> findByName(String name);
|
||||||
|
|
||||||
|
@Modifying(flushAutomatically = true)
|
||||||
|
@Query(value = "DELETE FROM comic_team WHERE id NOT IN (SELECT DISTINCT team_id FROM comic_metadata_team_mapping)", nativeQuery = true)
|
||||||
|
void deleteOrphaned();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,12 +146,14 @@ public class SettingPersistenceHelper {
|
|||||||
.comicvineId(nullProvider)
|
.comicvineId(nullProvider)
|
||||||
.hardcoverId(nullProvider)
|
.hardcoverId(nullProvider)
|
||||||
.googleId(nullProvider)
|
.googleId(nullProvider)
|
||||||
|
.lubimyczytacId(nullProvider)
|
||||||
.amazonRating(nullProvider)
|
.amazonRating(nullProvider)
|
||||||
.amazonReviewCount(nullProvider)
|
.amazonReviewCount(nullProvider)
|
||||||
.goodreadsRating(nullProvider)
|
.goodreadsRating(nullProvider)
|
||||||
.goodreadsReviewCount(nullProvider)
|
.goodreadsReviewCount(nullProvider)
|
||||||
.hardcoverRating(nullProvider)
|
.hardcoverRating(nullProvider)
|
||||||
.hardcoverReviewCount(nullProvider)
|
.hardcoverReviewCount(nullProvider)
|
||||||
|
.lubimyczytacRating(nullProvider)
|
||||||
.ranobedbId(nullProvider)
|
.ranobedbId(nullProvider)
|
||||||
.ranobedbRating(nullProvider)
|
.ranobedbRating(nullProvider)
|
||||||
.moods(nullProvider)
|
.moods(nullProvider)
|
||||||
@@ -179,12 +181,14 @@ public class SettingPersistenceHelper {
|
|||||||
.comicvineId(true)
|
.comicvineId(true)
|
||||||
.hardcoverId(true)
|
.hardcoverId(true)
|
||||||
.googleId(true)
|
.googleId(true)
|
||||||
|
.lubimyczytacId(true)
|
||||||
.amazonRating(true)
|
.amazonRating(true)
|
||||||
.amazonReviewCount(true)
|
.amazonReviewCount(true)
|
||||||
.goodreadsRating(true)
|
.goodreadsRating(true)
|
||||||
.goodreadsReviewCount(true)
|
.goodreadsReviewCount(true)
|
||||||
.hardcoverRating(true)
|
.hardcoverRating(true)
|
||||||
.hardcoverReviewCount(true)
|
.hardcoverReviewCount(true)
|
||||||
|
.lubimyczytacRating(true)
|
||||||
.ranobedbId(false)
|
.ranobedbId(false)
|
||||||
.ranobedbRating(false)
|
.ranobedbRating(false)
|
||||||
.moods(true)
|
.moods(true)
|
||||||
|
|||||||
@@ -438,6 +438,11 @@ public class BookMetadataUpdater {
|
|||||||
if (comicDto.getLocationsLocked() != null) c.setLocationsLocked(comicDto.getLocationsLocked());
|
if (comicDto.getLocationsLocked() != null) c.setLocationsLocked(comicDto.getLocationsLocked());
|
||||||
|
|
||||||
comicMetadataRepository.save(c);
|
comicMetadataRepository.save(c);
|
||||||
|
|
||||||
|
comicCharacterRepository.deleteOrphaned();
|
||||||
|
comicTeamRepository.deleteOrphaned();
|
||||||
|
comicLocationRepository.deleteOrphaned();
|
||||||
|
comicCreatorRepository.deleteOrphaned();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateComicCharacters(ComicMetadataEntity c, Set<String> characters, MetadataReplaceMode mode) {
|
private void updateComicCharacters(ComicMetadataEntity c, Set<String> characters, MetadataReplaceMode mode) {
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ app:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
forward-headers-strategy: native
|
forward-headers-strategy: native
|
||||||
port: 8080
|
port: ${BOOKLORE_PORT:6060}
|
||||||
|
compression:
|
||||||
|
enabled: true
|
||||||
|
mime-types: text/html,text/css,application/javascript,application/json,image/svg+xml,application/xml,application/atom+xml
|
||||||
|
min-response-size: 1024
|
||||||
tomcat:
|
tomcat:
|
||||||
relaxed-query-chars: '[,],%,{,},|'
|
relaxed-query-chars: '[,],%,{,},|'
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
DROP TRIGGER IF EXISTS trg_cleanup_orphaned_comic_character;
|
|
||||||
CREATE TRIGGER trg_cleanup_orphaned_comic_character
|
|
||||||
AFTER DELETE
|
|
||||||
ON comic_metadata_character_mapping
|
|
||||||
FOR EACH ROW
|
|
||||||
DELETE
|
|
||||||
FROM comic_character
|
|
||||||
WHERE id = OLD.character_id
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_character_mapping WHERE character_id = OLD.character_id);
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_cleanup_orphaned_comic_team;
|
|
||||||
CREATE TRIGGER trg_cleanup_orphaned_comic_team
|
|
||||||
AFTER DELETE
|
|
||||||
ON comic_metadata_team_mapping
|
|
||||||
FOR EACH ROW
|
|
||||||
DELETE
|
|
||||||
FROM comic_team
|
|
||||||
WHERE id = OLD.team_id
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_team_mapping WHERE team_id = OLD.team_id);
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_cleanup_orphaned_comic_location;
|
|
||||||
CREATE TRIGGER trg_cleanup_orphaned_comic_location
|
|
||||||
AFTER DELETE
|
|
||||||
ON comic_metadata_location_mapping
|
|
||||||
FOR EACH ROW
|
|
||||||
DELETE
|
|
||||||
FROM comic_location
|
|
||||||
WHERE id = OLD.location_id
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_location_mapping WHERE location_id = OLD.location_id);
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_cleanup_orphaned_comic_creator;
|
|
||||||
CREATE TRIGGER trg_cleanup_orphaned_comic_creator
|
|
||||||
AFTER DELETE
|
|
||||||
ON comic_metadata_creator_mapping
|
|
||||||
FOR EACH ROW
|
|
||||||
DELETE
|
|
||||||
FROM comic_creator
|
|
||||||
WHERE id = OLD.creator_id
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_creator_mapping WHERE creator_id = OLD.creator_id);
|
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
"index": "/index.html",
|
"index": "/index.html",
|
||||||
"navigationUrls": [
|
"navigationUrls": [
|
||||||
"/**",
|
"/**",
|
||||||
"!/api/**"
|
"!/api/**",
|
||||||
|
"!/komga/**"
|
||||||
],
|
],
|
||||||
"assetGroups": [
|
"assetGroups": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AfterViewInit, ApplicationRef, ChangeDetectorRef, Component, HostListener, inject, NgZone, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
import {AfterViewInit, ChangeDetectorRef, Component, HostListener, inject, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||||
import {ActivatedRoute, NavigationStart, Router} from '@angular/router';
|
import {ActivatedRoute, NavigationStart, Router} from '@angular/router';
|
||||||
import {ConfirmationService, MenuItem, MessageService} from 'primeng/api';
|
import {ConfirmationService, MenuItem, MessageService} from 'primeng/api';
|
||||||
import {PageTitleService} from '../../../../shared/service/page-title.service';
|
import {PageTitleService} from '../../../../shared/service/page-title.service';
|
||||||
@@ -105,8 +105,6 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
protected appSettingsService = inject(AppSettingsService);
|
protected appSettingsService = inject(AppSettingsService);
|
||||||
|
|
||||||
private cdr = inject(ChangeDetectorRef);
|
private cdr = inject(ChangeDetectorRef);
|
||||||
private ngZone = inject(NgZone);
|
|
||||||
private appRef = inject(ApplicationRef);
|
|
||||||
private activatedRoute = inject(ActivatedRoute);
|
private activatedRoute = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private messageService = inject(MessageService);
|
private messageService = inject(MessageService);
|
||||||
@@ -477,11 +475,8 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.bookSelectionService.selectedBooks$
|
this.bookSelectionService.selectedBooks$
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(selectedBooks => {
|
.subscribe(selectedBooks => {
|
||||||
this.ngZone.run(() => {
|
this.selectedCount = selectedBooks.size;
|
||||||
this.selectedCount = selectedBooks.size;
|
this.cdr.detectChanges();
|
||||||
this.cdr.detectChanges();
|
|
||||||
this.appRef.tick();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
API_CONFIG: {
|
API_CONFIG: {
|
||||||
BASE_URL: 'http://localhost:8080',
|
BASE_URL: 'http://localhost:6060',
|
||||||
BROKER_URL: 'ws://localhost:8080/ws',
|
BROKER_URL: 'ws://localhost:6060/ws',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ services:
|
|||||||
image: gradle:9.3.1-jdk25-alpine
|
image: gradle:9.3.1-jdk25-alpine
|
||||||
command: sh -c "cd /booklore-api && ./gradlew bootRun"
|
command: sh -c "cd /booklore-api && ./gradlew bootRun"
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8080}:8080"
|
- "${BACKEND_PORT:-6060}:6060"
|
||||||
- "${REMOTE_DEBUG_PORT:-5005}:5005"
|
- "${REMOTE_DEBUG_PORT:-5005}:5005"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=jdbc:mariadb://backend_db:3306/booklore
|
- DATABASE_URL=jdbc:mariadb://backend_db:3306/booklore
|
||||||
|
|||||||
15
entrypoint.sh
Normal file
15
entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
USER_ID="${USER_ID:-1000}"
|
||||||
|
GROUP_ID="${GROUP_ID:-1000}"
|
||||||
|
|
||||||
|
# Create group and user if they don't exist
|
||||||
|
if ! getent group "$GROUP_ID" >/dev/null 2>&1; then
|
||||||
|
addgroup -g "$GROUP_ID" -S booklore
|
||||||
|
fi
|
||||||
|
if ! getent passwd "$USER_ID" >/dev/null 2>&1; then
|
||||||
|
adduser -u "$USER_ID" -G "$(getent group "$GROUP_ID" | cut -d: -f1)" -S -D booklore
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec su-exec "$USER_ID:$GROUP_ID" "$@"
|
||||||
@@ -62,8 +62,6 @@ spec:
|
|||||||
containerPort: {{ .Values.service.port }}
|
containerPort: {{ .Values.service.port }}
|
||||||
#protocol: TCP
|
#protocol: TCP
|
||||||
env:
|
env:
|
||||||
- name: BOOKLORE_PORT
|
|
||||||
value: "6060"
|
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
value: jdbc:mariadb://{{ .Release.Name }}-mariadb:{{ .Values.mariadb.primary.service.ports.mysql }}/{{ .Values.mariadb.auth.database }}
|
value: jdbc:mariadb://{{ .Release.Name }}-mariadb:{{ .Values.mariadb.primary.service.ports.mysql }}/{{ .Values.mariadb.auth.database }}
|
||||||
- name: DATABASE_USERNAME
|
- name: DATABASE_USERNAME
|
||||||
|
|||||||
@@ -6,22 +6,19 @@ services:
|
|||||||
# image: ghcr.io/booklore-app/booklore:latest
|
# image: ghcr.io/booklore-app/booklore:latest
|
||||||
container_name: booklore
|
container_name: booklore
|
||||||
environment:
|
environment:
|
||||||
- USER_ID=0 # Modify this if the volume's ownership is not root
|
- USER_ID=1000 # Modify this if the volume's ownership is not root
|
||||||
- GROUP_ID=0 # Modify this if the volume's ownership is not root
|
- GROUP_ID=1000 # Modify this if the volume's ownership is not root
|
||||||
- TZ=Etc/UTC
|
- TZ=Etc/UTC
|
||||||
- DATABASE_URL=jdbc:mariadb://mariadb:3306/booklore # Only modify this if you're familiar with JDBC and your database setup
|
- DATABASE_URL=jdbc:mariadb://mariadb:3306/booklore # Only modify this if you're familiar with JDBC and your database setup
|
||||||
- DATABASE_USERNAME=booklore # Must match MYSQL_USER defined in the mariadb container
|
- DATABASE_USERNAME=booklore # Must match MYSQL_USER defined in the mariadb container
|
||||||
- DATABASE_PASSWORD=your_secure_password # Use a strong password; must match MYSQL_PASSWORD defined in the mariadb container
|
- DATABASE_PASSWORD=your_secure_password # Use a strong password; must match MYSQL_PASSWORD defined in the mariadb container
|
||||||
- BOOKLORE_PORT=6060 # Port BookLore listens on inside the container; must match container port below
|
|
||||||
- SWAGGER_ENABLED=false # Enable or disable Swagger UI (API docs). Set to 'true' to allow access; 'false' to block access (recommended for production).
|
- SWAGGER_ENABLED=false # Enable or disable Swagger UI (API docs). Set to 'true' to allow access; 'false' to block access (recommended for production).
|
||||||
- FORCE_DISABLE_OIDC=false # Set to 'true' to force-disable OIDC and allow internal login, regardless of UI config
|
- FORCE_DISABLE_OIDC=false # Set to 'true' to force-disable OIDC and allow internal login, regardless of UI config
|
||||||
depends_on:
|
depends_on:
|
||||||
mariadb:
|
mariadb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "6060:6060" # HostPort:ContainerPort → Keep both numbers the same, and also ensure the container port matches BOOKLORE_PORT, no exceptions.
|
- "6060:6060" # HostPort:ContainerPort — container port is 6060 by default (configurable via BOOKLORE_PORT env var)
|
||||||
# All three (host port, container port, BOOKLORE_PORT) must be identical for BookLore to function properly.
|
|
||||||
# Example: To expose on host port 7070, set BOOKLORE_PORT=7070 and use "7070:7070".
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data # Application data (settings, metadata, cache, etc.). Persist this folder to retain your library state across container restarts.
|
- ./data:/app/data # Application data (settings, metadata, cache, etc.). Persist this folder to retain your library state across container restarts.
|
||||||
- ./books:/books # Primary book library folder. Mount your collection here so BookLore can access and organize your books.
|
- ./books:/books # Primary book library folder. Mount your collection here so BookLore can access and organize your books.
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ Environment=TZ=Etc/UTC
|
|||||||
Secret=booklore_db_pass,type=env,target=DATABASE_PASSWORD
|
Secret=booklore_db_pass,type=env,target=DATABASE_PASSWORD
|
||||||
Environment=DATABASE_URL=jdbc:mariadb://localhost:3306/booklore
|
Environment=DATABASE_URL=jdbc:mariadb://localhost:3306/booklore
|
||||||
Environment=DATABASE_USERNAME=booklore
|
Environment=DATABASE_USERNAME=booklore
|
||||||
Environment=BOOKLORE_PORT=6060
|
|
||||||
|
|
||||||
HealthCmd=wget -q -O - http://localhost:6060/api/v1/healthcheck
|
HealthCmd=wget -q -O - http://localhost:6060/api/v1/healthcheck
|
||||||
HealthInterval=1m
|
HealthInterval=1m
|
||||||
HealthRetries=5
|
HealthRetries=5
|
||||||
|
|||||||
89
nginx.conf
89
nginx.conf
@@ -1,89 +0,0 @@
|
|||||||
# the events block is required
|
|
||||||
events {}
|
|
||||||
|
|
||||||
http {
|
|
||||||
# include the default mime.types to map file extensions to MIME types
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
|
|
||||||
# Set max request body size to 100MB (adjust as needed)
|
|
||||||
client_max_body_size 1000M;
|
|
||||||
|
|
||||||
proxy_buffer_size 128k;
|
|
||||||
proxy_buffers 4 256k;
|
|
||||||
proxy_busy_buffers_size 256k;
|
|
||||||
large_client_header_buffers 8 32k;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen ${BOOKLORE_PORT};
|
|
||||||
listen [::]:${BOOKLORE_PORT};
|
|
||||||
|
|
||||||
# Set the root directory for the server (Angular app)
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
|
|
||||||
# Set the default index file for the server
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Serve Angular UI
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html; # Fallback to index.html for Angular routing
|
|
||||||
|
|
||||||
location ~* \.mjs$ {
|
|
||||||
# target only *.mjs files
|
|
||||||
# now we can safely override types since we are only
|
|
||||||
# targeting a single file extension.
|
|
||||||
types {
|
|
||||||
text/javascript mjs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy API requests that start with /api/ to the backend
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://localhost:8080;
|
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
|
|
||||||
# Disable buffering for streaming responses
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
proxy_set_header Connection '';
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
chunked_transfer_encoding on;
|
|
||||||
|
|
||||||
# Set timeouts for long-running connections
|
|
||||||
proxy_read_timeout 86400s;
|
|
||||||
proxy_send_timeout 86400s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy API requests that start with /komga/ to the backend
|
|
||||||
# we can't use /api because Komga clients expect /komga/api/v1
|
|
||||||
location /komga/ {
|
|
||||||
proxy_pass http://localhost:8080;
|
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
|
|
||||||
# Disable buffering for streaming responses
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
proxy_set_header Connection '';
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
chunked_transfer_encoding on;
|
|
||||||
|
|
||||||
# Set timeouts for long-running connections
|
|
||||||
proxy_read_timeout 86400s;
|
|
||||||
proxy_send_timeout 86400s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy WebSocket requests (ws://) to the backend
|
|
||||||
location /ws {
|
|
||||||
proxy_pass http://localhost:8080/ws; # Backend WebSocket endpoint
|
|
||||||
proxy_http_version 1.1; # Ensure HTTP 1.1 is used for WebSocket connection
|
|
||||||
proxy_set_header Upgrade $http_upgrade; # Pass the upgrade header
|
|
||||||
proxy_set_header Connection 'upgrade'; # Pass the connection header
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
start.sh
21
start.sh
@@ -1,21 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Set default and export so envsubst sees it
|
|
||||||
: "${BOOKLORE_PORT:=6060}"
|
|
||||||
export BOOKLORE_PORT
|
|
||||||
|
|
||||||
# Use envsubst safely
|
|
||||||
TMP_CONF="/tmp/nginx.conf.tmp"
|
|
||||||
envsubst '${BOOKLORE_PORT}' < /etc/nginx/nginx.conf > "$TMP_CONF"
|
|
||||||
|
|
||||||
# Move to final location
|
|
||||||
mv "$TMP_CONF" /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Disable nginx IPv6 listener when IPv6 is disabled on host
|
|
||||||
[ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null)" = "0" ] || sed -i '/^[[:space:]]*listen \[\:\:\]:6060;$/d' /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Start nginx in background
|
|
||||||
nginx -g 'daemon off;' &
|
|
||||||
|
|
||||||
# Start Spring Boot in foreground
|
|
||||||
su-exec ${USER_ID:-0}:${GROUP_ID:-0} java -jar /app/app.jar
|
|
||||||
Reference in New Issue
Block a user