mirror of
https://github.com/gotson/komga.git
synced 2026-05-09 05:10:19 +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
|
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue