refactor: reorganize code shared between REST and OPDS

This commit is contained in:
Gauthier Roebroeck 2024-03-15 14:07:37 +08:00
parent 3250c123bd
commit ec3eb440be
8 changed files with 576 additions and 496 deletions

View file

@ -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<MediaType>?,
) =
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<ByteArray> =
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<ByteArray> =
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<ByteArray> {
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<StreamingResponseBody> =
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)
}

View file

@ -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)
}
}

View file

@ -1,42 +1,12 @@
package org.gotson.komga.interfaces.api package org.gotson.komga.interfaces.api
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.infrastructure.web.setCachePrivate
import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.springframework.http.ResponseEntity
import org.gotson.komga.interfaces.api.rest.dto.SeriesDto import java.time.ZoneOffset
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
/** fun getBookLastModified(media: Media) =
* Convenience function to check for content restriction. media.lastModifiedDate.toInstant(ZoneOffset.UTC).toEpochMilli()
*
* @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 ResponseEntity.BodyBuilder.setNotModified(media: Media): ResponseEntity.BodyBuilder =
* Convenience function to check for content restriction. this.setCachePrivate().lastModified(getBookLastModified(media))
* 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)
}

View file

@ -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.Content
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse 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.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.security.KomgaPrincipal 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.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
@ -20,8 +18,7 @@ import org.springframework.web.server.ResponseStatusException
@RestController @RestController
class OpdsCommonController( class OpdsCommonController(
private val seriesMetadataRepository: SeriesMetadataRepository, private val contentRestrictionChecker: ContentRestrictionChecker,
private val bookRepository: BookRepository,
private val bookLifecycle: BookLifecycle, private val bookLifecycle: BookLifecycle,
private val imageConverter: ImageConverter, private val imageConverter: ImageConverter,
) { ) {
@ -37,7 +34,7 @@ class OpdsCommonController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String, @PathVariable bookId: String,
): ByteArray { ): ByteArray {
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) contentRestrictionChecker.checkContentRestriction(principal.user, bookId)
val poster = bookLifecycle.getThumbnailBytesOriginal(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) val poster = bookLifecycle.getThumbnailBytesOriginal(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return if (poster.mediaType != ImageType.JPEG.mediaType) return if (poster.mediaType != ImageType.JPEG.mediaType)
imageConverter.convertImage(poster.bytes, ImageType.JPEG.imageIOFormat) imageConverter.convertImage(poster.bytes, ImageType.JPEG.imageIOFormat)

View file

@ -10,24 +10,24 @@ import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaProfile 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.ReadList
import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.domain.model.ThumbnailBook 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.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.ReferentialRepository import org.gotson.komga.domain.persistence.ReferentialRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository 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.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageAsQueryParam 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.MEDIATYPE_OPDS_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.OpdsLinkRel import org.gotson.komga.interfaces.api.dto.OpdsLinkRel
import org.gotson.komga.interfaces.api.opds.v1.dto.OpdsAuthor 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.data.domain.Sort
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType 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.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.context.request.ServletWebRequest
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.support.ServletUriComponentsBuilder import org.springframework.web.servlet.support.ServletUriComponentsBuilder
import org.springframework.web.util.UriComponentsBuilder import org.springframework.web.util.UriComponentsBuilder
@ -105,13 +108,13 @@ class OpdsController(
private val collectionRepository: SeriesCollectionRepository, private val collectionRepository: SeriesCollectionRepository,
private val readListRepository: ReadListRepository, private val readListRepository: ReadListRepository,
private val seriesDtoRepository: SeriesDtoRepository, private val seriesDtoRepository: SeriesDtoRepository,
private val seriesMetadataRepository: SeriesMetadataRepository,
private val bookDtoRepository: BookDtoRepository, private val bookDtoRepository: BookDtoRepository,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val referentialRepository: ReferentialRepository, private val referentialRepository: ReferentialRepository,
private val bookRepository: BookRepository,
private val bookLifecycle: BookLifecycle, private val bookLifecycle: BookLifecycle,
private val commonBookController: CommonBookController,
private val komgaSettingsProvider: KomgaSettingsProvider, private val komgaSettingsProvider: KomgaSettingsProvider,
private val contentRestrictionChecker: ContentRestrictionChecker,
@Qualifier("pdfImageType") @Qualifier("pdfImageType")
private val pdfImageType: ImageType, private val pdfImageType: ImageType,
) { ) {
@ -539,7 +542,7 @@ class OpdsController(
@Parameter(hidden = true) page: Pageable, @Parameter(hidden = true) page: Pageable,
): OpdsFeed = ): OpdsFeed =
seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { series -> seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { series ->
principal.user.checkContentRestriction(series) contentRestrictionChecker.checkContentRestriction(principal.user, series)
val bookSearch = val bookSearch =
BookSearchWithReadProgress( BookSearchWithReadProgress(
@ -713,13 +716,30 @@ class OpdsController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String, @PathVariable bookId: String,
): ByteArray { ): ByteArray {
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) contentRestrictionChecker.checkContentRestriction(principal.user, bookId)
val thumbnail = bookLifecycle.getThumbnail(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) 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 return bookLifecycle.getThumbnailBytes(bookId, if (thumbnail.type == ThumbnailBook.Type.GENERATED) null else komgaSettingsProvider.thumbnailSize.maxEdge)?.bytes
?: throw ResponseStatusException(HttpStatus.NOT_FOUND) ?: 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<ByteArray> =
commonBookController.getBookPageInternal(bookId, pageNumber + 1, convertTo, request, principal, null)
private fun SeriesDto.toOpdsEntry(prepend: Int? = null): OpdsEntryNavigation { private fun SeriesDto.toOpdsEntry(prepend: Int? = null): OpdsEntryNavigation {
val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: "" val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: ""
return OpdsEntryNavigation( return OpdsEntryNavigation(

View file

@ -1,10 +1,14 @@
package org.gotson.komga.interfaces.api.opds.v2 package org.gotson.komga.interfaces.api.opds.v2
import io.swagger.v3.oas.annotations.Parameter 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.BookSearchWithReadProgress
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media 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.ReadList
import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SeriesCollection 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.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageAsQueryParam 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.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_AUTHENTICATION_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_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.OpdsLinkRel
import org.gotson.komga.interfaces.api.dto.WPLinkDto 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.FacetDto
import org.gotson.komga.interfaces.api.opds.v2.dto.FeedDto import org.gotson.komga.interfaces.api.opds.v2.dto.FeedDto
import org.gotson.komga.interfaces.api.opds.v2.dto.FeedGroupDto 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.Pageable
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import org.springframework.http.HttpStatus 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.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.context.request.ServletWebRequest
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.support.ServletUriComponentsBuilder import org.springframework.web.servlet.support.ServletUriComponentsBuilder
import org.springframework.web.util.UriComponentsBuilder import org.springframework.web.util.UriComponentsBuilder
@ -60,7 +71,9 @@ class Opds2Controller(
private val seriesDtoRepository: SeriesDtoRepository, private val seriesDtoRepository: SeriesDtoRepository,
private val bookDtoRepository: BookDtoRepository, private val bookDtoRepository: BookDtoRepository,
private val referentialRepository: ReferentialRepository, private val referentialRepository: ReferentialRepository,
private val commonBookController: CommonBookController,
private val opdsGenerator: OpdsGenerator, private val opdsGenerator: OpdsGenerator,
private val contentRestrictionChecker: ContentRestrictionChecker,
) { ) {
private fun linkStart() = private fun linkStart() =
WPLinkDto( WPLinkDto(
@ -683,7 +696,7 @@ class Opds2Controller(
@Parameter(hidden = true) page: Pageable, @Parameter(hidden = true) page: Pageable,
): FeedDto = ): FeedDto =
seriesDtoRepository.findByIdOrNull(id, "principal.user.id")?.let { series -> seriesDtoRepository.findByIdOrNull(id, "principal.user.id")?.let { series ->
principal.user.checkContentRestriction(series) contentRestrictionChecker.checkContentRestriction(principal.user, series)
val bookSearch = val bookSearch =
BookSearchWithReadProgress( BookSearchWithReadProgress(
@ -805,6 +818,66 @@ class Opds2Controller(
@GetMapping(value = [ROUTE_AUTH], produces = [MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE]) @GetMapping(value = [ROUTE_AUTH], produces = [MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE])
fun getAuthDocument() = opdsGenerator.generateOpdsAuthDocument() 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<ByteArray> =
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 = private fun Library.toWPLinkDto(): WPLinkDto =
WPLinkDto( WPLinkDto(
title = name, title = name,

View file

@ -8,29 +8,21 @@ import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.Valid 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.HIGHEST_PRIORITY
import org.gotson.komga.application.tasks.HIGH_PRIORITY import org.gotson.komga.application.tasks.HIGH_PRIORITY
import org.gotson.komga.application.tasks.LOWEST_PRIORITY import org.gotson.komga.application.tasks.LOWEST_PRIORITY
import org.gotson.komga.application.tasks.TaskEmitter 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.BookSearchWithReadProgress
import org.gotson.komga.domain.model.BookWithMedia
import org.gotson.komga.domain.model.Dimension import org.gotson.komga.domain.model.Dimension
import org.gotson.komga.domain.model.DomainEvent 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.ImageConversionException
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.MarkSelectedPreference
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaExtensionEpub import org.gotson.komga.domain.model.MediaExtensionEpub
import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.MediaProfile 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.R2Progression
import org.gotson.komga.domain.model.ROLE_ADMIN 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.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.ThumbnailBook 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.MediaRepository
import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository 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.persistence.ThumbnailBookRepository
import org.gotson.komga.domain.service.BookAnalyzer import org.gotson.komga.domain.service.BookAnalyzer
import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.image.ImageAnalyzer 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.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault
import org.gotson.komga.infrastructure.web.setCachePrivate import org.gotson.komga.interfaces.api.CommonBookController
import org.gotson.komga.interfaces.api.OpdsGenerator import org.gotson.komga.interfaces.api.ContentRestrictionChecker
import org.gotson.komga.interfaces.api.WebPubGenerator 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_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
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON_VALUE 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_PROGRESSION_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_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.dto.WPPublicationDto
import org.gotson.komga.interfaces.api.getBookLastModified
import org.gotson.komga.interfaces.api.persistence.BookDtoRepository 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.BookDto
import org.gotson.komga.interfaces.api.rest.dto.BookImportBatchDto 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.patch
import org.gotson.komga.interfaces.api.rest.dto.restrictUrl import org.gotson.komga.interfaces.api.rest.dto.restrictUrl
import org.gotson.komga.interfaces.api.rest.dto.toDto import org.gotson.komga.interfaces.api.rest.dto.toDto
import org.gotson.komga.interfaces.api.setNotModified
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.core.io.FileSystemResource
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import org.springframework.format.annotation.DateTimeFormat import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ContentDisposition
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal 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.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping 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.context.request.WebRequest
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException 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.nio.file.NoSuchFileException
import java.time.LocalDate 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 logger = KotlinLogging.logger {}
private val FONT_EXTENSIONS = listOf("otf", "woff", "woff2", "eot", "ttf", "svg")
@RestController @RestController
@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) @RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE])
@ -128,7 +107,6 @@ class BookController(
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
private val readProgressRepository: ReadProgressRepository, private val readProgressRepository: ReadProgressRepository,
private val bookMetadataRepository: BookMetadataRepository, private val bookMetadataRepository: BookMetadataRepository,
private val seriesMetadataRepository: SeriesMetadataRepository,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val bookDtoRepository: BookDtoRepository, private val bookDtoRepository: BookDtoRepository,
private val readListRepository: ReadListRepository, private val readListRepository: ReadListRepository,
@ -137,7 +115,8 @@ class BookController(
private val eventPublisher: ApplicationEventPublisher, private val eventPublisher: ApplicationEventPublisher,
private val thumbnailBookRepository: ThumbnailBookRepository, private val thumbnailBookRepository: ThumbnailBookRepository,
private val webPubGenerator: WebPubGenerator, private val webPubGenerator: WebPubGenerator,
private val opdsGenerator: OpdsGenerator, private val contentRestrictionChecker: ContentRestrictionChecker,
private val commonBookController: CommonBookController,
) { ) {
@PageableAsQueryParam @PageableAsQueryParam
@GetMapping("api/v1/books") @GetMapping("api/v1/books")
@ -263,7 +242,7 @@ class BookController(
@PathVariable bookId: String, @PathVariable bookId: String,
): BookDto = ): BookDto =
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let {
principal.user.checkContentRestriction(it) contentRestrictionChecker.checkContentRestriction(principal.user, it)
it.restrictUrl(!principal.user.roleAdmin) it.restrictUrl(!principal.user.roleAdmin)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ -273,7 +252,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String, @PathVariable bookId: String,
): BookDto { ): BookDto {
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) contentRestrictionChecker.checkContentRestriction(principal.user, bookId)
return bookDtoRepository.findPreviousInSeriesOrNull(bookId, principal.user.id) return bookDtoRepository.findPreviousInSeriesOrNull(bookId, principal.user.id)
?.restrictUrl(!principal.user.roleAdmin) ?.restrictUrl(!principal.user.roleAdmin)
@ -285,7 +264,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String, @PathVariable bookId: String,
): BookDto { ): BookDto {
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) contentRestrictionChecker.checkContentRestriction(principal.user, bookId)
return bookDtoRepository.findNextInSeriesOrNull(bookId, principal.user.id) return bookDtoRepository.findNextInSeriesOrNull(bookId, principal.user.id)
?.restrictUrl(!principal.user.roleAdmin) ?.restrictUrl(!principal.user.roleAdmin)
@ -297,7 +276,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "bookId") bookId: String, @PathVariable(name = "bookId") bookId: String,
): List<ReadListDto> { ): List<ReadListDto> {
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) contentRestrictionChecker.checkContentRestriction(principal.user, bookId)
return readListRepository.findAllContainingBookId(bookId, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions) return readListRepository.findAllContainingBookId(bookId, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)
.map { it.toDto() } .map { it.toDto() }
@ -312,7 +291,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String, @PathVariable bookId: String,
): ByteArray { ): ByteArray {
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) contentRestrictionChecker.checkContentRestriction(principal.user, bookId)
return bookLifecycle.getThumbnailBytes(bookId)?.bytes ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) return bookLifecycle.getThumbnailBytes(bookId)?.bytes ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }
@ -324,7 +303,7 @@ class BookController(
@PathVariable(name = "bookId") bookId: String, @PathVariable(name = "bookId") bookId: String,
@PathVariable(name = "thumbnailId") thumbnailId: String, @PathVariable(name = "thumbnailId") thumbnailId: String,
): ByteArray { ): ByteArray {
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) contentRestrictionChecker.checkContentRestriction(principal.user, bookId)
return bookLifecycle.getThumbnailBytesByThumbnailId(thumbnailId) return bookLifecycle.getThumbnailBytesByThumbnailId(thumbnailId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ -335,7 +314,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "bookId") bookId: String, @PathVariable(name = "bookId") bookId: String,
): Collection<ThumbnailBookDto> { ): Collection<ThumbnailBookDto> {
principal.user.checkContentRestriction(bookId, bookRepository, seriesMetadataRepository) contentRestrictionChecker.checkContentRestriction(principal.user, bookId)
return thumbnailBookRepository.findAllByBookId(bookId) return thumbnailBookRepository.findAllByBookId(bookId)
.map { it.toDto() } .map { it.toDto() }
@ -400,61 +379,13 @@ class BookController(
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: 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<StreamingResponseBody> =
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") @GetMapping("api/v1/books/{bookId}/pages")
fun getBookPages( fun getBookPages(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String, @PathVariable bookId: String,
): List<PageDto> = ): List<PageDto> =
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.findByIdOrNull(bookId)?.let { book ->
principal.user.checkContentRestriction(book) contentRestrictionChecker.checkContentRestriction(principal.user, book)
val media = mediaRepository.findById(book.id) val media = mediaRepository.findById(book.id)
when (media.status) { when (media.status) {
@ -482,23 +413,6 @@ class BookController(
} }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: 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<ByteArray> =
getBookPageInternal(bookId, pageNumber + 1, convertTo, request, principal, null)
@ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))]) @ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))])
@GetMapping( @GetMapping(
value = ["api/v1/books/{bookId}/pages/{pageNumber}"], value = ["api/v1/books/{bookId}/pages/{pageNumber}"],
@ -523,152 +437,7 @@ class BookController(
@RequestHeader(HttpHeaders.ACCEPT, required = false) @RequestHeader(HttpHeaders.ACCEPT, required = false)
acceptHeaders: MutableList<MediaType>?, acceptHeaders: MutableList<MediaType>?,
): ResponseEntity<ByteArray> = ): ResponseEntity<ByteArray> =
getBookPageInternal(bookId, if (zeroBasedIndex) pageNumber + 1 else pageNumber, convertTo, request, principal, acceptHeaders) commonBookController.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<ByteArray> =
getBookPageInternal(bookId, pageNumber, convertTo, request, principal, null)
private fun getBookPageInternal(
bookId: String,
pageNumber: Int,
convertTo: String?,
request: ServletWebRequest,
principal: KomgaPrincipal,
acceptHeaders: MutableList<MediaType>?,
) =
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<ByteArray> =
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<ByteArray> =
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")
}
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))]) @ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping( @GetMapping(
@ -690,7 +459,7 @@ class BookController(
.body(ByteArray(0)) .body(ByteArray(0))
} }
principal.user.checkContentRestriction(book) contentRestrictionChecker.checkContentRestriction(principal.user, book)
try { try {
val pageContent = bookLifecycle.getBookPage(book, pageNumber, resizeTo = 300) val pageContent = bookLifecycle.getBookPage(book, pageNumber, resizeTo = 300)
@ -719,89 +488,12 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String, @PathVariable bookId: String,
): ResponseEntity<WPPublicationDto> { ): ResponseEntity<WPPublicationDto> {
val manifest = getWebPubManifestInternal(principal, bookId, webPubGenerator) val manifest = commonBookController.getWebPubManifestInternal(principal, bookId, webPubGenerator)
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(manifest.mediaType) .contentType(manifest.mediaType)
.body(manifest) .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<ByteArray> {
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( @GetMapping(
value = ["api/v1/books/{bookId}/positions"], value = ["api/v1/books/{bookId}/positions"],
produces = [MEDIATYPE_POSITION_LIST_JSON_VALUE], produces = [MEDIATYPE_POSITION_LIST_JSON_VALUE],
@ -821,7 +513,7 @@ class BookController(
.body(null) .body(null)
} }
principal.user.checkContentRestriction(book) contentRestrictionChecker.checkContentRestriction(principal.user, book)
val extension = val extension =
mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub mediaRepository.findExtensionByIdOrNull(book.id) as? MediaExtensionEpub
@ -842,7 +534,7 @@ class BookController(
@PathVariable bookId: String, @PathVariable bookId: String,
): ResponseEntity<R2Progression> = ): ResponseEntity<R2Progression> =
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.findByIdOrNull(bookId)?.let { book ->
principal.user.checkContentRestriction(book) contentRestrictionChecker.checkContentRestriction(principal.user, book)
readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.let { readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.let {
ResponseEntity.ok(it.toR2Progression()) ResponseEntity.ok(it.toR2Progression())
@ -857,7 +549,7 @@ class BookController(
@RequestBody progression: R2Progression, @RequestBody progression: R2Progression,
) { ) {
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.findByIdOrNull(bookId)?.let { book ->
principal.user.checkContentRestriction(book) contentRestrictionChecker.checkContentRestriction(principal.user, book)
try { try {
bookLifecycle.markProgression(book, principal.user, progression) bookLifecycle.markProgression(book, principal.user, progression)
@ -877,32 +569,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String, @PathVariable bookId: String,
): WPPublicationDto = ): WPPublicationDto =
getWebPubManifestEpubInternal(principal, bookId, webPubGenerator) commonBookController.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)
@GetMapping( @GetMapping(
value = ["api/v1/books/{bookId}/manifest/pdf"], value = ["api/v1/books/{bookId}/manifest/pdf"],
@ -912,32 +579,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String, @PathVariable bookId: String,
): WPPublicationDto = ): WPPublicationDto =
getWebPubManifestPdfInternal(principal, bookId, webPubGenerator) commonBookController.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)
@GetMapping( @GetMapping(
value = ["api/v1/books/{bookId}/manifest/divina"], value = ["api/v1/books/{bookId}/manifest/divina"],
@ -947,31 +589,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String, @PathVariable bookId: String,
): WPPublicationDto = ): WPPublicationDto =
getWebPubManifestDivinaInternal(principal, bookId, webPubGenerator) commonBookController.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)
@PostMapping("api/v1/books/{bookId}/analyze") @PostMapping("api/v1/books/{bookId}/analyze")
@PreAuthorize("hasRole('$ROLE_ADMIN')") @PreAuthorize("hasRole('$ROLE_ADMIN')")
@ -1051,7 +669,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
) { ) {
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.findByIdOrNull(bookId)?.let { book ->
principal.user.checkContentRestriction(book) contentRestrictionChecker.checkContentRestriction(principal.user, book)
try { try {
if (readProgress.completed != null && readProgress.completed) if (readProgress.completed != null && readProgress.completed)
@ -1072,7 +690,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
) { ) {
bookRepository.findByIdOrNull(bookId)?.let { book -> bookRepository.findByIdOrNull(bookId)?.let { book ->
principal.user.checkContentRestriction(book) contentRestrictionChecker.checkContentRestriction(principal.user, book)
bookLifecycle.deleteReadProgress(book, principal.user) bookLifecycle.deleteReadProgress(book, principal.user)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ -1120,38 +738,4 @@ class BookController(
) { ) {
taskEmitter.findBookThumbnailsToRegenerate(forBiggerResultOnly, LOWEST_PRIORITY) 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)
}
}
} }

View file

@ -49,7 +49,7 @@ import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.infrastructure.web.Authors import org.gotson.komga.infrastructure.web.Authors
import org.gotson.komga.infrastructure.web.DelimitedPair 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.BookDtoRepository
import org.gotson.komga.interfaces.api.persistence.ReadProgressDtoRepository import org.gotson.komga.interfaces.api.persistence.ReadProgressDtoRepository
import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository
@ -114,6 +114,7 @@ class SeriesController(
private val contentDetector: ContentDetector, private val contentDetector: ContentDetector,
private val imageAnalyzer: ImageAnalyzer, private val imageAnalyzer: ImageAnalyzer,
private val thumbnailsSeriesRepository: ThumbnailSeriesRepository, private val thumbnailsSeriesRepository: ThumbnailSeriesRepository,
private val contentRestrictionChecker: ContentRestrictionChecker,
) { ) {
@PageableAsQueryParam @PageableAsQueryParam
@AuthorsAsQueryParam @AuthorsAsQueryParam
@ -374,7 +375,7 @@ class SeriesController(
@PathVariable(name = "seriesId") id: String, @PathVariable(name = "seriesId") id: String,
): SeriesDto = ): SeriesDto =
seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let {
principal.user.checkContentRestriction(it) contentRestrictionChecker.checkContentRestriction(principal.user, it)
it.restrictUrl(!principal.user.roleAdmin) it.restrictUrl(!principal.user.roleAdmin)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)