diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt new file mode 100644 index 00000000..36627e9d --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt @@ -0,0 +1,348 @@ +package org.gotson.komga.interfaces.api + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.swagger.v3.oas.annotations.Operation +import jakarta.servlet.http.HttpServletRequest +import org.apache.commons.io.FilenameUtils +import org.apache.commons.io.IOUtils +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.EntryNotFoundException +import org.gotson.komga.domain.model.ImageConversionException +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.MediaUnsupportedException +import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD +import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.infrastructure.image.ImageType +import org.gotson.komga.infrastructure.mediacontainer.ContentDetector +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault +import org.gotson.komga.interfaces.api.persistence.BookDtoRepository +import org.springframework.core.io.FileSystemResource +import org.springframework.http.ContentDisposition +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.util.MimeTypeUtils +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.ServletWebRequest +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import java.io.FileNotFoundException +import java.io.OutputStream +import java.nio.charset.StandardCharsets +import java.nio.file.NoSuchFileException +import kotlin.io.path.name + +private val logger = KotlinLogging.logger {} +private val FONT_EXTENSIONS = listOf("otf", "woff", "woff2", "eot", "ttf", "svg") + +@RestController +@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) +class CommonBookController( + private val mediaRepository: MediaRepository, + private val bookRepository: BookRepository, + private val bookDtoRepository: BookDtoRepository, + private val seriesMetadataRepository: SeriesMetadataRepository, + private val bookLifecycle: BookLifecycle, + private val bookAnalyzer: BookAnalyzer, + private val contentRestrictionChecker: ContentRestrictionChecker, + private val contentDetector: ContentDetector, +) { + fun getWebPubManifestInternal( + principal: KomgaPrincipal, + bookId: String, + webPubGenerator: WebPubGenerator, + ) = + mediaRepository.findByIdOrNull(bookId)?.let { media -> + when (org.gotson.komga.domain.model.MediaType.fromMediaType(media.mediaType)?.profile) { + MediaProfile.DIVINA -> getWebPubManifestDivinaInternal(principal, bookId, webPubGenerator) + MediaProfile.PDF -> getWebPubManifestPdfInternal(principal, bookId, webPubGenerator) + MediaProfile.EPUB -> getWebPubManifestEpubInternal(principal, bookId, webPubGenerator) + null -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + fun getWebPubManifestEpubInternal( + principal: KomgaPrincipal, + bookId: String, + webPubGenerator: WebPubGenerator, + ) = + bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto -> + if (bookDto.media.mediaProfile != MediaProfile.EPUB.name) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile") + contentRestrictionChecker.checkContentRestriction(principal.user, bookDto) + webPubGenerator.toManifestEpub( + bookDto, + mediaRepository.findById(bookId), + seriesMetadataRepository.findById(bookDto.seriesId), + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + fun getWebPubManifestPdfInternal( + principal: KomgaPrincipal, + bookId: String, + webPubGenerator: WebPubGenerator, + ) = + bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto -> + if (bookDto.media.mediaProfile != MediaProfile.PDF.name) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile") + contentRestrictionChecker.checkContentRestriction(principal.user, bookDto) + webPubGenerator.toManifestPdf( + bookDto, + mediaRepository.findById(bookDto.id), + seriesMetadataRepository.findById(bookDto.seriesId), + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + fun getWebPubManifestDivinaInternal( + principal: KomgaPrincipal, + bookId: String, + webPubGenerator: WebPubGenerator, + ) = + bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto -> + contentRestrictionChecker.checkContentRestriction(principal.user, bookDto) + webPubGenerator.toManifestDivina( + bookDto, + mediaRepository.findById(bookDto.id), + seriesMetadataRepository.findById(bookDto.seriesId), + ) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + fun getBookPageInternal( + bookId: String, + pageNumber: Int, + convertTo: String?, + request: ServletWebRequest, + principal: KomgaPrincipal, + acceptHeaders: MutableList?, + ) = + bookRepository.findByIdOrNull((bookId))?.let { book -> + val media = mediaRepository.findById(bookId) + if (request.checkNotModified(getBookLastModified(media))) { + return@let ResponseEntity + .status(HttpStatus.NOT_MODIFIED) + .setNotModified(media) + .body(ByteArray(0)) + } + + contentRestrictionChecker.checkContentRestriction(principal.user, book) + + if (media.profile == MediaProfile.PDF && acceptHeaders != null && acceptHeaders.any { it.isCompatibleWith(MediaType.APPLICATION_PDF) }) { + // keep only pdf and image + acceptHeaders.removeIf { !it.isCompatibleWith(MediaType.APPLICATION_PDF) && !it.isCompatibleWith(MediaType("image")) } + MimeTypeUtils.sortBySpecificity(acceptHeaders) + if (acceptHeaders.first().isCompatibleWith(MediaType.APPLICATION_PDF)) + return getBookPageRawInternal(book, media, pageNumber) + } + + try { + val convertFormat = + when (convertTo?.lowercase()) { + "jpeg" -> ImageType.JPEG + "png" -> ImageType.PNG + "", null -> null + else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid conversion format: $convertTo") + } + + val pageContent = bookLifecycle.getBookPage(book, pageNumber, convertFormat) + + ResponseEntity.ok() + .headers( + HttpHeaders().apply { + val extension = contentDetector.mediaTypeToExtension(pageContent.mediaType) ?: "jpeg" + val imageFileName = "${book.name}-$pageNumber$extension" + contentDisposition = + ContentDisposition.builder("inline") + .filename(imageFileName, StandardCharsets.UTF_8) + .build() + }, + ) + .contentType(getMediaTypeOrDefault(pageContent.mediaType)) + .setNotModified(media) + .body(pageContent.bytes) + } catch (ex: IndexOutOfBoundsException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") + } catch (ex: ImageConversionException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, ex.message) + } catch (ex: MediaNotReadyException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + } catch (ex: NoSuchFileException) { + logger.warn(ex) { "File not found: $book" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @GetMapping( + value = [ + "api/v1/books/{bookId}/pages/{pageNumber}/raw", + "opds/v2/books/{bookId}/pages/{pageNumber}/raw", + ], + produces = [MediaType.ALL_VALUE], + ) + @PreAuthorize("hasRole('$ROLE_PAGE_STREAMING')") + fun getBookPageRaw( + @AuthenticationPrincipal principal: KomgaPrincipal, + request: ServletWebRequest, + @PathVariable bookId: String, + @PathVariable pageNumber: Int, + ): ResponseEntity = + bookRepository.findByIdOrNull((bookId))?.let { book -> + val media = mediaRepository.findById(bookId) + if (request.checkNotModified(getBookLastModified(media))) { + return@let ResponseEntity + .status(HttpStatus.NOT_MODIFIED) + .setNotModified(media) + .body(ByteArray(0)) + } + + contentRestrictionChecker.checkContentRestriction(principal.user, book) + + getBookPageRawInternal(book, media, pageNumber) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + fun getBookPageRawInternal( + book: Book, + media: Media, + pageNumber: Int, + ): ResponseEntity = + try { + val pageContent = bookAnalyzer.getPageContentRaw(BookWithMedia(book, media), pageNumber) + + ResponseEntity.ok() + .headers( + HttpHeaders().apply { + val extension = contentDetector.mediaTypeToExtension(pageContent.mediaType) ?: "" + val pageFileName = "${book.name}-$pageNumber$extension" + contentDisposition = + ContentDisposition.builder("inline") + .filename(pageFileName, StandardCharsets.UTF_8) + .build() + }, + ) + .contentType(getMediaTypeOrDefault(pageContent.mediaType)) + .setNotModified(media) + .body(pageContent.bytes) + } catch (ex: IndexOutOfBoundsException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") + } catch (ex: MediaUnsupportedException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, ex.message) + } catch (ex: MediaNotReadyException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + } catch (ex: NoSuchFileException) { + logger.warn(ex) { "File not found: $book" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") + } + + @GetMapping( + value = [ + "api/v1/books/{bookId}/resource/{*resource}", + "opds/v2/books/{bookId}/resource/{*resource}", + ], + produces = ["*/*"], + ) + fun getBookResource( + request: HttpServletRequest, + @AuthenticationPrincipal principal: KomgaPrincipal?, + @PathVariable bookId: String, + @PathVariable resource: String, + ): ResponseEntity { + val resourceName = resource.removePrefix("/") + val isFont = FONT_EXTENSIONS.contains(FilenameUtils.getExtension(resourceName).lowercase()) + + if (!isFont && principal == null) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) + + val book = bookRepository.findByIdOrNull(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + val media = mediaRepository.findById(book.id) + + if (ServletWebRequest(request).checkNotModified(getBookLastModified(media))) { + return ResponseEntity + .status(HttpStatus.NOT_MODIFIED) + .setNotModified(media) + .body(ByteArray(0)) + } + + if (media.profile != MediaProfile.EPUB) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${media.mediaType}' not compatible with requested profile") + if (!isFont) contentRestrictionChecker.checkContentRestriction(principal!!.user, book) + + val res = media.files.firstOrNull { it.fileName == resourceName } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + val bytes = + try { + bookAnalyzer.getFileContent(BookWithMedia(book, media), resourceName) + } catch (e: EntryNotFoundException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + return ResponseEntity.ok() + .headers( + HttpHeaders().apply { + contentDisposition = + ContentDisposition.builder("inline") + .filename(FilenameUtils.getName(resourceName), StandardCharsets.UTF_8) + .build() + }, + ) + .contentType(getMediaTypeOrDefault(res.mediaType)) + .setNotModified(media) + .body(bytes) + } + + @Operation(description = "Download the book file.") + @GetMapping( + value = [ + "api/v1/books/{bookId}/file", + "api/v1/books/{bookId}/file/*", + "opds/v1.2/books/{bookId}/file/*", + "opds/v2/books/{bookId}/file", + "opds/v2/books/{bookId}/file/*", + ], + produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE], + ) + @PreAuthorize("hasRole('$ROLE_FILE_DOWNLOAD')") + fun getBookFile( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ResponseEntity = + bookRepository.findByIdOrNull(bookId)?.let { book -> + contentRestrictionChecker.checkContentRestriction(principal.user, book) + try { + val media = mediaRepository.findById(book.id) + with(FileSystemResource(book.path)) { + if (!exists()) throw FileNotFoundException(path) + val stream = + StreamingResponseBody { os: OutputStream -> + this.inputStream.use { + IOUtils.copyLarge(it, os, ByteArray(8192)) + os.close() + } + } + ResponseEntity.ok() + .headers( + HttpHeaders().apply { + contentDisposition = + ContentDisposition.builder("attachment") + .filename(book.path.name, StandardCharsets.UTF_8) + .build() + }, + ) + .contentType(getMediaTypeOrDefault(media.mediaType)) + .contentLength(this.contentLength()) + .body(stream) + } + } catch (ex: FileNotFoundException) { + logger.warn(ex) { "File not found: $book" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/ContentRestrictionChecker.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/ContentRestrictionChecker.kt new file mode 100644 index 00000000..da0b7d34 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/ContentRestrictionChecker.kt @@ -0,0 +1,87 @@ +package org.gotson.komga.interfaces.api + +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.interfaces.api.rest.dto.BookDto +import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +@Component +class ContentRestrictionChecker( + private val seriesMetadataRepository: SeriesMetadataRepository, + private val bookRepository: BookRepository, +) { + /** + * Convenience function to check for content restriction. + * This will retrieve data from repositories if needed. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + fun checkContentRestriction( + komgaUser: KomgaUser, + book: BookDto, + ) { + if (!komgaUser.canAccessLibrary(book.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + if (komgaUser.restrictions.isRestricted) + seriesMetadataRepository.findById(book.seriesId).let { + if (!komgaUser.isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } + + /** + * Convenience function to check for content restriction. + * This will retrieve data from repositories if needed. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + fun checkContentRestriction( + komgaUser: KomgaUser, + book: Book, + ) { + if (!komgaUser.canAccessLibrary(book.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + if (komgaUser.restrictions.isRestricted) + seriesMetadataRepository.findById(book.seriesId).let { + if (!komgaUser.isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } + + /** + * Convenience function to check for content restriction. + * This will retrieve data from repositories if needed. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + fun checkContentRestriction( + komgaUser: KomgaUser, + bookId: String, + ) { + if (!komgaUser.sharedAllLibraries) { + bookRepository.getLibraryIdOrNull(bookId)?.let { + if (!komgaUser.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + if (komgaUser.restrictions.isRestricted) + bookRepository.getSeriesIdOrNull(bookId)?.let { seriesId -> + seriesMetadataRepository.findById(seriesId).let { + if (!komgaUser.isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + /** + * Convenience function to check for content restriction. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + fun checkContentRestriction( + komgaUser: KomgaUser, + series: SeriesDto, + ) { + if (!komgaUser.canAccessLibrary(series.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + if (!komgaUser.isContentAllowed(series.metadata.ageRating, series.metadata.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt index db2d6f2e..d8d00af9 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt @@ -1,42 +1,12 @@ package org.gotson.komga.interfaces.api -import org.gotson.komga.domain.model.KomgaUser -import org.gotson.komga.domain.persistence.BookRepository -import org.gotson.komga.domain.persistence.SeriesMetadataRepository -import org.gotson.komga.interfaces.api.rest.dto.SeriesDto -import org.springframework.http.HttpStatus -import org.springframework.web.server.ResponseStatusException +import org.gotson.komga.domain.model.Media +import org.gotson.komga.infrastructure.web.setCachePrivate +import org.springframework.http.ResponseEntity +import java.time.ZoneOffset -/** - * Convenience function to check for content restriction. - * - * @throws[ResponseStatusException] if the user cannot access the content - */ -fun KomgaUser.checkContentRestriction(series: SeriesDto) { - if (!canAccessLibrary(series.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - if (!isContentAllowed(series.metadata.ageRating, series.metadata.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) -} +fun getBookLastModified(media: Media) = + media.lastModifiedDate.toInstant(ZoneOffset.UTC).toEpochMilli() -/** - * Convenience function to check for content restriction. - * This will retrieve data from repositories if needed. - * - * @throws[ResponseStatusException] if the user cannot access the content - */ -fun KomgaUser.checkContentRestriction( - bookId: String, - bookRepository: BookRepository, - seriesMetadataRepository: SeriesMetadataRepository, -) { - if (!sharedAllLibraries) { - bookRepository.getLibraryIdOrNull(bookId)?.let { - if (!canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - } - if (restrictions.isRestricted) - bookRepository.getSeriesIdOrNull(bookId)?.let { seriesId -> - seriesMetadataRepository.findById(seriesId).let { - if (!isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) -} +fun ResponseEntity.BodyBuilder.setNotModified(media: Media): ResponseEntity.BodyBuilder = + this.setCachePrivate().lastModified(getBookLastModified(media)) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsCommonController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsCommonController.kt index f289d8dd..b3f445ae 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsCommonController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsCommonController.kt @@ -3,13 +3,11 @@ package org.gotson.komga.interfaces.api.opds import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse -import org.gotson.komga.domain.persistence.BookRepository -import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.security.KomgaPrincipal -import org.gotson.komga.interfaces.api.checkContentRestriction +import org.gotson.komga.interfaces.api.ContentRestrictionChecker import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -20,8 +18,7 @@ import org.springframework.web.server.ResponseStatusException @RestController class OpdsCommonController( - private val seriesMetadataRepository: SeriesMetadataRepository, - private val bookRepository: BookRepository, + private val contentRestrictionChecker: ContentRestrictionChecker, private val bookLifecycle: BookLifecycle, private val imageConverter: ImageConverter, ) { @@ -37,7 +34,7 @@ class OpdsCommonController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): ByteArray { - principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) + contentRestrictionChecker.checkContentRestriction(principal.user, bookId) val poster = bookLifecycle.getThumbnailBytesOriginal(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) return if (poster.mediaType != ImageType.JPEG.mediaType) imageConverter.convertImage(poster.bytes, ImageType.JPEG.imageIOFormat) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt index 55faf9ce..598759ee 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v1/OpdsController.kt @@ -10,24 +10,24 @@ import org.gotson.komga.domain.model.BookSearchWithReadProgress import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.model.SeriesSearchWithReadProgress import org.gotson.komga.domain.model.ThumbnailBook -import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.domain.persistence.ReferentialRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository -import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageAsQueryParam -import org.gotson.komga.interfaces.api.checkContentRestriction +import org.gotson.komga.interfaces.api.CommonBookController +import org.gotson.komga.interfaces.api.ContentRestrictionChecker import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE import org.gotson.komga.interfaces.api.dto.OpdsLinkRel import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsAuthor @@ -56,12 +56,15 @@ import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.http.HttpStatus import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.ServletWebRequest import org.springframework.web.server.ResponseStatusException import org.springframework.web.servlet.support.ServletUriComponentsBuilder import org.springframework.web.util.UriComponentsBuilder @@ -105,13 +108,13 @@ class OpdsController( private val collectionRepository: SeriesCollectionRepository, private val readListRepository: ReadListRepository, private val seriesDtoRepository: SeriesDtoRepository, - private val seriesMetadataRepository: SeriesMetadataRepository, private val bookDtoRepository: BookDtoRepository, private val mediaRepository: MediaRepository, private val referentialRepository: ReferentialRepository, - private val bookRepository: BookRepository, private val bookLifecycle: BookLifecycle, + private val commonBookController: CommonBookController, private val komgaSettingsProvider: KomgaSettingsProvider, + private val contentRestrictionChecker: ContentRestrictionChecker, @Qualifier("pdfImageType") private val pdfImageType: ImageType, ) { @@ -539,7 +542,7 @@ class OpdsController( @Parameter(hidden = true) page: Pageable, ): OpdsFeed = seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { series -> - principal.user.checkContentRestriction(series) + contentRestrictionChecker.checkContentRestriction(principal.user, series) val bookSearch = BookSearchWithReadProgress( @@ -713,13 +716,30 @@ class OpdsController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): ByteArray { - principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) + contentRestrictionChecker.checkContentRestriction(principal.user, bookId) val thumbnail = bookLifecycle.getThumbnail(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) return bookLifecycle.getThumbnailBytes(bookId, if (thumbnail.type == ThumbnailBook.Type.GENERATED) null else komgaSettingsProvider.thumbnailSize.maxEdge)?.bytes ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } + @ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))]) + @GetMapping("books/{bookId}/pages/{pageNumber}", produces = ["image/png", "image/gif", "image/jpeg"]) + @PreAuthorize("hasRole('$ROLE_PAGE_STREAMING')") + fun getBookPageOpds( + @AuthenticationPrincipal principal: KomgaPrincipal, + request: ServletWebRequest, + @PathVariable bookId: String, + @PathVariable pageNumber: Int, + @Parameter( + description = "Convert the image to the provided format.", + schema = Schema(allowableValues = ["jpeg", "png"]), + ) + @RequestParam(value = "convert", required = false) + convertTo: String?, + ): ResponseEntity = + commonBookController.getBookPageInternal(bookId, pageNumber + 1, convertTo, request, principal, null) + private fun SeriesDto.toOpdsEntry(prepend: Int? = null): OpdsEntryNavigation { val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: "" return OpdsEntryNavigation( diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt index 9255392f..d8fa712c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/v2/Opds2Controller.kt @@ -1,10 +1,14 @@ package org.gotson.komga.interfaces.api.opds.v2 import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse import org.gotson.komga.domain.model.BookSearchWithReadProgress import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.SeriesCollection @@ -15,12 +19,15 @@ import org.gotson.komga.domain.persistence.ReferentialRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageAsQueryParam +import org.gotson.komga.interfaces.api.CommonBookController +import org.gotson.komga.interfaces.api.ContentRestrictionChecker import org.gotson.komga.interfaces.api.OpdsGenerator -import org.gotson.komga.interfaces.api.checkContentRestriction import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE +import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE import org.gotson.komga.interfaces.api.dto.OpdsLinkRel import org.gotson.komga.interfaces.api.dto.WPLinkDto +import org.gotson.komga.interfaces.api.dto.WPPublicationDto import org.gotson.komga.interfaces.api.opds.v2.dto.FacetDto import org.gotson.komga.interfaces.api.opds.v2.dto.FeedDto import org.gotson.komga.interfaces.api.opds.v2.dto.FeedGroupDto @@ -34,12 +41,16 @@ import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.ServletWebRequest import org.springframework.web.server.ResponseStatusException import org.springframework.web.servlet.support.ServletUriComponentsBuilder import org.springframework.web.util.UriComponentsBuilder @@ -60,7 +71,9 @@ class Opds2Controller( private val seriesDtoRepository: SeriesDtoRepository, private val bookDtoRepository: BookDtoRepository, private val referentialRepository: ReferentialRepository, + private val commonBookController: CommonBookController, private val opdsGenerator: OpdsGenerator, + private val contentRestrictionChecker: ContentRestrictionChecker, ) { private fun linkStart() = WPLinkDto( @@ -683,7 +696,7 @@ class Opds2Controller( @Parameter(hidden = true) page: Pageable, ): FeedDto = seriesDtoRepository.findByIdOrNull(id, "principal.user.id")?.let { series -> - principal.user.checkContentRestriction(series) + contentRestrictionChecker.checkContentRestriction(principal.user, series) val bookSearch = BookSearchWithReadProgress( @@ -805,6 +818,66 @@ class Opds2Controller( @GetMapping(value = [ROUTE_AUTH], produces = [MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE]) fun getAuthDocument() = opdsGenerator.generateOpdsAuthDocument() + @ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))]) + @GetMapping( + value = ["books/{bookId}/pages/{pageNumber}"], + produces = [MediaType.ALL_VALUE], + ) + @PreAuthorize("hasRole('$ROLE_PAGE_STREAMING')") + fun getBookPage( + @AuthenticationPrincipal principal: KomgaPrincipal, + request: ServletWebRequest, + @PathVariable bookId: String, + @PathVariable pageNumber: Int, + @Parameter( + description = "Convert the image to the provided format.", + schema = Schema(allowableValues = ["jpeg", "png"]), + ) + @RequestParam(value = "convert", required = false) + convertTo: String?, + ): ResponseEntity = + commonBookController.getBookPageInternal(bookId, pageNumber, convertTo, request, principal, null) + + @GetMapping( + value = ["books/{bookId}/manifest"], + produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], + ) + fun getWebPubManifest( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): WPPublicationDto = + commonBookController.getWebPubManifestInternal(principal, bookId, opdsGenerator) + + @GetMapping( + value = ["books/{bookId}/manifest/epub"], + produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], + ) + fun getWebPubManifestEpub( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): WPPublicationDto = + commonBookController.getWebPubManifestEpubInternal(principal, bookId, opdsGenerator) + + @GetMapping( + value = ["books/{bookId}/manifest/pdf"], + produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], + ) + fun getWebPubManifestPdf( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): WPPublicationDto = + commonBookController.getWebPubManifestPdfInternal(principal, bookId, opdsGenerator) + + @GetMapping( + value = ["books/{bookId}/manifest/divina"], + produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], + ) + fun getWebPubManifestDivina( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): WPPublicationDto = + commonBookController.getWebPubManifestDivinaInternal(principal, bookId, opdsGenerator) + private fun Library.toWPLinkDto(): WPLinkDto = WPLinkDto( title = name, diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt index 9b26b429..3c3074d7 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt @@ -8,29 +8,21 @@ import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import jakarta.servlet.http.HttpServletRequest import jakarta.validation.Valid -import org.apache.commons.io.FilenameUtils -import org.apache.commons.io.IOUtils import org.gotson.komga.application.tasks.HIGHEST_PRIORITY import org.gotson.komga.application.tasks.HIGH_PRIORITY import org.gotson.komga.application.tasks.LOWEST_PRIORITY import org.gotson.komga.application.tasks.TaskEmitter -import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookSearchWithReadProgress -import org.gotson.komga.domain.model.BookWithMedia import org.gotson.komga.domain.model.Dimension import org.gotson.komga.domain.model.DomainEvent -import org.gotson.komga.domain.model.EntryNotFoundException import org.gotson.komga.domain.model.ImageConversionException -import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaExtensionEpub import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaProfile -import org.gotson.komga.domain.model.MediaUnsupportedException import org.gotson.komga.domain.model.R2Progression import org.gotson.komga.domain.model.ROLE_ADMIN -import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.ThumbnailBook @@ -40,29 +32,26 @@ import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.domain.persistence.ReadProgressRepository -import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.ThumbnailBookRepository import org.gotson.komga.domain.service.BookAnalyzer import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.infrastructure.image.ImageAnalyzer -import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.jooq.UnpagedSorted import org.gotson.komga.infrastructure.mediacontainer.ContentDetector import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault -import org.gotson.komga.infrastructure.web.setCachePrivate -import org.gotson.komga.interfaces.api.OpdsGenerator +import org.gotson.komga.interfaces.api.CommonBookController +import org.gotson.komga.interfaces.api.ContentRestrictionChecker import org.gotson.komga.interfaces.api.WebPubGenerator -import org.gotson.komga.interfaces.api.checkContentRestriction import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE -import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON_VALUE import org.gotson.komga.interfaces.api.dto.MEDIATYPE_PROGRESSION_JSON_VALUE import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON_VALUE import org.gotson.komga.interfaces.api.dto.WPPublicationDto +import org.gotson.komga.interfaces.api.getBookLastModified import org.gotson.komga.interfaces.api.persistence.BookDtoRepository import org.gotson.komga.interfaces.api.rest.dto.BookDto import org.gotson.komga.interfaces.api.rest.dto.BookImportBatchDto @@ -75,21 +64,19 @@ import org.gotson.komga.interfaces.api.rest.dto.ThumbnailBookDto import org.gotson.komga.interfaces.api.rest.dto.patch import org.gotson.komga.interfaces.api.rest.dto.restrictUrl import org.gotson.komga.interfaces.api.rest.dto.toDto +import org.gotson.komga.interfaces.api.setNotModified import org.springframework.context.ApplicationEventPublisher -import org.springframework.core.io.FileSystemResource import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.format.annotation.DateTimeFormat -import org.springframework.http.ContentDisposition import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.annotation.AuthenticationPrincipal -import org.springframework.util.MimeTypeUtils import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping @@ -106,18 +93,10 @@ import org.springframework.web.context.request.ServletWebRequest import org.springframework.web.context.request.WebRequest import org.springframework.web.multipart.MultipartFile import org.springframework.web.server.ResponseStatusException -import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody -import java.io.FileNotFoundException -import java.io.OutputStream -import java.nio.charset.StandardCharsets.UTF_8 import java.nio.file.NoSuchFileException import java.time.LocalDate -import java.time.ZoneOffset -import kotlin.io.path.name -import org.gotson.komga.domain.model.MediaType as KomgaMediaType private val logger = KotlinLogging.logger {} -private val FONT_EXTENSIONS = listOf("otf", "woff", "woff2", "eot", "ttf", "svg") @RestController @RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -128,7 +107,6 @@ class BookController( private val bookRepository: BookRepository, private val readProgressRepository: ReadProgressRepository, private val bookMetadataRepository: BookMetadataRepository, - private val seriesMetadataRepository: SeriesMetadataRepository, private val mediaRepository: MediaRepository, private val bookDtoRepository: BookDtoRepository, private val readListRepository: ReadListRepository, @@ -137,7 +115,8 @@ class BookController( private val eventPublisher: ApplicationEventPublisher, private val thumbnailBookRepository: ThumbnailBookRepository, private val webPubGenerator: WebPubGenerator, - private val opdsGenerator: OpdsGenerator, + private val contentRestrictionChecker: ContentRestrictionChecker, + private val commonBookController: CommonBookController, ) { @PageableAsQueryParam @GetMapping("api/v1/books") @@ -263,7 +242,7 @@ class BookController( @PathVariable bookId: String, ): BookDto = bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { - principal.user.checkContentRestriction(it) + contentRestrictionChecker.checkContentRestriction(principal.user, it) it.restrictUrl(!principal.user.roleAdmin) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -273,7 +252,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): BookDto { - principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) + contentRestrictionChecker.checkContentRestriction(principal.user, bookId) return bookDtoRepository.findPreviousInSeriesOrNull(bookId, principal.user.id) ?.restrictUrl(!principal.user.roleAdmin) @@ -285,7 +264,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): BookDto { - principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) + contentRestrictionChecker.checkContentRestriction(principal.user, bookId) return bookDtoRepository.findNextInSeriesOrNull(bookId, principal.user.id) ?.restrictUrl(!principal.user.roleAdmin) @@ -297,7 +276,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable(name = "bookId") bookId: String, ): List { - principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) + contentRestrictionChecker.checkContentRestriction(principal.user, bookId) return readListRepository.findAllContainingBookId(bookId, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions) .map { it.toDto() } @@ -312,7 +291,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): ByteArray { - principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) + contentRestrictionChecker.checkContentRestriction(principal.user, bookId) return bookLifecycle.getThumbnailBytes(bookId)?.bytes ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } @@ -324,7 +303,7 @@ class BookController( @PathVariable(name = "bookId") bookId: String, @PathVariable(name = "thumbnailId") thumbnailId: String, ): ByteArray { - principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) + contentRestrictionChecker.checkContentRestriction(principal.user, bookId) return bookLifecycle.getThumbnailBytesByThumbnailId(thumbnailId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -335,7 +314,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable(name = "bookId") bookId: String, ): Collection { - principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) + contentRestrictionChecker.checkContentRestriction(principal.user, bookId) return thumbnailBookRepository.findAllByBookId(bookId) .map { it.toDto() } @@ -400,61 +379,13 @@ class BookController( } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } - @Operation(description = "Download the book file.") - @GetMapping( - value = [ - "api/v1/books/{bookId}/file", - "api/v1/books/{bookId}/file/*", - "opds/v1.2/books/{bookId}/file/*", - "opds/v2/books/{bookId}/file", - "opds/v2/books/{bookId}/file/*", - ], - produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE], - ) - @PreAuthorize("hasRole('$ROLE_FILE_DOWNLOAD')") - fun getBookFile( - @AuthenticationPrincipal principal: KomgaPrincipal, - @PathVariable bookId: String, - ): ResponseEntity = - bookRepository.findByIdOrNull(bookId)?.let { book -> - principal.user.checkContentRestriction(book) - try { - val media = mediaRepository.findById(book.id) - with(FileSystemResource(book.path)) { - if (!exists()) throw FileNotFoundException(path) - val stream = - StreamingResponseBody { os: OutputStream -> - this.inputStream.use { - IOUtils.copyLarge(it, os, ByteArray(8192)) - os.close() - } - } - ResponseEntity.ok() - .headers( - HttpHeaders().apply { - contentDisposition = - ContentDisposition.builder("attachment") - .filename(book.path.name, UTF_8) - .build() - }, - ) - .contentType(getMediaTypeOrDefault(media.mediaType)) - .contentLength(this.contentLength()) - .body(stream) - } - } catch (ex: FileNotFoundException) { - logger.warn(ex) { "File not found: $book" } - throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") - } - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - @GetMapping("api/v1/books/{bookId}/pages") fun getBookPages( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): List = bookRepository.findByIdOrNull(bookId)?.let { book -> - principal.user.checkContentRestriction(book) + contentRestrictionChecker.checkContentRestriction(principal.user, book) val media = mediaRepository.findById(book.id) when (media.status) { @@ -482,23 +413,6 @@ class BookController( } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - @ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))]) - @GetMapping("opds/v1.2/books/{bookId}/pages/{pageNumber}", produces = ["image/png", "image/gif", "image/jpeg"]) - @PreAuthorize("hasRole('$ROLE_PAGE_STREAMING')") - fun getBookPageOpds( - @AuthenticationPrincipal principal: KomgaPrincipal, - request: ServletWebRequest, - @PathVariable bookId: String, - @PathVariable pageNumber: Int, - @Parameter( - description = "Convert the image to the provided format.", - schema = Schema(allowableValues = ["jpeg", "png"]), - ) - @RequestParam(value = "convert", required = false) - convertTo: String?, - ): ResponseEntity = - getBookPageInternal(bookId, pageNumber + 1, convertTo, request, principal, null) - @ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))]) @GetMapping( value = ["api/v1/books/{bookId}/pages/{pageNumber}"], @@ -523,152 +437,7 @@ class BookController( @RequestHeader(HttpHeaders.ACCEPT, required = false) acceptHeaders: MutableList?, ): ResponseEntity = - getBookPageInternal(bookId, if (zeroBasedIndex) pageNumber + 1 else pageNumber, convertTo, request, principal, acceptHeaders) - - @ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))]) - @GetMapping( - value = ["opds/v2/books/{bookId}/pages/{pageNumber}"], - produces = [MediaType.ALL_VALUE], - ) - @PreAuthorize("hasRole('$ROLE_PAGE_STREAMING')") - fun getBookPageOpdsv2( - @AuthenticationPrincipal principal: KomgaPrincipal, - request: ServletWebRequest, - @PathVariable bookId: String, - @PathVariable pageNumber: Int, - @Parameter( - description = "Convert the image to the provided format.", - schema = Schema(allowableValues = ["jpeg", "png"]), - ) - @RequestParam(value = "convert", required = false) - convertTo: String?, - ): ResponseEntity = - getBookPageInternal(bookId, pageNumber, convertTo, request, principal, null) - - private fun getBookPageInternal( - bookId: String, - pageNumber: Int, - convertTo: String?, - request: ServletWebRequest, - principal: KomgaPrincipal, - acceptHeaders: MutableList?, - ) = - bookRepository.findByIdOrNull((bookId))?.let { book -> - val media = mediaRepository.findById(bookId) - if (request.checkNotModified(getBookLastModified(media))) { - return@let ResponseEntity - .status(HttpStatus.NOT_MODIFIED) - .setNotModified(media) - .body(ByteArray(0)) - } - - principal.user.checkContentRestriction(book) - - if (media.profile == MediaProfile.PDF && acceptHeaders != null && acceptHeaders.any { it.isCompatibleWith(MediaType.APPLICATION_PDF) }) { - // keep only pdf and image - acceptHeaders.removeIf { !it.isCompatibleWith(MediaType.APPLICATION_PDF) && !it.isCompatibleWith(MediaType("image")) } - MimeTypeUtils.sortBySpecificity(acceptHeaders) - if (acceptHeaders.first().isCompatibleWith(MediaType.APPLICATION_PDF)) - return getBookPageRaw(book, media, pageNumber) - } - - try { - val convertFormat = - when (convertTo?.lowercase()) { - "jpeg" -> ImageType.JPEG - "png" -> ImageType.PNG - "", null -> null - else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid conversion format: $convertTo") - } - - val pageContent = bookLifecycle.getBookPage(book, pageNumber, convertFormat) - - ResponseEntity.ok() - .headers( - HttpHeaders().apply { - val extension = contentDetector.mediaTypeToExtension(pageContent.mediaType) ?: "jpeg" - val imageFileName = "${book.name}-$pageNumber$extension" - contentDisposition = - ContentDisposition.builder("inline") - .filename(imageFileName, UTF_8) - .build() - }, - ) - .contentType(getMediaTypeOrDefault(pageContent.mediaType)) - .setNotModified(media) - .body(pageContent.bytes) - } catch (ex: IndexOutOfBoundsException) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") - } catch (ex: ImageConversionException) { - throw ResponseStatusException(HttpStatus.NOT_FOUND, ex.message) - } catch (ex: MediaNotReadyException) { - throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") - } catch (ex: NoSuchFileException) { - logger.warn(ex) { "File not found: $book" } - throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") - } - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - - @GetMapping( - value = [ - "api/v1/books/{bookId}/pages/{pageNumber}/raw", - "opds/v2/books/{bookId}/pages/{pageNumber}/raw", - ], - produces = [MediaType.ALL_VALUE], - ) - @PreAuthorize("hasRole('$ROLE_PAGE_STREAMING')") - fun getBookPageRaw( - @AuthenticationPrincipal principal: KomgaPrincipal, - request: ServletWebRequest, - @PathVariable bookId: String, - @PathVariable pageNumber: Int, - ): ResponseEntity = - bookRepository.findByIdOrNull((bookId))?.let { book -> - val media = mediaRepository.findById(bookId) - if (request.checkNotModified(getBookLastModified(media))) { - return@let ResponseEntity - .status(HttpStatus.NOT_MODIFIED) - .setNotModified(media) - .body(ByteArray(0)) - } - - principal.user.checkContentRestriction(book) - - getBookPageRaw(book, media, pageNumber) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - - private fun getBookPageRaw( - book: Book, - media: Media, - pageNumber: Int, - ): ResponseEntity = - try { - val pageContent = bookAnalyzer.getPageContentRaw(BookWithMedia(book, media), pageNumber) - - ResponseEntity.ok() - .headers( - HttpHeaders().apply { - val extension = contentDetector.mediaTypeToExtension(pageContent.mediaType) ?: "" - val pageFileName = "${book.name}-$pageNumber$extension" - contentDisposition = - ContentDisposition.builder("inline") - .filename(pageFileName, UTF_8) - .build() - }, - ) - .contentType(getMediaTypeOrDefault(pageContent.mediaType)) - .setNotModified(media) - .body(pageContent.bytes) - } catch (ex: IndexOutOfBoundsException) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") - } catch (ex: MediaUnsupportedException) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, ex.message) - } catch (ex: MediaNotReadyException) { - throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") - } catch (ex: NoSuchFileException) { - logger.warn(ex) { "File not found: $book" } - throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") - } + commonBookController.getBookPageInternal(bookId, if (zeroBasedIndex) pageNumber + 1 else pageNumber, convertTo, request, principal, acceptHeaders) @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) @GetMapping( @@ -690,7 +459,7 @@ class BookController( .body(ByteArray(0)) } - principal.user.checkContentRestriction(book) + contentRestrictionChecker.checkContentRestriction(principal.user, book) try { val pageContent = bookLifecycle.getBookPage(book, pageNumber, resizeTo = 300) @@ -719,89 +488,12 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): ResponseEntity { - val manifest = getWebPubManifestInternal(principal, bookId, webPubGenerator) + val manifest = commonBookController.getWebPubManifestInternal(principal, bookId, webPubGenerator) return ResponseEntity.ok() .contentType(manifest.mediaType) .body(manifest) } - @GetMapping( - value = ["opds/v2/books/{bookId}/manifest"], - produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], - ) - fun getWebPubManifestOpds( - @AuthenticationPrincipal principal: KomgaPrincipal, - @PathVariable bookId: String, - ): WPPublicationDto = - getWebPubManifestInternal(principal, bookId, opdsGenerator) - - private fun getWebPubManifestInternal( - principal: KomgaPrincipal, - bookId: String, - webPubGenerator: WebPubGenerator, - ) = - mediaRepository.findByIdOrNull(bookId)?.let { media -> - when (KomgaMediaType.fromMediaType(media.mediaType)?.profile) { - MediaProfile.DIVINA -> getWebPubManifestDivinaInternal(principal, bookId, webPubGenerator) - MediaProfile.PDF -> getWebPubManifestPdfInternal(principal, bookId, webPubGenerator) - MediaProfile.EPUB -> getWebPubManifestEpubInternal(principal, bookId, webPubGenerator) - null -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") - } - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - - @GetMapping( - value = [ - "api/v1/books/{bookId}/resource/{*resource}", - "opds/v2/books/{bookId}/resource/{*resource}", - ], - produces = ["*/*"], - ) - fun getBookResource( - request: HttpServletRequest, - @AuthenticationPrincipal principal: KomgaPrincipal?, - @PathVariable bookId: String, - @PathVariable resource: String, - ): ResponseEntity { - val resourceName = resource.removePrefix("/") - val isFont = FONT_EXTENSIONS.contains(FilenameUtils.getExtension(resourceName).lowercase()) - - if (!isFont && principal == null) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - - val book = bookRepository.findByIdOrNull(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - val media = mediaRepository.findById(book.id) - - if (ServletWebRequest(request).checkNotModified(getBookLastModified(media))) { - return ResponseEntity - .status(HttpStatus.NOT_MODIFIED) - .setNotModified(media) - .body(ByteArray(0)) - } - - if (media.profile != MediaProfile.EPUB) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${media.mediaType}' not compatible with requested profile") - if (!isFont) principal!!.user.checkContentRestriction(book) - - val res = media.files.firstOrNull { it.fileName == resourceName } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - val bytes = - try { - bookAnalyzer.getFileContent(BookWithMedia(book, media), resourceName) - } catch (e: EntryNotFoundException) { - throw ResponseStatusException(HttpStatus.NOT_FOUND) - } - - return ResponseEntity.ok() - .headers( - HttpHeaders().apply { - contentDisposition = - ContentDisposition.builder("inline") - .filename(FilenameUtils.getName(resourceName), UTF_8) - .build() - }, - ) - .contentType(getMediaTypeOrDefault(res.mediaType)) - .setNotModified(media) - .body(bytes) - } - @GetMapping( value = ["api/v1/books/{bookId}/positions"], produces = [MEDIATYPE_POSITION_LIST_JSON_VALUE], @@ -821,7 +513,7 @@ class BookController( .body(null) } - principal.user.checkContentRestriction(book) + contentRestrictionChecker.checkContentRestriction(principal.user, book) val extension = mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub @@ -842,7 +534,7 @@ class BookController( @PathVariable bookId: String, ): ResponseEntity = bookRepository.findByIdOrNull(bookId)?.let { book -> - principal.user.checkContentRestriction(book) + contentRestrictionChecker.checkContentRestriction(principal.user, book) readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.let { ResponseEntity.ok(it.toR2Progression()) @@ -857,7 +549,7 @@ class BookController( @RequestBody progression: R2Progression, ) { bookRepository.findByIdOrNull(bookId)?.let { book -> - principal.user.checkContentRestriction(book) + contentRestrictionChecker.checkContentRestriction(principal.user, book) try { bookLifecycle.markProgression(book, principal.user, progression) @@ -877,32 +569,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): WPPublicationDto = - getWebPubManifestEpubInternal(principal, bookId, webPubGenerator) - - @GetMapping( - value = ["opds/v2/books/{bookId}/manifest/epub"], - produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], - ) - fun getWebPubManifestEpubOpds( - @AuthenticationPrincipal principal: KomgaPrincipal, - @PathVariable bookId: String, - ): WPPublicationDto = - getWebPubManifestEpubInternal(principal, bookId, opdsGenerator) - - private fun getWebPubManifestEpubInternal( - principal: KomgaPrincipal, - bookId: String, - webPubGenerator: WebPubGenerator, - ) = - bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto -> - if (bookDto.media.mediaProfile != MediaProfile.EPUB.name) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile") - principal.user.checkContentRestriction(bookDto) - webPubGenerator.toManifestEpub( - bookDto, - mediaRepository.findById(bookId), - seriesMetadataRepository.findById(bookDto.seriesId), - ) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + commonBookController.getWebPubManifestEpubInternal(principal, bookId, webPubGenerator) @GetMapping( value = ["api/v1/books/{bookId}/manifest/pdf"], @@ -912,32 +579,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): WPPublicationDto = - getWebPubManifestPdfInternal(principal, bookId, webPubGenerator) - - @GetMapping( - value = ["opds/v2/books/{bookId}/manifest/pdf"], - produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], - ) - fun getWebPubManifestPdfOpds( - @AuthenticationPrincipal principal: KomgaPrincipal, - @PathVariable bookId: String, - ): WPPublicationDto = - getWebPubManifestPdfInternal(principal, bookId, opdsGenerator) - - private fun getWebPubManifestPdfInternal( - principal: KomgaPrincipal, - bookId: String, - webPubGenerator: WebPubGenerator, - ) = - bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto -> - if (bookDto.media.mediaProfile != MediaProfile.PDF.name) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile") - principal.user.checkContentRestriction(bookDto) - webPubGenerator.toManifestPdf( - bookDto, - mediaRepository.findById(bookDto.id), - seriesMetadataRepository.findById(bookDto.seriesId), - ) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + commonBookController.getWebPubManifestPdfInternal(principal, bookId, webPubGenerator) @GetMapping( value = ["api/v1/books/{bookId}/manifest/divina"], @@ -947,31 +589,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): WPPublicationDto = - getWebPubManifestDivinaInternal(principal, bookId, webPubGenerator) - - @GetMapping( - value = ["opds/v2/books/{bookId}/manifest/divina"], - produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE], - ) - fun getWebPubManifestDivinaOpds( - @AuthenticationPrincipal principal: KomgaPrincipal, - @PathVariable bookId: String, - ): WPPublicationDto = - getWebPubManifestDivinaInternal(principal, bookId, opdsGenerator) - - private fun getWebPubManifestDivinaInternal( - principal: KomgaPrincipal, - bookId: String, - webPubGenerator: WebPubGenerator, - ) = - bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto -> - principal.user.checkContentRestriction(bookDto) - webPubGenerator.toManifestDivina( - bookDto, - mediaRepository.findById(bookDto.id), - seriesMetadataRepository.findById(bookDto.seriesId), - ) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + commonBookController.getWebPubManifestDivinaInternal(principal, bookId, webPubGenerator) @PostMapping("api/v1/books/{bookId}/analyze") @PreAuthorize("hasRole('$ROLE_ADMIN')") @@ -1051,7 +669,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, ) { bookRepository.findByIdOrNull(bookId)?.let { book -> - principal.user.checkContentRestriction(book) + contentRestrictionChecker.checkContentRestriction(principal.user, book) try { if (readProgress.completed != null && readProgress.completed) @@ -1072,7 +690,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, ) { bookRepository.findByIdOrNull(bookId)?.let { book -> - principal.user.checkContentRestriction(book) + contentRestrictionChecker.checkContentRestriction(principal.user, book) bookLifecycle.deleteReadProgress(book, principal.user) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -1120,38 +738,4 @@ class BookController( ) { taskEmitter.findBookThumbnailsToRegenerate(forBiggerResultOnly, LOWEST_PRIORITY) } - - private fun ResponseEntity.BodyBuilder.setNotModified(media: Media) = - this.setCachePrivate().lastModified(getBookLastModified(media)) - - private fun getBookLastModified(media: Media) = - media.lastModifiedDate.toInstant(ZoneOffset.UTC).toEpochMilli() - - /** - * Convenience function to check for content restriction. - * This will retrieve data from repositories if needed. - * - * @throws[ResponseStatusException] if the user cannot access the content - */ - private fun KomgaUser.checkContentRestriction(book: BookDto) { - if (!canAccessLibrary(book.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - if (restrictions.isRestricted) - seriesMetadataRepository.findById(book.seriesId).let { - if (!isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } - } - - /** - * Convenience function to check for content restriction. - * This will retrieve data from repositories if needed. - * - * @throws[ResponseStatusException] if the user cannot access the content - */ - private fun KomgaUser.checkContentRestriction(book: Book) { - if (!canAccessLibrary(book.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - if (restrictions.isRestricted) - seriesMetadataRepository.findById(book.seriesId).let { - if (!isContentAllowed(it.ageRating, it.sharingLabels)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } - } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt index 8507121c..90bdab44 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt @@ -49,7 +49,7 @@ import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam import org.gotson.komga.infrastructure.web.Authors import org.gotson.komga.infrastructure.web.DelimitedPair -import org.gotson.komga.interfaces.api.checkContentRestriction +import org.gotson.komga.interfaces.api.ContentRestrictionChecker import org.gotson.komga.interfaces.api.persistence.BookDtoRepository import org.gotson.komga.interfaces.api.persistence.ReadProgressDtoRepository import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository @@ -114,6 +114,7 @@ class SeriesController( private val contentDetector: ContentDetector, private val imageAnalyzer: ImageAnalyzer, private val thumbnailsSeriesRepository: ThumbnailSeriesRepository, + private val contentRestrictionChecker: ContentRestrictionChecker, ) { @PageableAsQueryParam @AuthorsAsQueryParam @@ -374,7 +375,7 @@ class SeriesController( @PathVariable(name = "seriesId") id: String, ): SeriesDto = seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { - principal.user.checkContentRestriction(it) + contentRestrictionChecker.checkContentRestriction(principal.user, it) it.restrictUrl(!principal.user.roleAdmin) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)