feat(opds): support authentication for OPDS

always use /opds/v2 for webpub manifests served in OPDS 2 feeds
This commit is contained in:
Gauthier Roebroeck 2024-03-14 16:41:50 +08:00
parent 89a0f4ae44
commit 3250c123bd
9 changed files with 410 additions and 165 deletions

View file

@ -0,0 +1,44 @@
package org.gotson.komga.infrastructure.security
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.gotson.komga.interfaces.api.OpdsGenerator
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.OpdsLinkRel
import org.gotson.komga.interfaces.api.opds.v2.ROUTE_AUTH
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.stereotype.Component
import org.springframework.web.servlet.support.ServletUriComponentsBuilder
private const val DEFAULT_REALM: String = "Realm"
@Component
class OpdsAuthenticationEntryPoint(
private val opdsGenerator: OpdsGenerator,
private val objectMapper: ObjectMapper,
) : AuthenticationEntryPoint {
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException,
) {
with(response) {
contentType = MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE
characterEncoding = Charsets.UTF_8.name()
status = HttpStatus.UNAUTHORIZED.value()
setHeader("WWW-Authenticate", """Basic realm="$DEFAULT_REALM"""")
setHeader(
HttpHeaders.LINK,
"""<${ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2").path(ROUTE_AUTH).toUriString()}>; rel="${OpdsLinkRel.AUTH}"; type="$MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE"""",
)
with(writer) {
write(objectMapper.writeValueAsString(opdsGenerator.generateOpdsAuthDocument()))
flush()
}
}
}
}

View file

@ -26,6 +26,7 @@ import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
private val logger = KotlinLogging.logger {}
@ -41,6 +42,7 @@ class SecurityConfiguration(
private val sessionCookieName: String,
private val userAgentWebAuthenticationDetailsSource: WebAuthenticationDetailsSource,
private val sessionRegistry: SessionRegistry,
private val opdsAuthenticationEntryPoint: OpdsAuthenticationEntryPoint,
clientRegistrationRepository: InMemoryClientRegistrationRepository?,
) {
private val oauth2Enabled = clientRegistrationRepository != null
@ -75,6 +77,8 @@ class SecurityConfiguration(
"/api/v1/oauth2/providers",
// epub resources - fonts are always requested anonymously, so we check for authorization within the controller method directly
"api/v1/books/{bookId}/resource/**",
// OPDS authentication document
"/opds/v2/auth",
).permitAll()
// all other endpoints are restricted to authenticated users
@ -103,6 +107,9 @@ class SecurityConfiguration(
it.maximumSessions(-1)
}
}
.exceptionHandling {
it.defaultAuthenticationEntryPointFor(opdsAuthenticationEntryPoint, AntPathRequestMatcher("/opds/v2/**"))
}
if (oauth2Enabled) {
http.oauth2Login { oauth2 ->

View file

@ -0,0 +1,60 @@
package org.gotson.komga.interfaces.api
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.service.BookAnalyzer
import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_PUBLICATION_JSON
import org.gotson.komga.interfaces.api.dto.WPLinkDto
import org.gotson.komga.interfaces.api.dto.WPPublicationDto
import org.gotson.komga.interfaces.api.opds.v2.ROUTE_AUTH
import org.gotson.komga.interfaces.api.opds.v2.dto.AuthenticationDocumentDto
import org.gotson.komga.interfaces.api.opds.v2.dto.AuthenticationFlowDto
import org.gotson.komga.interfaces.api.opds.v2.dto.AuthenticationType
import org.gotson.komga.interfaces.api.opds.v2.dto.LabelsDto
import org.gotson.komga.interfaces.api.rest.dto.BookDto
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.servlet.support.ServletUriComponentsBuilder
@Component
class OpdsGenerator(
@Qualifier("thumbnailType") thumbnailType: ImageType,
imageConverter: ImageConverter,
bookAnalyzer: BookAnalyzer,
mediaRepository: MediaRepository,
) : WebPubGenerator(thumbnailType, imageConverter, bookAnalyzer, mediaRepository, listOf("opds", "v2")) {
fun toOpdsPublicationDto(bookDto: BookDto): WPPublicationDto =
toBasePublicationDto(bookDto).copy(images = buildThumbnailLinkDtos(bookDto.id))
override fun getDefaultMediaType(): MediaType = MEDIATYPE_OPDS_PUBLICATION_JSON
override fun getBookSeriesLink(bookDto: BookDto): List<WPLinkDto> =
listOf(
WPLinkDto(
href = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray()).path("series/${bookDto.seriesId}").toUriString(),
type = MEDIATYPE_OPDS_JSON_VALUE,
),
)
fun generateOpdsAuthDocument() =
AuthenticationDocumentDto(
id = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2").path(ROUTE_AUTH).toUriString(),
title = "Komga",
description = "Enter your email and password to authenticate.",
links =
listOf(
WPLinkDto(rel = "help", href = "https://komga.org"),
WPLinkDto(rel = "logo", href = ServletUriComponentsBuilder.fromCurrentContextPath().path("android-chrome-512x512.png").toUriString()),
),
authentication =
listOf(
AuthenticationFlowDto(
type = AuthenticationType.BASIC,
labels = LabelsDto(login = "Email", password = "Password"),
),
),
)
}

View file

@ -14,8 +14,6 @@ import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_PUBLICATION_JSON
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.OpdsLinkRel
@ -33,55 +31,35 @@ import org.gotson.komga.interfaces.api.rest.dto.BookDto
import org.gotson.komga.language.toZonedDateTime
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.MediaType
import org.springframework.stereotype.Service
import org.springframework.stereotype.Component
import org.springframework.web.servlet.support.ServletUriComponentsBuilder
import org.springframework.web.util.UriComponentsBuilder
import org.gotson.komga.domain.model.MediaType as KomgaMediaType
@Service
@Component
class WebPubGenerator(
@Qualifier("thumbnailType") private val thumbnailType: ImageType,
private val imageConverter: ImageConverter,
private val bookAnalyzer: BookAnalyzer,
private val mediaRepository: MediaRepository,
protected val pathSegments: List<String> = listOf("api", "v1"),
) {
private val wpKnownRoles =
listOf(
"author",
"translator",
"editor",
"artist",
"illustrator",
"letterer",
"penciler",
"penciller",
"colorist",
"inker",
)
private val recommendedImageMediaTypes = listOf("image/jpeg", "image/png", "image/gif")
private fun BookDto.toBasePublicationDto(includeOpdsLinks: Boolean = false): WPPublicationDto {
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1")
protected fun toBasePublicationDto(bookDto: BookDto): WPPublicationDto {
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray())
return WPPublicationDto(
mediaType = MEDIATYPE_OPDS_PUBLICATION_JSON,
mediaType = getDefaultMediaType(),
context = "https://readium.org/webpub-manifest/context.jsonld",
metadata = toWPMetadataDto(includeOpdsLinks).withAuthors(metadata.authors),
links = toWPLinkDtos(uriBuilder),
metadata = toWPMetadataDto(bookDto).withAuthors(bookDto.metadata.authors),
links = bookDto.toWPLinkDtos(uriBuilder),
)
}
fun toOpdsPublicationDto(
bookDto: BookDto,
includeOpdsLinks: Boolean = false,
): WPPublicationDto {
return bookDto.toBasePublicationDto(includeOpdsLinks).copy(images = buildThumbnailLinkDtos(bookDto.id))
}
protected open fun getDefaultMediaType(): MediaType = MEDIATYPE_WEBPUB_JSON
private fun buildThumbnailLinkDtos(bookId: String) =
protected fun buildThumbnailLinkDtos(bookId: String) =
listOf(
WPLinkDto(
href = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1").path("books/$bookId/thumbnail").toUriString(),
href = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray()).path("books/$bookId/thumbnail").toUriString(),
type = thumbnailType.mediaType,
),
)
@ -91,8 +69,8 @@ class WebPubGenerator(
media: Media,
seriesMetadata: SeriesMetadata,
): WPPublicationDto {
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1")
return bookDto.toBasePublicationDto().let {
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray())
return toBasePublicationDto(bookDto).let {
val pages = if (media.profile == MediaProfile.PDF) bookAnalyzer.getPdfPagesDynamic(media) else media.pages
it.copy(
mediaType = MEDIATYPE_DIVINA_JSON,
@ -128,8 +106,8 @@ class WebPubGenerator(
media: Media,
seriesMetadata: SeriesMetadata,
): WPPublicationDto {
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1")
return bookDto.toBasePublicationDto().let {
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray())
return toBasePublicationDto(bookDto).let {
it.copy(
mediaType = MEDIATYPE_WEBPUB_JSON,
metadata = it.metadata.withSeriesMetadata(seriesMetadata).copy(conformsTo = PROFILE_PDF),
@ -150,14 +128,14 @@ class WebPubGenerator(
media: Media,
seriesMetadata: SeriesMetadata,
): WPPublicationDto {
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1")
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment(*pathSegments.toTypedArray())
val extension =
when {
media.extension is ProxyExtension && media.extension.proxyForType<MediaExtensionEpub>() -> mediaRepository.findExtensionByIdOrNull(media.bookId) as? MediaExtensionEpub
media.extension is MediaExtensionEpub -> media.extension
else -> null
}
return bookDto.toBasePublicationDto().let { publication ->
return toBasePublicationDto(bookDto).let { publication ->
publication.copy(
mediaType = MEDIATYPE_WEBPUB_JSON,
metadata =
@ -204,36 +182,30 @@ class WebPubGenerator(
children = children.map { it.toWPLinkDto(uriBuilder) },
)
private fun BookDto.toWPMetadataDto(includeOpdsLinks: Boolean = false) =
protected open fun toWPMetadataDto(bookDto: BookDto) =
WPMetadataDto(
title = metadata.title,
description = metadata.summary,
numberOfPages = this.media.pagesCount,
modified = lastModified.toZonedDateTime(),
published = metadata.releaseDate,
subject = metadata.tags.toList(),
identifier = if (metadata.isbn.isNotBlank()) "urn:isbn:${metadata.isbn}" else null,
title = bookDto.metadata.title,
description = bookDto.metadata.summary,
numberOfPages = bookDto.media.pagesCount,
modified = bookDto.lastModified.toZonedDateTime(),
published = bookDto.metadata.releaseDate,
subject = bookDto.metadata.tags.toList(),
identifier = if (bookDto.metadata.isbn.isNotBlank()) "urn:isbn:${bookDto.metadata.isbn}" else null,
belongsTo =
WPBelongsToDto(
series =
listOf(
WPContributorDto(
seriesTitle,
metadata.numberSort,
if (includeOpdsLinks)
listOf(
WPLinkDto(
href = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2").path("series/$seriesId").toUriString(),
type = MEDIATYPE_OPDS_JSON_VALUE,
),
)
else
emptyList(),
bookDto.seriesTitle,
bookDto.metadata.numberSort,
getBookSeriesLink(bookDto),
),
),
),
)
protected open fun getBookSeriesLink(bookDto: BookDto): List<WPLinkDto> = emptyList()
private fun WPMetadataDto.withSeriesMetadata(seriesMetadata: SeriesMetadata) =
copy(
language = seriesMetadata.language,
@ -284,4 +256,21 @@ class WebPubGenerator(
MediaProfile.EPUB -> MEDIATYPE_WEBPUB_JSON_VALUE
null -> MEDIATYPE_WEBPUB_JSON_VALUE
}
companion object {
private val wpKnownRoles =
listOf(
"author",
"translator",
"editor",
"artist",
"illustrator",
"letterer",
"penciler",
"penciller",
"colorist",
"inker",
)
private val recommendedImageMediaTypes = listOf("image/jpeg", "image/png", "image/gif")
}
}

View file

@ -3,6 +3,8 @@ package org.gotson.komga.interfaces.api.dto
import org.springframework.http.MediaType
const val MEDIATYPE_OPDS_JSON_VALUE = "application/opds+json"
const val MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE = "application/opds-publication+json"
const val MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE = "application/opds-authentication+json"
const val MEDIATYPE_DIVINA_JSON_VALUE = "application/divina+json"
const val MEDIATYPE_WEBPUB_JSON_VALUE = "application/webpub+json"
const val MEDIATYPE_POSITION_LIST_JSON_VALUE = "application/vnd.readium.position-list+json"

View file

@ -12,5 +12,6 @@ class OpdsLinkRel {
const val SORT_NEW = "http://opds-spec.org/sort/new"
const val SORT_POPULAR = "http://opds-spec.org/sort/popular"
const val ACQUISITION = "http://opds-spec.org/acquisition"
const val AUTH = "http://opds-spec.org/auth/document"
}
}

View file

@ -15,8 +15,9 @@ import org.gotson.komga.domain.persistence.ReferentialRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageAsQueryParam
import org.gotson.komga.interfaces.api.WebPubGenerator
import org.gotson.komga.interfaces.api.OpdsGenerator
import org.gotson.komga.interfaces.api.checkContentRestriction
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.OpdsLinkRel
import org.gotson.komga.interfaces.api.dto.WPLinkDto
@ -46,6 +47,7 @@ import java.time.ZoneId
import java.time.ZonedDateTime
private const val ROUTE_CATALOG = "catalog"
const val ROUTE_AUTH = "auth"
private const val RECOMMENDED_ITEMS_NUMBER = 5
@ -58,7 +60,7 @@ class Opds2Controller(
private val seriesDtoRepository: SeriesDtoRepository,
private val bookDtoRepository: BookDtoRepository,
private val referentialRepository: ReferentialRepository,
private val webPubGenerator: WebPubGenerator,
private val opdsGenerator: OpdsGenerator,
) {
private fun linkStart() =
WPLinkDto(
@ -77,8 +79,7 @@ class Opds2Controller(
templated = true,
)
private fun uriBuilder(path: String) =
ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2").path(path)
private fun uriBuilder(path: String) = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("opds", "v2").path(path)
private fun linkPage(
uriBuilder: UriComponentsBuilder,
@ -105,18 +106,16 @@ class Opds2Controller(
private fun linkSelf(
path: String,
type: String? = null,
) =
linkSelf(uriBuilder(path), type)
) = linkSelf(uriBuilder(path), type)
private fun linkSelf(
uriBuilder: UriComponentsBuilder,
type: String? = null,
) =
WPLinkDto(
rel = OpdsLinkRel.SELF,
href = uriBuilder.toUriString(),
type = type,
)
) = WPLinkDto(
rel = OpdsLinkRel.SELF,
href = uriBuilder.toUriString(),
type = type,
)
private fun getLibrariesFeedGroup(
principal: KomgaPrincipal,
@ -178,7 +177,7 @@ class Opds2Controller(
principal.user.id,
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("readProgress.readDate"))),
principal.user.restrictions,
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
).map { opdsGenerator.toOpdsPublicationDto(it) }
val onDeck =
bookDtoRepository.findAllOnDeck(
@ -186,7 +185,7 @@ class Opds2Controller(
authorizedLibraryIds,
Pageable.ofSize(RECOMMENDED_ITEMS_NUMBER),
principal.user.restrictions,
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
).map { opdsGenerator.toOpdsPublicationDto(it) }
val latestBooks =
bookDtoRepository.findAll(
@ -198,7 +197,7 @@ class Opds2Controller(
principal.user.id,
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("createdDate"))),
principal.user.restrictions,
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
).map { opdsGenerator.toOpdsPublicationDto(it) }
val latestSeries =
seriesDtoRepository.findAll(
@ -286,7 +285,7 @@ class Opds2Controller(
principal.user.id,
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("readProgress.readDate"))),
principal.user.restrictions,
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
).map { opdsGenerator.toOpdsPublicationDto(it) }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/keep-reading")
@ -323,7 +322,7 @@ class Opds2Controller(
authorizedLibraryIds,
page,
principal.user.restrictions,
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
).map { opdsGenerator.toOpdsPublicationDto(it) }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/on-deck")
@ -364,7 +363,7 @@ class Opds2Controller(
principal.user.id,
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("createdDate"))),
principal.user.restrictions,
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
).map { opdsGenerator.toOpdsPublicationDto(it) }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/books/latest")
@ -446,22 +445,18 @@ class Opds2Controller(
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.titleSort")))
val entries =
seriesDtoRepository
.findAll(seriesSearch, principal.user.id, pageable, principal.user.restrictions)
.map { it.toWPLinkDto() }
val entries = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable, principal.user.restrictions).map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/browse")
val publisherLinks =
referentialRepository.findAllPublishers(authorizedLibraryIds)
.map {
WPLinkDto(
title = it,
href = uriBuilder.cloneBuilder().queryParam("publisher", it).toUriString(),
type = MEDIATYPE_OPDS_JSON_VALUE,
)
}
referentialRepository.findAllPublishers(authorizedLibraryIds).map {
WPLinkDto(
title = it,
href = uriBuilder.cloneBuilder().queryParam("publisher", it).toUriString(),
type = MEDIATYPE_OPDS_JSON_VALUE,
)
}
return FeedDto(
metadata =
@ -548,9 +543,7 @@ class Opds2Controller(
deleted = false,
)
val entries =
seriesDtoRepository.findAllByCollectionId(collection.id, seriesSearch, principal.user.id, pageable, principal.user.restrictions)
.map { it.toWPLinkDto() }
val entries = seriesDtoRepository.findAllByCollectionId(collection.id, seriesSearch, principal.user.id, pageable, principal.user.restrictions).map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("collections/$id")
@ -643,7 +636,7 @@ class Opds2Controller(
principal.user.restrictions,
)
val entries = booksPage.map { webPubGenerator.toOpdsPublicationDto(it, true) }
val entries = booksPage.map { opdsGenerator.toOpdsPublicationDto(it) }
val uriBuilder = uriBuilder("readlists/$id")
@ -701,22 +694,19 @@ class Opds2Controller(
)
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.numberSort")))
val entries =
bookDtoRepository.findAll(bookSearch, principal.user.id, pageable, principal.user.restrictions)
.map { webPubGenerator.toOpdsPublicationDto(it, true) }
val entries = bookDtoRepository.findAll(bookSearch, principal.user.id, pageable, principal.user.restrictions).map { opdsGenerator.toOpdsPublicationDto(it) }
val uriBuilder = uriBuilder("series/$id")
val tagLinks =
referentialRepository.findAllBookTagsBySeries(series.id, null)
.map {
WPLinkDto(
title = it,
href = uriBuilder.cloneBuilder().queryParam("tag", it).toUriString(),
type = MEDIATYPE_OPDS_JSON_VALUE,
rel = if (it == tag) OpdsLinkRel.SELF else null,
)
}
referentialRepository.findAllBookTagsBySeries(series.id, null).map {
WPLinkDto(
title = it,
href = uriBuilder.cloneBuilder().queryParam("tag", it).toUriString(),
type = MEDIATYPE_OPDS_JSON_VALUE,
rel = if (it == tag) OpdsLinkRel.SELF else null,
)
}
FeedDto(
metadata =
@ -749,32 +739,29 @@ class Opds2Controller(
val pageable = PageRequest.of(0, 20, Sort.by("relevance"))
val resultsSeries =
seriesDtoRepository
.findAll(
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
searchTerm = query,
oneshot = false,
deleted = false,
),
principal.user.id,
pageable,
principal.user.restrictions,
)
.map { it.toWPLinkDto() }
seriesDtoRepository.findAll(
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
searchTerm = query,
oneshot = false,
deleted = false,
),
principal.user.id,
pageable,
principal.user.restrictions,
).map { it.toWPLinkDto() }
val resultsBooks =
bookDtoRepository
.findAll(
BookSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
searchTerm = query,
deleted = false,
),
principal.user.id,
pageable,
principal.user.restrictions,
).map { webPubGenerator.toOpdsPublicationDto(it, true) }
bookDtoRepository.findAll(
BookSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
searchTerm = query,
deleted = false,
),
principal.user.id,
pageable,
principal.user.restrictions,
).map { opdsGenerator.toOpdsPublicationDto(it) }
val resultsCollections =
collectionRepository.findAll(
@ -815,6 +802,9 @@ class Opds2Controller(
)
}
@GetMapping(value = [ROUTE_AUTH], produces = [MEDIATYPE_OPDS_AUTHENTICATION_JSON_VALUE])
fun getAuthDocument() = opdsGenerator.generateOpdsAuthDocument()
private fun Library.toWPLinkDto(): WPLinkDto =
WPLinkDto(
title = name,

View file

@ -0,0 +1,58 @@
package org.gotson.komga.interfaces.api.opds.v2.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonValue
import org.gotson.komga.interfaces.api.dto.WPLinkDto
/**
* https://drafts.opds.io/authentication-for-opds-1.0.html#23-syntax
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class AuthenticationDocumentDto(
/**
* A list of supported Authentication Flows as defined in section 3. Authentication Flows.
*/
val authentication: List<AuthenticationFlowDto>,
/**
* Title of the Catalog being accessed.
*/
val title: String,
/**
* Unique identifier for the Catalog provider and canonical location for the Authentication Document.
*/
val id: String,
/**
* A description of the service being displayed to the user.
*/
val description: String? = null,
/**
* An Authentication Document may also contain a links object.
* This is used to associate the Authentication Document with resources that are not locally available.
*/
val links: List<WPLinkDto> = emptyList(),
)
/**
* In addition to the Authentication Document, this specification also defines multiple scenarios to handle how the client is authenticated.
* Each Authentication Document contains at least one Authentication Object that describes how a client can leverage an Authentication Flow.
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class AuthenticationFlowDto(
val type: AuthenticationType,
val labels: LabelsDto? = null,
val links: List<WPLinkDto> = emptyList(),
)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class LabelsDto(
val login: String? = null,
val password: String? = null,
)
enum class AuthenticationType(
@get:JsonValue val value: String,
) {
BASIC("http://opds-spec.org/auth/basic"),
OAUTH2_IMPLICIT("http://opds-spec.org/auth/oauth/implicit"),
OAUTH2_PASSWORD("http://opds-spec.org/auth/oauth/password"),
}

View file

@ -53,9 +53,11 @@ import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault
import org.gotson.komga.infrastructure.web.setCachePrivate
import org.gotson.komga.interfaces.api.OpdsGenerator
import org.gotson.komga.interfaces.api.WebPubGenerator
import org.gotson.komga.interfaces.api.checkContentRestriction
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_DIVINA_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_POSITION_LIST_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_PROGRESSION_JSON_VALUE
@ -135,6 +137,7 @@ class BookController(
private val eventPublisher: ApplicationEventPublisher,
private val thumbnailBookRepository: ThumbnailBookRepository,
private val webPubGenerator: WebPubGenerator,
private val opdsGenerator: OpdsGenerator,
) {
@PageableAsQueryParam
@GetMapping("api/v1/books")
@ -403,6 +406,8 @@ class BookController(
"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],
)
@ -495,7 +500,10 @@ class BookController(
getBookPageInternal(bookId, pageNumber + 1, convertTo, request, principal, null)
@ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))])
@GetMapping("api/v1/books/{bookId}/pages/{pageNumber}", produces = [MediaType.ALL_VALUE])
@GetMapping(
value = ["api/v1/books/{bookId}/pages/{pageNumber}"],
produces = [MediaType.ALL_VALUE],
)
@PreAuthorize("hasRole('$ROLE_PAGE_STREAMING')")
fun getBookPage(
@AuthenticationPrincipal principal: KomgaPrincipal,
@ -517,6 +525,26 @@ class BookController(
): ResponseEntity<ByteArray> =
getBookPageInternal(bookId, if (zeroBasedIndex) pageNumber + 1 else pageNumber, convertTo, request, principal, acceptHeaders)
@ApiResponse(content = [Content(mediaType = "image/*", schema = Schema(type = "string", format = "binary"))])
@GetMapping(
value = ["opds/v2/books/{bookId}/pages/{pageNumber}"],
produces = [MediaType.ALL_VALUE],
)
@PreAuthorize("hasRole('$ROLE_PAGE_STREAMING')")
fun getBookPageOpdsv2(
@AuthenticationPrincipal principal: KomgaPrincipal,
request: ServletWebRequest,
@PathVariable bookId: String,
@PathVariable pageNumber: Int,
@Parameter(
description = "Convert the image to the provided format.",
schema = Schema(allowableValues = ["jpeg", "png"]),
)
@RequestParam(value = "convert", required = false)
convertTo: String?,
): ResponseEntity<ByteArray> =
getBookPageInternal(bookId, pageNumber, convertTo, request, principal, null)
private fun getBookPageInternal(
bookId: String,
pageNumber: Int,
@ -582,7 +610,10 @@ class BookController(
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
value = ["api/v1/books/{bookId}/pages/{pageNumber}/raw"],
value = [
"api/v1/books/{bookId}/pages/{pageNumber}/raw",
"opds/v2/books/{bookId}/pages/{pageNumber}/raw",
],
produces = [MediaType.ALL_VALUE],
)
@PreAuthorize("hasRole('$ROLE_PAGE_STREAMING')")
@ -687,18 +718,42 @@ class BookController(
fun getWebPubManifest(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<WPPublicationDto> =
): ResponseEntity<WPPublicationDto> {
val manifest = getWebPubManifestInternal(principal, bookId, webPubGenerator)
return ResponseEntity.ok()
.contentType(manifest.mediaType)
.body(manifest)
}
@GetMapping(
value = ["opds/v2/books/{bookId}/manifest"],
produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE],
)
fun getWebPubManifestOpds(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): WPPublicationDto =
getWebPubManifestInternal(principal, bookId, opdsGenerator)
private fun getWebPubManifestInternal(
principal: KomgaPrincipal,
bookId: String,
webPubGenerator: WebPubGenerator,
) =
mediaRepository.findByIdOrNull(bookId)?.let { media ->
when (KomgaMediaType.fromMediaType(media.mediaType)?.profile) {
MediaProfile.DIVINA -> getWebPubManifestDivina(principal, bookId)
MediaProfile.PDF -> getWebPubManifestPdf(principal, bookId)
MediaProfile.EPUB -> getWebPubManifestEpub(principal, bookId)
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}"],
value = [
"api/v1/books/{bookId}/resource/{*resource}",
"opds/v2/books/{bookId}/resource/{*resource}",
],
produces = ["*/*"],
)
fun getBookResource(
@ -821,19 +876,32 @@ class BookController(
fun getWebPubManifestEpub(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<WPPublicationDto> =
): WPPublicationDto =
getWebPubManifestEpubInternal(principal, bookId, webPubGenerator)
@GetMapping(
value = ["opds/v2/books/{bookId}/manifest/epub"],
produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE],
)
fun getWebPubManifestEpubOpds(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): WPPublicationDto =
getWebPubManifestEpubInternal(principal, bookId, opdsGenerator)
private fun getWebPubManifestEpubInternal(
principal: KomgaPrincipal,
bookId: String,
webPubGenerator: WebPubGenerator,
) =
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto ->
if (bookDto.media.mediaProfile != MediaProfile.EPUB.name) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile")
principal.user.checkContentRestriction(bookDto)
val manifest =
webPubGenerator.toManifestEpub(
bookDto,
mediaRepository.findById(bookId),
seriesMetadataRepository.findById(bookDto.seriesId),
)
ResponseEntity.ok()
.contentType(manifest.mediaType)
.body(manifest)
webPubGenerator.toManifestEpub(
bookDto,
mediaRepository.findById(bookId),
seriesMetadataRepository.findById(bookDto.seriesId),
)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
@ -843,19 +911,32 @@ class BookController(
fun getWebPubManifestPdf(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<WPPublicationDto> =
): WPPublicationDto =
getWebPubManifestPdfInternal(principal, bookId, webPubGenerator)
@GetMapping(
value = ["opds/v2/books/{bookId}/manifest/pdf"],
produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE],
)
fun getWebPubManifestPdfOpds(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): WPPublicationDto =
getWebPubManifestPdfInternal(principal, bookId, opdsGenerator)
private fun getWebPubManifestPdfInternal(
principal: KomgaPrincipal,
bookId: String,
webPubGenerator: WebPubGenerator,
) =
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto ->
if (bookDto.media.mediaProfile != MediaProfile.PDF.name) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile")
principal.user.checkContentRestriction(bookDto)
val manifest =
webPubGenerator.toManifestPdf(
bookDto,
mediaRepository.findById(bookDto.id),
seriesMetadataRepository.findById(bookDto.seriesId),
)
ResponseEntity.ok()
.contentType(manifest.mediaType)
.body(manifest)
webPubGenerator.toManifestPdf(
bookDto,
mediaRepository.findById(bookDto.id),
seriesMetadataRepository.findById(bookDto.seriesId),
)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
@ -865,18 +946,31 @@ class BookController(
fun getWebPubManifestDivina(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<WPPublicationDto> =
): WPPublicationDto =
getWebPubManifestDivinaInternal(principal, bookId, webPubGenerator)
@GetMapping(
value = ["opds/v2/books/{bookId}/manifest/divina"],
produces = [MEDIATYPE_OPDS_PUBLICATION_JSON_VALUE],
)
fun getWebPubManifestDivinaOpds(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): WPPublicationDto =
getWebPubManifestDivinaInternal(principal, bookId, opdsGenerator)
private fun getWebPubManifestDivinaInternal(
principal: KomgaPrincipal,
bookId: String,
webPubGenerator: WebPubGenerator,
) =
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto ->
principal.user.checkContentRestriction(bookDto)
val manifest =
webPubGenerator.toManifestDivina(
bookDto,
mediaRepository.findById(bookDto.id),
seriesMetadataRepository.findById(bookDto.seriesId),
)
ResponseEntity.ok()
.contentType(manifest.mediaType)
.body(manifest)
webPubGenerator.toManifestDivina(
bookDto,
mediaRepository.findById(bookDto.id),
seriesMetadataRepository.findById(bookDto.seriesId),
)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PostMapping("api/v1/books/{bookId}/analyze")