mirror of
https://github.com/gotson/komga.git
synced 2026-05-08 21:00:16 +02:00
refactor: reorganize code shared between REST and OPDS
This commit is contained in:
parent
3250c123bd
commit
ec3eb440be
8 changed files with 576 additions and 496 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<ByteArray> =
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<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 =
|
||||
WPLinkDto(
|
||||
title = name,
|
||||
|
|
|
|||
|
|
@ -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<ReadListDto> {
|
||||
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<ThumbnailBookDto> {
|
||||
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<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")
|
||||
fun getBookPages(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@PathVariable bookId: String,
|
||||
): List<PageDto> =
|
||||
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<ByteArray> =
|
||||
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<MediaType>?,
|
||||
): ResponseEntity<ByteArray> =
|
||||
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")
|
||||
}
|
||||
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<WPPublicationDto> {
|
||||
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<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(
|
||||
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<R2Progression> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue