diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20240529120934__syncpoint.sql b/komga/src/flyway/resources/db/migration/sqlite/V20240529120934__syncpoint.sql new file mode 100644 index 000000000..e36b0239c --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20240529120934__syncpoint.sql @@ -0,0 +1,41 @@ +CREATE TABLE SYNC_POINT +( + ID varchar NOT NULL PRIMARY KEY, + CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + USER_ID varchar NOT NULL, + API_KEY_ID varchar NULL, + FOREIGN KEY (USER_ID) REFERENCES USER (ID) +); + +create index if not exists idx__sync_point__user_id + on SYNC_POINT (USER_ID); + +CREATE TABLE SYNC_POINT_BOOK_REMOVED_SYNCED +( + SYNC_POINT_ID varchar NOT NULL, + BOOK_ID varchar NOT NULL, + PRIMARY KEY (SYNC_POINT_ID, BOOK_ID), + FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID) +); + +create index if not exists idx__sync_point_book_removed_status__sync_point_id + on SYNC_POINT_BOOK_REMOVED_SYNCED (SYNC_POINT_ID); + +CREATE TABLE SYNC_POINT_BOOK +( + SYNC_POINT_ID varchar NOT NULL, + BOOK_ID varchar NOT NULL, + BOOK_CREATED_DATE datetime NOT NULL, + BOOK_LAST_MODIFIED_DATE datetime NOT NULL, + BOOK_FILE_LAST_MODIFIED datetime NOT NULL, + BOOK_FILE_SIZE int8 NOT NULL, + BOOK_FILE_HASH varchar NOT NULL, + BOOK_METADATA_LAST_MODIFIED_DATE datetime NOT NULL, + BOOK_READ_PROGRESS_LAST_MODIFIED_DATE datetime NULL, + SYNCED boolean NOT NULL default false, + PRIMARY KEY (SYNC_POINT_ID, BOOK_ID), + FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID) +); + +create index if not exists idx__sync_point_book__sync_point_id + on SYNC_POINT_BOOK (SYNC_POINT_ID); diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20240614170012__user_kobo_role.sql b/komga/src/flyway/resources/db/migration/sqlite/V20240614170012__user_kobo_role.sql new file mode 100644 index 000000000..2e0cda28c --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20240614170012__user_kobo_role.sql @@ -0,0 +1,6 @@ +alter table USER + add column ROLE_KOBO_SYNC boolean NOT NULL DEFAULT 0; + +update USER +set ROLE_KOBO_SYNC = 1 +where ROLE_ADMIN = 1; diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt index 370384bbe..fe093a6ad 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt @@ -7,6 +7,7 @@ open class BookSearch( val seriesIds: Collection? = null, val searchTerm: String? = null, val mediaStatus: Collection? = null, + val mediaProfile: Collection? = null, val deleted: Boolean? = null, val releasedAfter: LocalDate? = null, ) @@ -16,6 +17,7 @@ class BookSearchWithReadProgress( seriesIds: Collection? = null, searchTerm: String? = null, mediaStatus: Collection? = null, + mediaProfile: Collection? = null, deleted: Boolean? = null, releasedAfter: LocalDate? = null, val tags: Collection? = null, @@ -26,6 +28,7 @@ class BookSearchWithReadProgress( seriesIds = seriesIds, searchTerm = searchTerm, mediaStatus = mediaStatus, + mediaProfile = mediaProfile, deleted = deleted, releasedAfter = releasedAfter, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaSyncToken.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaSyncToken.kt new file mode 100644 index 000000000..6920a7043 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaSyncToken.kt @@ -0,0 +1,14 @@ +package org.gotson.komga.domain.model + +data class KomgaSyncToken( + val version: Int = 1, + val rawKoboSyncToken: String = "", + /** + * Only if a sync is currently ongoing, else null. + */ + val ongoingSyncPointId: String? = null, + /** + * The last successful SyncPoint ID. + */ + val lastSuccessfulSyncPointId: String? = null, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt index 5d609f190..b07a62f68 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt @@ -10,6 +10,7 @@ const val ROLE_USER = "USER" const val ROLE_ADMIN = "ADMIN" const val ROLE_FILE_DOWNLOAD = "FILE_DOWNLOAD" const val ROLE_PAGE_STREAMING = "PAGE_STREAMING" +const val ROLE_KOBO_SYNC = "KOBO_SYNC" data class KomgaUser( @Email(regexp = ".+@.+\\..+") @@ -20,6 +21,7 @@ data class KomgaUser( val roleAdmin: Boolean, val roleFileDownload: Boolean = true, val rolePageStreaming: Boolean = true, + val roleKoboSync: Boolean = false, val sharedLibrariesIds: Set = emptySet(), val sharedAllLibraries: Boolean = true, val restrictions: ContentRestrictions = ContentRestrictions(), @@ -34,6 +36,7 @@ data class KomgaUser( if (roleAdmin) add(ROLE_ADMIN) if (roleFileDownload) add(ROLE_FILE_DOWNLOAD) if (rolePageStreaming) add(ROLE_PAGE_STREAMING) + if (roleKoboSync) add(ROLE_KOBO_SYNC) } } @@ -107,5 +110,5 @@ data class KomgaUser( } override fun toString(): String = - "KomgaUser(email='$email', roleAdmin=$roleAdmin, roleFileDownload=$roleFileDownload, rolePageStreaming=$rolePageStreaming, sharedLibrariesIds=$sharedLibrariesIds, sharedAllLibraries=$sharedAllLibraries, restrictions=$restrictions, id='$id', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate)" + "KomgaUser(email='$email', roleAdmin=$roleAdmin, roleFileDownload=$roleFileDownload, rolePageStreaming=$rolePageStreaming, roleKoboSync=$roleKoboSync, sharedLibrariesIds=$sharedLibrariesIds, sharedAllLibraries=$sharedAllLibraries, restrictions=$restrictions, id='$id', createdDate=$createdDate, lastModifiedDate=$lastModifiedDate)" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaType.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaType.kt index 592e77a76..b676a9b63 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaType.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/MediaType.kt @@ -10,6 +10,8 @@ enum class MediaType(val type: String, val profile: MediaProfile, val fileExtens ; companion object { - fun fromMediaType(mediaType: String?): MediaType? = values().firstOrNull { it.type == mediaType } + fun fromMediaType(mediaType: String?): MediaType? = entries.firstOrNull { it.type == mediaType } + + fun matchingMediaProfile(mediaProfile: MediaProfile): Collection = entries.filter { it.profile == mediaProfile } } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/SyncPoint.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/SyncPoint.kt new file mode 100644 index 000000000..38fb209cd --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/SyncPoint.kt @@ -0,0 +1,22 @@ +package org.gotson.komga.domain.model + +import java.time.ZonedDateTime + +data class SyncPoint( + val id: String, + val userId: String, + val apiKeyId: String?, + val createdDate: ZonedDateTime, +) { + data class Book( + val syncPointId: String, + val bookId: String, + val createdDate: ZonedDateTime, + val lastModifiedDate: ZonedDateTime, + val fileLastModified: ZonedDateTime, + val fileSize: Long, + val fileHash: String, + val metadataLastModifiedDate: ZonedDateTime, + val synced: Boolean, + ) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt index b254a3bef..feac8a451 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt @@ -73,6 +73,8 @@ interface BookRepository { sort: Sort, ): Collection + fun existsById(bookId: String): Boolean + fun insert(book: Book) fun insert(books: Collection) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SyncPointRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SyncPointRepository.kt new file mode 100644 index 000000000..a2041b65b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SyncPointRepository.kt @@ -0,0 +1,68 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.SyncPoint +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface SyncPointRepository { + fun create( + user: KomgaUser, + apiKeyId: String?, + search: BookSearch, + ): SyncPoint + + fun findByIdOrNull(syncPointId: String): SyncPoint? + + fun findBooksById( + syncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findBooksAdded( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findBooksRemoved( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findBooksChanged( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun findBooksReadProgressChanged( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page + + fun markBooksSynced( + syncPointId: String, + forRemovedBooks: Boolean, + bookIds: Collection, + ) + + fun deleteByUserId(userId: String) + + fun deleteByUserIdAndApiKeyIds( + userId: String, + apiKeyIds: Collection, + ) + + fun deleteOne(syncPointId: String) + + fun deleteAll() +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt index 890169fde..5936f0e24 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -472,15 +472,18 @@ class BookLifecycle( // match progression with positions val matchingPositions = extension.positions.filter { it.href == href } val matchedPosition = - matchingPositions.firstOrNull { it.locations!!.progression == newProgression.locator.locations!!.progression } - ?: run { - // no exact match - val before = matchingPositions.filter { it.locations!!.progression!! < newProgression.locator.locations!!.progression!! }.maxByOrNull { it.locations!!.position!! } - val after = matchingPositions.filter { it.locations!!.progression!! > newProgression.locator.locations!!.progression!! }.minByOrNull { it.locations!!.position!! } - if (before == null || after == null || before.locations!!.position!! > after.locations!!.position!!) - throw IllegalArgumentException("Invalid progression") - before - } + if (extension.isFixedLayout && matchingPositions.size == 1) + matchingPositions.first() + else + matchingPositions.firstOrNull { it.locations!!.progression == newProgression.locator.locations!!.progression } + ?: run { + // no exact match + val before = matchingPositions.filter { it.locations!!.progression!! < newProgression.locator.locations!!.progression!! }.maxByOrNull { it.locations!!.position!! } + val after = matchingPositions.filter { it.locations!!.progression!! > newProgression.locator.locations!!.progression!! }.minByOrNull { it.locations!!.position!! } + if (before == null || after == null || before.locations!!.position!! > after.locations!!.position!!) + throw IllegalArgumentException("Invalid progression") + before + } val totalProgression = matchedPosition.locations?.totalProgression ReadProgress( @@ -491,7 +494,8 @@ class BookLifecycle( newProgression.modified.toLocalDateTime().toCurrentTimeZone(), newProgression.device.id, newProgression.device.name, - newProgression.locator, + // use the type we have instead of the one provided + newProgression.locator.copy(type = matchedPosition.type), ) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt index 09f2698fb..4760517af 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycle.kt @@ -1,13 +1,18 @@ package org.gotson.komga.domain.service import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.ApiKey import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.DuplicateNameException import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.UserEmailAlreadyExistsException import org.gotson.komga.domain.persistence.AuthenticationActivityRepository import org.gotson.komga.domain.persistence.KomgaUserRepository import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SyncPointRepository import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.security.TokenEncoder +import org.gotson.komga.infrastructure.security.apikey.ApiKeyGenerator import org.springframework.context.ApplicationEventPublisher import org.springframework.security.core.session.SessionRegistry import org.springframework.security.crypto.password.PasswordEncoder @@ -21,10 +26,13 @@ class KomgaUserLifecycle( private val userRepository: KomgaUserRepository, private val readProgressRepository: ReadProgressRepository, private val authenticationActivityRepository: AuthenticationActivityRepository, + private val syncPointRepository: SyncPointRepository, private val passwordEncoder: PasswordEncoder, + private val tokenEncoder: TokenEncoder, private val sessionRegistry: SessionRegistry, private val transactionTemplate: TransactionTemplate, private val eventPublisher: ApplicationEventPublisher, + private val apiKeyGenerator: ApiKeyGenerator, ) { fun updatePassword( user: KomgaUser, @@ -78,6 +86,7 @@ class KomgaUserLifecycle( transactionTemplate.executeWithoutResult { readProgressRepository.deleteByUserId(user.id) authenticationActivityRepository.deleteByUser(user) + syncPointRepository.deleteByUserId(user.id) userRepository.delete(user.id) } @@ -95,4 +104,32 @@ class KomgaUserLifecycle( it.expireNow() } } + + /** + * Create and persist an API key for the user. + * @return the ApiKey, or null if a unique API key could not be generated + */ + fun createApiKey( + user: KomgaUser, + comment: String, + ): ApiKey? { + val commentTrimmed = comment.trim() + if (userRepository.existsApiKeyByCommentAndUserId(commentTrimmed, user.id)) + throw DuplicateNameException("api key comment already exists for this user", "ERR_1034") + for (attempt in 1..10) { + try { + val plainTextKey = + ApiKey( + userId = user.id, + key = apiKeyGenerator.generate(), + comment = commentTrimmed, + ) + userRepository.insert(plainTextKey.copy(key = tokenEncoder.encode(plainTextKey.key))) + return plainTextKey + } catch (e: Exception) { + logger.debug { "Failed to generate unique api key, attempt #$attempt" } + } + } + return null + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/SyncPointLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/SyncPointLifecycle.kt new file mode 100644 index 000000000..9db08e081 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/SyncPointLifecycle.kt @@ -0,0 +1,86 @@ +package org.gotson.komga.domain.service + +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.SyncPoint +import org.gotson.komga.domain.persistence.SyncPointRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Component + +@Component +class SyncPointLifecycle( + private val syncPointRepository: SyncPointRepository, +) { + fun createSyncPoint( + user: KomgaUser, + apiKeyId: String?, + libraryIds: List?, + ): SyncPoint = + syncPointRepository.create( + user, + apiKeyId, + BookSearch( + libraryIds = user.getAuthorizedLibraryIds(libraryIds), + mediaStatus = setOf(Media.Status.READY), + mediaProfile = listOf(MediaProfile.EPUB), + deleted = false, + ), + ) + + /** + * Retrieve a page of un-synced books and mark them as synced. + */ + fun takeBooks( + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository.findBooksById(toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) } + + /** + * Retrieve a page of un-synced added books and mark them as synced. + */ + fun takeBooksAdded( + fromSyncPointId: String, + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository.findBooksAdded(fromSyncPointId, toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) } + + /** + * Retrieve a page of un-synced changed books and mark them as synced. + */ + fun takeBooksChanged( + fromSyncPointId: String, + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository.findBooksChanged(fromSyncPointId, toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) } + + /** + * Retrieve a page of un-synced removed books and mark them as synced. + */ + fun takeBooksRemoved( + fromSyncPointId: String, + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository.findBooksRemoved(fromSyncPointId, toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markBooksSynced(toSyncPointId, true, page.content.map { it.bookId }) } + + /** + * Retrieve a page of un-synced unchanged books with changed read progress and mark them as synced. + */ + fun takeBooksReadProgressChanged( + fromSyncPointId: String, + toSyncPointId: String, + pageable: Pageable, + ): Page = + syncPointRepository.findBooksReadProgressChanged(fromSyncPointId, toSyncPointId, true, pageable) + .also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaProperties.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaProperties.kt index 333903e03..cebd7b573 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaProperties.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaProperties.kt @@ -67,6 +67,8 @@ class KomgaProperties { var configDir: String? = null + var kobo = Kobo() + @Positive @Deprecated("Artemis has been replaced") var taskConsumers: Int = 1 @@ -130,4 +132,9 @@ class KomgaProperties { var preserveOriginal: Boolean = true } } + + class Kobo { + @get:Positive + var syncItemLimit: Int = 100 + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaSettingsProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaSettingsProvider.kt index f01e6bfdb..51e6ccf52 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaSettingsProvider.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/configuration/KomgaSettingsProvider.kt @@ -85,6 +85,13 @@ class KomgaSettingsProvider( serverSettingsDao.deleteSetting(Settings.SERVER_CONTEXT_PATH.name) field = value } + + var koboProxy: Boolean = + serverSettingsDao.getSettingByKey(Settings.KOBO_PROXY.name, Boolean::class.java) ?: false + set(value) { + serverSettingsDao.saveSetting(Settings.KOBO_PROXY.name, value) + field = value + } } private enum class Settings { @@ -96,4 +103,5 @@ private enum class Settings { TASK_POOL_SIZE, SERVER_PORT, SERVER_CONTEXT_PATH, + KOBO_PROXY, } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt index 2d542526f..d06e369ed 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt @@ -2,7 +2,10 @@ package org.gotson.komga.infrastructure.jooq import com.fasterxml.jackson.databind.ObjectMapper import org.gotson.komga.domain.model.AllowExclude +import org.gotson.komga.domain.model.BookSearch import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.MediaExtension +import org.gotson.komga.domain.model.MediaType import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource import org.gotson.komga.jooq.main.Tables import org.jooq.Condition @@ -67,6 +70,21 @@ fun DSLContext.insertTempStrings( fun DSLContext.selectTempStrings() = this.select(Tables.TEMP_STRING_LIST.STRING).from(Tables.TEMP_STRING_LIST) +fun BookSearch.toCondition(): Condition { + var c: Condition = DSL.noCondition() + + if (libraryIds != null) c = c.and(Tables.BOOK.LIBRARY_ID.`in`(libraryIds)) + if (!seriesIds.isNullOrEmpty()) c = c.and(Tables.BOOK.SERIES_ID.`in`(seriesIds)) + searchTerm?.let { c = c.and(Tables.BOOK_METADATA.TITLE.containsIgnoreCase(it)) } + if (!mediaStatus.isNullOrEmpty()) c = c.and(Tables.MEDIA.STATUS.`in`(mediaStatus)) + if (!mediaProfile.isNullOrEmpty()) c = c.and(Tables.MEDIA.MEDIA_TYPE.`in`(mediaProfile.flatMap { profile -> MediaType.matchingMediaProfile(profile).map { it.type } }.toSet())) + if (deleted == true) c = c.and(Tables.BOOK.DELETED_DATE.isNotNull) + if (deleted == false) c = c.and(Tables.BOOK.DELETED_DATE.isNull) + if (releasedAfter != null) c = c.and(Tables.BOOK_METADATA.RELEASE_DATE.gt(releasedAfter)) + + return c +} + fun ContentRestrictions.toCondition(dsl: DSLContext): Condition { val ageAllowed = if (ageRestriction?.restriction == AllowExclude.ALLOW_ONLY) { @@ -127,3 +145,17 @@ inline fun ObjectMapper.deserializeJsonGz(gzJson: ByteArray?): T? { null } } + +fun ObjectMapper.deserializeMediaExtension( + extensionClass: String?, + extensionBlob: ByteArray?, +): MediaExtension? { + if (extensionClass == null || extensionBlob == null) return null + return try { + GZIPInputStream(extensionBlob.inputStream()).use { gz -> + this.readValue(gz, Class.forName(extensionClass)) as MediaExtension + } + } catch (e: Exception) { + null + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDao.kt index dee6b3c67..5ec7c06d6 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDao.kt @@ -5,11 +5,11 @@ import org.gotson.komga.domain.model.BookSearch import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.infrastructure.jooq.insertTempStrings import org.gotson.komga.infrastructure.jooq.selectTempStrings +import org.gotson.komga.infrastructure.jooq.toCondition import org.gotson.komga.infrastructure.jooq.toOrderBy import org.gotson.komga.jooq.main.Tables import org.gotson.komga.jooq.main.tables.records.BookRecord import org.gotson.komga.language.toCurrentTimeZone -import org.jooq.Condition import org.jooq.DSLContext import org.jooq.impl.DSL import org.springframework.beans.factory.annotation.Value @@ -230,6 +230,9 @@ class BookDao( .fetch(b.ID) } + override fun existsById(bookId: String): Boolean = + dsl.fetchExists(b, b.ID.eq(bookId)) + override fun findAllByLibraryIdAndMediaTypes( libraryId: String, mediaTypes: Collection, @@ -364,20 +367,6 @@ class BookDao( .groupBy(b.LIBRARY_ID) .fetchMap(b.LIBRARY_ID, DSL.sum(b.FILE_SIZE)) - private fun BookSearch.toCondition(): Condition { - var c: Condition = DSL.trueCondition() - - if (libraryIds != null) c = c.and(b.LIBRARY_ID.`in`(libraryIds)) - if (!seriesIds.isNullOrEmpty()) c = c.and(b.SERIES_ID.`in`(seriesIds)) - searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) } - if (!mediaStatus.isNullOrEmpty()) c = c.and(m.STATUS.`in`(mediaStatus)) - if (deleted == true) c = c.and(b.DELETED_DATE.isNotNull) - if (deleted == false) c = c.and(b.DELETED_DATE.isNull) - if (releasedAfter != null) c = c.and(d.RELEASE_DATE.gt(releasedAfter)) - - return c - } - private fun BookRecord.toDomain() = Book( name = name, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt index f64bda05d..98095cc6e 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDtoDao.kt @@ -2,6 +2,7 @@ package org.gotson.komga.infrastructure.jooq.main import org.gotson.komga.domain.model.BookSearchWithReadProgress import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.MediaType import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource @@ -442,6 +443,7 @@ class BookDtoDao( if (libraryIds != null) c = c.and(b.LIBRARY_ID.`in`(libraryIds)) if (!seriesIds.isNullOrEmpty()) c = c.and(b.SERIES_ID.`in`(seriesIds)) if (!mediaStatus.isNullOrEmpty()) c = c.and(m.STATUS.`in`(mediaStatus)) + if (!mediaProfile.isNullOrEmpty()) c = c.and(m.MEDIA_TYPE.`in`(mediaProfile.flatMap { profile -> MediaType.matchingMediaProfile(profile).map { it.type } }.toSet())) if (deleted == true) c = c.and(b.DELETED_DATE.isNotNull) if (deleted == false) c = c.and(b.DELETED_DATE.isNull) if (releasedAfter != null) c = c.and(d.RELEASE_DATE.gt(releasedAfter)) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KoboDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KoboDtoDao.kt new file mode 100644 index 000000000..33c9a7cb6 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KoboDtoDao.kt @@ -0,0 +1,113 @@ +package org.gotson.komga.infrastructure.jooq.main + +import com.fasterxml.jackson.databind.ObjectMapper +import org.gotson.komga.domain.model.MediaExtensionEpub +import org.gotson.komga.infrastructure.jooq.deserializeMediaExtension +import org.gotson.komga.interfaces.api.kobo.dto.ContributorDto +import org.gotson.komga.interfaces.api.kobo.dto.DownloadUrlDto +import org.gotson.komga.interfaces.api.kobo.dto.FormatDto +import org.gotson.komga.interfaces.api.kobo.dto.KoboBookMetadataDto +import org.gotson.komga.interfaces.api.kobo.dto.KoboSeriesDto +import org.gotson.komga.interfaces.api.kobo.dto.PublisherDto +import org.gotson.komga.interfaces.api.kobo.persistence.KoboDtoRepository +import org.gotson.komga.jooq.main.Tables +import org.jooq.DSLContext +import org.springframework.stereotype.Component +import org.springframework.web.util.UriBuilder +import java.time.ZoneId + +@Component +class KoboDtoDao( + private val dsl: DSLContext, + private val mapper: ObjectMapper, +) : KoboDtoRepository { + private val b = Tables.BOOK + private val m = Tables.MEDIA + private val d = Tables.BOOK_METADATA + private val a = Tables.BOOK_METADATA_AUTHOR + private val sd = Tables.SERIES_METADATA + + override fun findBookMetadataByIds( + bookIds: Collection, + downloadUriBuilder: UriBuilder, + ): Collection { + val records = + dsl.select( + d.BOOK_ID, + d.TITLE, + d.NUMBER, + d.NUMBER_SORT, + d.ISBN, + d.SUMMARY, + d.RELEASE_DATE, + d.CREATED_DATE, + sd.SERIES_ID, + sd.TITLE, + sd.PUBLISHER, + sd.LANGUAGE, + b.FILE_SIZE, + b.ONESHOT, + m.EXTENSION_CLASS, + m.EXTENSION_VALUE_BLOB, + ).from(b) + .leftJoin(d).on(b.ID.eq(d.BOOK_ID)) + .leftJoin(sd).on(b.SERIES_ID.eq(sd.SERIES_ID)) + .leftJoin(m).on(b.ID.eq(m.BOOK_ID)) + .where(d.BOOK_ID.`in`(bookIds)) + .fetch() + + return records.map { rec -> + val br = rec.into(b) + val dr = rec.into(d) + val sr = rec.into(sd) + val mr = rec.into(m) + val mediaExtension = mapper.deserializeMediaExtension(mr.extensionClass, mr.extensionValueBlob) as? MediaExtensionEpub + + val authors = + dsl.selectFrom(a) + .where(a.BOOK_ID.`in`(bookIds)) + .filter { it.name != null } + .groupBy({ it.bookId }, { it }) + + KoboBookMetadataDto( + contributorRoles = authors[dr.bookId].orEmpty().map { ContributorDto(it.name) }, + contributors = authors[dr.bookId].orEmpty().map { it.name }, + coverImageId = dr.bookId, + crossRevisionId = dr.bookId, + // if null or empty Kobo will not update it, force it to blank + description = dr.summary.ifEmpty { " " }, + downloadUrls = + listOf( + DownloadUrlDto( + format = if (mediaExtension?.isFixedLayout == true) FormatDto.EPUB3FL else FormatDto.EPUB3, + size = br.fileSize, + url = downloadUriBuilder.build(dr.bookId).toURL().toString(), + ), + DownloadUrlDto( + format = FormatDto.EPUB, + size = br.fileSize, + url = downloadUriBuilder.build(dr.bookId).toURL().toString(), + ), + ), + entitlementId = dr.bookId, + isbn = dr.isbn.ifBlank { null }, + language = sr.language.take(2).ifBlank { "en" }, + publicationDate = dr.releaseDate?.atStartOfDay(ZoneId.of("Z")) ?: dr.createdDate.atZone(ZoneId.of("Z")), + publisher = PublisherDto(name = sr.publisher), + revisionId = dr.bookId, + series = + if (!br.oneshot) + KoboSeriesDto( + id = sr.seriesId, + name = sr.title, + number = dr.number, + numberFloat = dr.numberSort, + ) + else + null, + title = dr.title, + workId = dr.bookId, + ) + } + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDao.kt index cc20e6d15..16abbbca0 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/MediaDao.kt @@ -9,6 +9,7 @@ import org.gotson.komga.domain.model.MediaExtension import org.gotson.komga.domain.model.MediaFile import org.gotson.komga.domain.model.ProxyExtension import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.infrastructure.jooq.deserializeMediaExtension import org.gotson.komga.infrastructure.jooq.insertTempStrings import org.gotson.komga.infrastructure.jooq.selectTempStrings import org.gotson.komga.infrastructure.jooq.serializeJsonGz @@ -24,7 +25,6 @@ import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime import java.time.ZoneId -import java.util.zip.GZIPInputStream private val logger = KotlinLogging.logger {} @@ -64,7 +64,7 @@ class MediaDao( .from(m) .where(m.BOOK_ID.eq(bookId)) .fetchOne() - ?.map { deserializeExtension(it.get(m.EXTENSION_CLASS), it.get(m.EXTENSION_VALUE_BLOB)) } + ?.map { mapper.deserializeMediaExtension(it.get(m.EXTENSION_CLASS), it.get(m.EXTENSION_VALUE_BLOB)) } override fun findAllBookIdsByLibraryIdAndMediaTypeAndWithMissingPageHash( libraryId: String, @@ -290,21 +290,6 @@ class MediaDao( lastModifiedDate = lastModifiedDate.toCurrentTimeZone(), ) - fun deserializeExtension( - extensionClass: String?, - extensionBlob: ByteArray?, - ): MediaExtension? { - if (extensionClass == null || extensionBlob == null) return null - return try { - GZIPInputStream(extensionBlob.inputStream()).use { gz -> - mapper.readValue(gz, Class.forName(extensionClass)) as MediaExtension - } - } catch (e: Exception) { - logger.error(e) { "Could not deserialize media extension class: $extensionClass" } - null - } - } - private fun MediaPageRecord.toDomain() = BookPage( fileName = fileName, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SyncPointDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SyncPointDao.kt new file mode 100644 index 000000000..0ed7e25f3 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/SyncPointDao.kt @@ -0,0 +1,335 @@ +package org.gotson.komga.infrastructure.jooq.main + +import com.github.f4b6a3.tsid.TsidCreator +import org.gotson.komga.domain.model.BookSearch +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.SyncPoint +import org.gotson.komga.domain.persistence.SyncPointRepository +import org.gotson.komga.infrastructure.jooq.toCondition +import org.gotson.komga.jooq.main.Tables +import org.gotson.komga.language.toZonedDateTime +import org.jooq.DSLContext +import org.jooq.SelectConditionStep +import org.jooq.impl.DSL +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class SyncPointDao( + private val dsl: DSLContext, +) : SyncPointRepository { + private val b = Tables.BOOK + private val m = Tables.MEDIA + private val d = Tables.BOOK_METADATA + private val r = Tables.READ_PROGRESS + private val sd = Tables.SERIES_METADATA + private val sp = Tables.SYNC_POINT + private val spb = Tables.SYNC_POINT_BOOK + private val spbs = Tables.SYNC_POINT_BOOK_REMOVED_SYNCED + + @Transactional + override fun create( + user: KomgaUser, + apiKeyId: String?, + search: BookSearch, + ): SyncPoint { + val conditions = search.toCondition().and(user.restrictions.toCondition(dsl)) + + val syncPointId = TsidCreator.getTsid256().toString() + val createdAt = LocalDateTime.now() + + dsl.insertInto( + sp, + sp.ID, + sp.USER_ID, + sp.API_KEY_ID, + sp.CREATED_DATE, + ).values( + syncPointId, + user.id, + apiKeyId, + createdAt, + ).execute() + + dsl.insertInto( + spb, + spb.SYNC_POINT_ID, + spb.BOOK_ID, + spb.BOOK_CREATED_DATE, + spb.BOOK_LAST_MODIFIED_DATE, + spb.BOOK_FILE_LAST_MODIFIED, + spb.BOOK_FILE_SIZE, + spb.BOOK_FILE_HASH, + spb.BOOK_METADATA_LAST_MODIFIED_DATE, + spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE, + ).select( + dsl.select( + DSL.`val`(syncPointId), + b.ID, + b.CREATED_DATE, + b.LAST_MODIFIED_DATE, + b.FILE_LAST_MODIFIED, + b.FILE_SIZE, + b.FILE_HASH, + d.LAST_MODIFIED_DATE, + r.LAST_MODIFIED_DATE, + ).from(b) + .join(m).on(b.ID.eq(m.BOOK_ID)) + .join(d).on(b.ID.eq(d.BOOK_ID)) + .join(sd).on(b.SERIES_ID.eq(sd.SERIES_ID)) + .leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(r.USER_ID.eq(user.id)) + .where(conditions), + ).execute() + + return findByIdOrNull(syncPointId)!! + } + + override fun findByIdOrNull(syncPointId: String): SyncPoint? = + dsl.selectFrom(sp) + .where(sp.ID.eq(syncPointId)) + .fetchInto(sp) + .map { + SyncPoint( + id = it.id, + userId = it.userId, + apiKeyId = it.apiKeyId, + createdDate = it.createdDate.toZonedDateTime(), + ) + }.firstOrNull() + + override fun findBooksById( + syncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val query = + dsl.selectFrom(spb) + .where(spb.SYNC_POINT_ID.eq(syncPointId)) + .apply { + if (onlyNotSynced) { + and(spb.SYNCED.isFalse) + } + } + + return queryToPage(query, pageable) + } + + override fun findBooksAdded( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val query = + dsl.selectFrom(spb) + .where(spb.SYNC_POINT_ID.eq(toSyncPointId)) + .apply { + if (onlyNotSynced) { + and(spb.SYNCED.isFalse) + } + } + .and( + spb.BOOK_ID.notIn( + dsl.select(spb.BOOK_ID).from(spb).where(spb.SYNC_POINT_ID.eq(fromSyncPointId)), + ), + ) + + return queryToPage(query, pageable) + } + + override fun findBooksRemoved( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val query = + dsl.selectFrom(spb) + .where(spb.SYNC_POINT_ID.eq(fromSyncPointId)) + .and( + spb.BOOK_ID.notIn( + dsl.select(spb.BOOK_ID).from(spb).where(spb.SYNC_POINT_ID.eq(toSyncPointId)), + ), + ) + .apply { + if (onlyNotSynced) + and( + spb.BOOK_ID.notIn( + dsl.select(spbs.BOOK_ID).from(spbs).where(spbs.SYNC_POINT_ID.eq(toSyncPointId)), + ), + ) + } + + return queryToPage(query, pageable) + } + + override fun findBooksChanged( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val spbFrom = spb.`as`("spbFrom") + val query = + dsl.select(*spb.fields()) + .from(spb) + .join(spbFrom).on(spb.BOOK_ID.eq(spbFrom.BOOK_ID)) + .where(spb.SYNC_POINT_ID.eq(toSyncPointId)) + .and(spbFrom.SYNC_POINT_ID.eq(fromSyncPointId)) + .apply { + if (onlyNotSynced) { + and(spb.SYNCED.isFalse) + } + } + .and( + spb.BOOK_FILE_LAST_MODIFIED.ne(spbFrom.BOOK_FILE_LAST_MODIFIED) + .or(spb.BOOK_FILE_SIZE.ne(spbFrom.BOOK_FILE_SIZE)) + .or(spb.BOOK_FILE_HASH.ne(spbFrom.BOOK_FILE_HASH).and(spbFrom.BOOK_FILE_HASH.isNotNull)) + .or(spb.BOOK_METADATA_LAST_MODIFIED_DATE.ne(spbFrom.BOOK_METADATA_LAST_MODIFIED_DATE)), + ) + + return queryToPage(query, pageable) + } + + override fun findBooksReadProgressChanged( + fromSyncPointId: String, + toSyncPointId: String, + onlyNotSynced: Boolean, + pageable: Pageable, + ): Page { + val spbFrom = spb.`as`("spbFrom") + val query = + dsl.select(*spb.fields()) + .from(spb) + .join(spbFrom).on(spb.BOOK_ID.eq(spbFrom.BOOK_ID)) + .where(spb.SYNC_POINT_ID.eq(toSyncPointId)) + .and(spbFrom.SYNC_POINT_ID.eq(fromSyncPointId)) + .apply { + if (onlyNotSynced) { + and(spb.SYNCED.isFalse) + } + } + .and( + // unchanged book + spb.BOOK_FILE_LAST_MODIFIED.eq(spbFrom.BOOK_FILE_LAST_MODIFIED) + .and(spb.BOOK_FILE_SIZE.eq(spbFrom.BOOK_FILE_SIZE)) + .and(spb.BOOK_FILE_HASH.eq(spbFrom.BOOK_FILE_HASH).or(spbFrom.BOOK_FILE_HASH.isNull)) + .and(spb.BOOK_METADATA_LAST_MODIFIED_DATE.eq(spbFrom.BOOK_METADATA_LAST_MODIFIED_DATE)) + // with changed read progress + .and( + spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.ne(spbFrom.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE) + .or(spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNull.and(spbFrom.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNotNull)) + .or(spb.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNotNull.and(spbFrom.BOOK_READ_PROGRESS_LAST_MODIFIED_DATE.isNull)), + ), + ) + + return queryToPage(query, pageable) + } + + override fun markBooksSynced( + syncPointId: String, + forRemovedBooks: Boolean, + bookIds: Collection, + ) { + // removed books are not present in the 'to' SyncPoint, only in the 'from' SyncPoint + // we store status in a separate table + if (bookIds.isNotEmpty()) { + if (forRemovedBooks) + dsl.batch( + dsl.insertInto(spbs, spbs.SYNC_POINT_ID, spbs.BOOK_ID).values(null as String?, null).onDuplicateKeyIgnore(), + ).also { step -> + bookIds.map { step.bind(syncPointId, it) } + }.execute() + else + dsl.update(spb) + .set(spb.SYNCED, true) + .where(spb.SYNC_POINT_ID.eq(syncPointId)) + .and(spb.BOOK_ID.`in`(bookIds)) + .execute() + } + } + + override fun deleteByUserId(userId: String) { + dsl.deleteFrom(spbs).where( + spbs.SYNC_POINT_ID.`in`( + dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId)), + ), + ).execute() + dsl.deleteFrom(spb).where( + spb.SYNC_POINT_ID.`in`( + dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId)), + ), + ).execute() + dsl.deleteFrom(sp).where(sp.USER_ID.eq(userId)).execute() + } + + override fun deleteByUserIdAndApiKeyIds( + userId: String, + apiKeyIds: Collection, + ) { + dsl.deleteFrom(spbs).where( + spbs.SYNC_POINT_ID.`in`( + dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds))), + ), + ).execute() + dsl.deleteFrom(spb).where( + spb.SYNC_POINT_ID.`in`( + dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds))), + ), + ).execute() + dsl.deleteFrom(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds))).execute() + } + + override fun deleteOne(syncPointId: String) { + dsl.deleteFrom(spbs).where(spbs.SYNC_POINT_ID.eq(syncPointId)).execute() + dsl.deleteFrom(spb).where(spb.SYNC_POINT_ID.eq(syncPointId)).execute() + dsl.deleteFrom(sp).where(sp.ID.eq(syncPointId)).execute() + } + + override fun deleteAll() { + dsl.deleteFrom(spbs).execute() + dsl.deleteFrom(spb).execute() + dsl.deleteFrom(sp).execute() + } + + private fun queryToPage( + query: SelectConditionStep<*>, + pageable: Pageable, + ): Page { + val count = dsl.fetchCount(query) + + val items = + query + .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } + .fetchInto(spb) + .map { + SyncPoint.Book( + syncPointId = it.syncPointId, + bookId = it.bookId, + createdDate = it.bookCreatedDate.atZone(ZoneId.of("Z")), + lastModifiedDate = it.bookLastModifiedDate.atZone(ZoneId.of("Z")), + fileLastModified = it.bookFileLastModified.atZone(ZoneId.of("Z")), + fileSize = it.bookFileSize, + fileHash = it.bookFileHash, + metadataLastModifiedDate = it.bookMetadataLastModifiedDate.atZone(ZoneId.of("Z")), + synced = it.synced, + ) + } + + return PageImpl( + items, + if (pageable.isPaged) + PageRequest.of(pageable.pageNumber, pageable.pageSize, Sort.unsorted()) + else + PageRequest.of(0, maxOf(count, 20), Sort.unsorted()), + count.toLong(), + ) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboHeaders.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboHeaders.kt new file mode 100644 index 000000000..675d0aaa3 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboHeaders.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.infrastructure.kobo + +object KoboHeaders { + const val X_KOBO_SYNCTOKEN = "x-kobo-synctoken" + const val X_KOBO_USERKEY = "X-Kobo-userkey" + const val X_KOBO_SYNC = "X-Kobo-sync" + const val X_KOBO_DEVICEID = "X-Kobo-deviceid" +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboProxy.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboProxy.kt new file mode 100644 index 000000000..9e5a76d82 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KoboProxy.kt @@ -0,0 +1,309 @@ +package org.gotson.komga.infrastructure.kobo + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNCTOKEN +import org.gotson.komga.infrastructure.web.getCurrentRequest +import org.gotson.komga.language.contains +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatusCode +import org.springframework.http.ResponseEntity +import org.springframework.http.client.ReactorNettyClientRequestFactory +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestClient +import org.springframework.web.client.toEntity +import org.springframework.web.server.ResponseStatusException +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +private val logger = KotlinLogging.logger {} + +@Component +class KoboProxy( + private val objectMapper: ObjectMapper, + private val komgaSyncTokenGenerator: KomgaSyncTokenGenerator, + private val komgaSettingsProvider: KomgaSettingsProvider, +) { + private val koboApiClient = + RestClient.builder() + .baseUrl("https://storeapi.kobo.com") + .requestFactory( + ReactorNettyClientRequestFactory().apply { + setConnectTimeout(1.minutes.toJavaDuration()) + setReadTimeout(1.minutes.toJavaDuration()) + setExchangeTimeout(1.minutes.toJavaDuration()) + }, + ) + .build() + + private val pathRegex = """\/kobo\/[-\w]*(.*)""".toRegex() + + private val headersOutInclude = + setOf( + HttpHeaders.AUTHORIZATION, + HttpHeaders.USER_AGENT, + HttpHeaders.ACCEPT, + HttpHeaders.ACCEPT_LANGUAGE, + ) + + private val headersOutExclude = + setOf( + X_KOBO_SYNCTOKEN, + ) + + private fun isKoboHeader(headerName: String) = headerName.startsWith("x-kobo-", true) + + fun isEnabled() = komgaSettingsProvider.koboProxy + + /** + * Proxy the current request to the Kobo store, if enabled. + * If [includeSyncToken] is set, the raw sync token will be extracted from the current request and sent to the store. + * If a X_KOBO_SYNCTOKEN header is present in the response, the original Komga sync token will be updated with the + * raw Kobo sync token returned, and added to the response headers. + */ + fun proxyCurrentRequest( + body: Any? = null, + includeSyncToken: Boolean = false, + ): ResponseEntity { + if (!komgaSettingsProvider.koboProxy) throw IllegalStateException("kobo proxying is disabled") + + val request = getCurrentRequest() + val (path) = pathRegex.find(request.requestURI)?.destructured ?: throw IllegalStateException("Could not get path from current request") + + val syncToken = + if (includeSyncToken) + komgaSyncTokenGenerator.fromRequestHeaders(request) + else + null + + val response = + koboApiClient.method(HttpMethod.valueOf(request.method)) + .uri { uriBuilder -> + uriBuilder.path(path) + .queryParams(LinkedMultiValueMap(request.parameterMap.mapValues { it.value.toList() })) + .build() + .also { logger.debug { "Proxy URL: $it" } } + } + .headers { headersOut -> + request.headerNames.toList() + .filterNot { headersOutExclude.contains(it, true) } + .filter { headersOutInclude.contains(it, true) || isKoboHeader(it) } + .forEach { + headersOut.addAll(it, request.getHeaders(it)?.toList() ?: emptyList()) + } + if (includeSyncToken) { + if (syncToken != null && syncToken.rawKoboSyncToken.isNotBlank()) { + headersOut.add(X_KOBO_SYNCTOKEN, syncToken.rawKoboSyncToken) + } else { + throw IllegalStateException("request must include sync token, but no raw Kobo sync token found") + } + } + logger.debug { "Headers out: $headersOut" } + } + .apply { if (body != null) body(body) } + .retrieve() + .onStatus(HttpStatusCode::isError) { _, response -> + throw ResponseStatusException(response.statusCode, response.statusText) + } + .toEntity() + + logger.debug { "Kobo response: $response" } + + val headersToReturn = + response.headers + .filterKeys { isKoboHeader(it) } + .toMutableMap() + .apply { + if (keys.contains(X_KOBO_SYNCTOKEN, true)) { + val koboSyncToken = this[X_KOBO_SYNCTOKEN]?.firstOrNull() + if (koboSyncToken != null && includeSyncToken && syncToken != null) { + val komgaSyncToken = syncToken.copy(rawKoboSyncToken = koboSyncToken) + this[X_KOBO_SYNCTOKEN] = listOf(komgaSyncTokenGenerator.toBase64(komgaSyncToken)) + } + } + } + + return ResponseEntity( + response.body, + LinkedMultiValueMap(headersToReturn), + response.statusCode, + ) + } + + val imageHostUrl = "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg" + + val nativeKoboResources: JsonNode by lazy { + objectMapper.readTree( + // language=JSON + """ + { + "account_page": "https://www.kobo.com/account/settings", + "account_page_rakuten": "https://my.rakuten.co.jp/", + "add_device": "https://storeapi.kobo.com/v1/user/add-device", + "add_entitlement": "https://storeapi.kobo.com/v1/library/{RevisionIds}", + "affiliaterequest": "https://storeapi.kobo.com/v1/affiliate", + "assets": "https://storeapi.kobo.com/v1/assets", + "audiobook": "https://storeapi.kobo.com/v1/products/audiobooks/{ProductId}", + "audiobook_detail_page": "https://www.kobo.com/{region}/{language}/audiobook/{slug}", + "audiobook_get_credits": "https://www.kobo.com/{region}/{language}/audiobooks/plans", + "audiobook_landing_page": "https://www.kobo.com/{region}/{language}/audiobooks", + "audiobook_preview": "https://storeapi.kobo.com/v1/products/audiobooks/{Id}/preview", + "audiobook_purchase_withcredit": "https://storeapi.kobo.com/v1/store/audiobook/{Id}", + "audiobook_subscription_management": "https://www.kobo.com/{region}/{language}/account/subscriptions", + "audiobook_subscription_orange_deal_inclusion_url": "https://authorize.kobo.com/inclusion", + "audiobook_subscription_purchase": "https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280", + "audiobook_subscription_tiers": "https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280", + "authorproduct_recommendations": "https://storeapi.kobo.com/v1/products/books/authors/recommendations", + "autocomplete": "https://storeapi.kobo.com/v1/products/autocomplete", + "blackstone_header": { + "key": "x-amz-request-payer", + "value": "requester" + }, + "book": "https://storeapi.kobo.com/v1/products/books/{ProductId}", + "book_detail_page": "https://www.kobo.com/{region}/{language}/ebook/{slug}", + "book_detail_page_rakuten": "http://books.rakuten.co.jp/rk/{crossrevisionid}", + "book_landing_page": "https://www.kobo.com/ebooks", + "book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions", + "browse_history": "https://storeapi.kobo.com/v1/user/browsehistory", + "categories": "https://storeapi.kobo.com/v1/categories", + "categories_page": "https://www.kobo.com/ebooks/categories", + "category": "https://storeapi.kobo.com/v1/categories/{CategoryId}", + "category_featured_lists": "https://storeapi.kobo.com/v1/categories/{CategoryId}/featured", + "category_products": "https://storeapi.kobo.com/v1/categories/{CategoryId}/products", + "checkout_borrowed_book": "https://storeapi.kobo.com/v1/library/borrow", + "client_authd_referral": "https://authorize.kobo.com/api/AuthenticatedReferral/client/v1/getLink", + "configuration_data": "https://storeapi.kobo.com/v1/configuration", + "content_access_book": "https://storeapi.kobo.com/v1/products/books/{ProductId}/access", + "customer_care_live_chat": "https://v2.zopim.com/widget/livechat.html?key=Y6gwUmnu4OATxN3Tli4Av9bYN319BTdO", + "daily_deal": "https://storeapi.kobo.com/v1/products/dailydeal", + "deals": "https://storeapi.kobo.com/v1/deals", + "delete_entitlement": "https://storeapi.kobo.com/v1/library/{Ids}", + "delete_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}", + "delete_tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/items/delete", + "device_auth": "https://storeapi.kobo.com/v1/auth/device", + "device_refresh": "https://storeapi.kobo.com/v1/auth/refresh", + "dictionary_host": "https://ereaderfiles.kobo.com", + "discovery_host": "https://discovery.kobobooks.com", + "dropbox_link_account_poll": "https://authorize.kobo.com/{region}/{language}/LinkDropbox", + "dropbox_link_account_start": "https://authorize.kobo.com/LinkDropbox/start", + "eula_page": "https://www.kobo.com/termsofuse?style=onestore", + "exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange", + "external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}", + "facebook_sso_page": "https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://kobo.com/", + "featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}", + "featured_lists": "https://storeapi.kobo.com/v1/products/featured", + "free_books_page": { + "EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks", + "FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits", + "IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti", + "NL": "https://www.kobo.com/{region}/{language}/List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg", + "PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis" + }, + "fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback", + "funnel_metrics": "https://storeapi.kobo.com/v1/funnelmetrics", + "get_download_keys": "https://storeapi.kobo.com/v1/library/downloadkeys", + "get_download_link": "https://storeapi.kobo.com/v1/library/downloadlink", + "get_tests_request": "https://storeapi.kobo.com/v1/analytics/gettests", + "giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader", + "giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem", + "googledrive_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkcloudstorage/provider/google_drive", + "gpb_flow_enabled": "False", + "help_page": "http://www.kobo.com/help", + "image_host": "//cdn.kobo.com/book-images/", + "image_url_quality_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg", + "image_url_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg", + "kobo_audiobooks_credit_redemption": "True", + "kobo_audiobooks_enabled": "True", + "kobo_audiobooks_orange_deal_enabled": "True", + "kobo_audiobooks_subscriptions_enabled": "True", + "kobo_display_price": "True", + "kobo_dropbox_link_account_enabled": "True", + "kobo_google_tax": "False", + "kobo_googledrive_link_account_enabled": "True", + "kobo_nativeborrow_enabled": "False", + "kobo_onedrive_link_account_enabled": "False", + "kobo_onestorelibrary_enabled": "False", + "kobo_privacyCentre_url": "https://www.kobo.com/privacy", + "kobo_redeem_enabled": "True", + "kobo_shelfie_enabled": "False", + "kobo_subscriptions_enabled": "True", + "kobo_superpoints_enabled": "False", + "kobo_wishlist_enabled": "True", + "library_book": "https://storeapi.kobo.com/v1/user/library/books/{LibraryItemId}", + "library_items": "https://storeapi.kobo.com/v1/user/library", + "library_metadata": "https://storeapi.kobo.com/v1/library/{Ids}/metadata", + "library_prices": "https://storeapi.kobo.com/v1/user/library/previews/prices", + "library_search": "https://storeapi.kobo.com/v1/library/search", + "library_sync": "https://storeapi.kobo.com/v1/library/sync", + "love_dashboard_page": "https://www.kobo.com/{region}/{language}/kobosuperpoints", + "love_points_redemption_page": "https://www.kobo.com/{region}/{language}/KoboSuperPointsRedemption?productId={ProductId}", + "magazine_landing_page": "https://www.kobo.com/emagazines", + "more_sign_in_options": "https://authorize.kobo.com/signin?returnUrl=http://kobo.com/#allProviders", + "notebooks": "https://storeapi.kobo.com/api/internal/notebooks", + "notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration", + "oauth_host": "https://oauth.kobo.com", + "password_retrieval_page": "https://www.kobo.com/passwordretrieval.html", + "personalizedrecommendations": "https://storeapi.kobo.com/v2/users/personalizedrecommendations", + "pocket_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkpocket", + "post_analytics_event": "https://storeapi.kobo.com/v1/analytics/event", + "ppx_purchasing_url": "https://purchasing.kobo.com", + "privacy_page": "https://www.kobo.com/privacypolicy?style=onestore", + "product_nextread": "https://storeapi.kobo.com/v1/products/{ProductIds}/nextread", + "product_prices": "https://storeapi.kobo.com/v1/products/{ProductIds}/prices", + "product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations", + "product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews", + "products": "https://storeapi.kobo.com/v1/products", + "productsv2": "https://storeapi.kobo.com/v2/products", + "provider_external_sign_in_page": "https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://kobo.com/", + "purchase_buy": "https://www.kobo.com/checkoutoption/", + "purchase_buy_templated": "https://www.kobo.com/{region}/{language}/checkoutoption/{ProductId}", + "quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout", + "quickbuy_create": "https://storeapi.kobo.com/v1/store/quickbuy/purchase", + "rakuten_token_exchange": "https://storeapi.kobo.com/v1/auth/rakuten_token_exchange", + "rating": "https://storeapi.kobo.com/v1/products/{ProductId}/rating/{Rating}", + "reading_services_host": "https://readingservices.kobo.com", + "reading_state": "https://storeapi.kobo.com/v1/library/{Ids}/state", + "redeem_interstitial_page": "https://www.kobo.com", + "registration_page": "https://authorize.kobo.com/signup?returnUrl=http://kobo.com/", + "related_items": "https://storeapi.kobo.com/v1/products/{Id}/related", + "remaining_book_series": "https://storeapi.kobo.com/v1/products/books/series/{SeriesId}", + "rename_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}", + "review": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}", + "review_sentiment": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}/sentiment/{Sentiment}", + "shelfie_recommendations": "https://storeapi.kobo.com/v1/user/recommendations/shelfie", + "sign_in_page": "https://auth.kobobooks.com/ActivateOnWeb", + "social_authorization_host": "https://social.kobobooks.com:8443", + "social_host": "https://social.kobobooks.com", + "store_home": "www.kobo.com/{region}/{language}", + "store_host": "www.kobo.com", + "store_newreleases": "https://www.kobo.com/{region}/{language}/List/new-releases/961XUjtsU0qxkFItWOutGA", + "store_search": "https://www.kobo.com/{region}/{language}/Search?Query={query}", + "store_top50": "https://www.kobo.com/{region}/{language}/ebooks/Top", + "subs_landing_page": "https://www.kobo.com/{region}/{language}/plus", + "subs_management_page": "https://www.kobo.com/{region}/{language}/account/subscriptions", + "subs_purchase_buy_templated": "https://www.kobo.com/{region}/{language}/Checkoutoption/{ProductId}/{TierId}", + "subscription_publisher_price_page": "https://www.kobo.com/{region}/{language}/subscriptionpublisherprice", + "tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/Items", + "tags": "https://storeapi.kobo.com/v1/library/tags", + "taste_profile": "https://storeapi.kobo.com/v1/products/tasteprofile", + "terms_of_sale_page": "https://authorize.kobo.com/{region}/{language}/terms/termsofsale", + "update_accessibility_to_preview": "https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview", + "use_one_store": "True", + "user_loyalty_benefits": "https://storeapi.kobo.com/v1/user/loyalty/benefits", + "user_platform": "https://storeapi.kobo.com/v1/user/platform", + "user_profile": "https://storeapi.kobo.com/v1/user/profile", + "user_ratings": "https://storeapi.kobo.com/v1/user/ratings", + "user_recommendations": "https://storeapi.kobo.com/v1/user/recommendations", + "user_reviews": "https://storeapi.kobo.com/v1/user/reviews", + "user_wishlist": "https://storeapi.kobo.com/v1/user/wishlist", + "userguide_host": "https://ereaderfiles.kobo.com", + "wishlist_page": "https://www.kobo.com/{region}/{language}/account/wishlist" + } + """.trimIndent(), + ) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGenerator.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGenerator.kt new file mode 100644 index 000000000..248c1e514 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGenerator.kt @@ -0,0 +1,66 @@ +package org.gotson.komga.infrastructure.kobo + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.servlet.http.HttpServletRequest +import org.gotson.komga.domain.model.KomgaSyncToken +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNCTOKEN +import org.springframework.stereotype.Component +import java.util.Base64 + +private val logger = KotlinLogging.logger {} + +private const val KOMGA_TOKEN_PREFIX = "KOMGA." + +@Component +class KomgaSyncTokenGenerator( + private val objectMapper: ObjectMapper, +) { + private val base64Encoder by lazy { Base64.getEncoder().withoutPadding() } + private val base64Decoder by lazy { Base64.getDecoder() } + + /** + * Convert any SyncToken to a [KomgaSyncToken]. + * The input SyncToken type depends on the String format: + * - the official Kobo store token is of the form `base64.base64` + * - the Calibre Web token is a single base64 string + * - the Komga token is a base64 string prefixed by `KOMGA.` + */ + fun fromBase64(base64Token: String): KomgaSyncToken { + try { + // check for a Komga token + if (base64Token.startsWith(KOMGA_TOKEN_PREFIX)) { + return objectMapper.readValue(base64Decoder.decode(base64Token.removePrefix(KOMGA_TOKEN_PREFIX))) + } + + // check for a Calibre Web token + if (!base64Token.contains('.')) { + try { + val json = objectMapper.readTree(base64Decoder.decode(base64Token)) + val koboToken = json.get("data").get("raw_kobo_store_token").asText() + return KomgaSyncToken(rawKoboSyncToken = koboToken) + } catch (e: Exception) { + logger.warn { "Failed to parse potential CalibreWeb token" } + } + } + + // check for a Kobo store token + if (base64Token.contains('.')) { + return KomgaSyncToken(rawKoboSyncToken = base64Token) + } + } catch (_: Exception) { + } + + // in last resort return a default token + return KomgaSyncToken() + } + + fun toBase64(token: KomgaSyncToken): String = + KOMGA_TOKEN_PREFIX + base64Encoder.encodeToString(objectMapper.writeValueAsString(token).toByteArray()) + + fun fromRequestHeaders(request: HttpServletRequest): KomgaSyncToken? { + val syncTokenB64 = request.getHeader(X_KOBO_SYNCTOKEN) + return if (syncTokenB64 != null) fromBase64(syncTokenB64) else null + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/EtagFilterConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/EtagFilterConfiguration.kt index 02e7c2581..1eea7e4bd 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/EtagFilterConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/EtagFilterConfiguration.kt @@ -16,6 +16,7 @@ class EtagFilterConfiguration { PathPatternParser.defaultInstance.parse("/opds/v1.2/books/*/file/**"), PathPatternParser.defaultInstance.parse("/api/v1/readlists/*/file/**"), PathPatternParser.defaultInstance.parse("/api/v1/series/*/file/**"), + PathPatternParser.defaultInstance.parse("/kobo/*/v1/books/*/file/**"), ) @Bean @@ -31,6 +32,7 @@ class EtagFilterConfiguration { it.addUrlPatterns( "/api/*", "/opds/*", + "/kobo/*", ) it.setName("etagFilter") } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/RequestLoggingFilterConfig.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/RequestLoggingFilterConfig.kt new file mode 100644 index 000000000..de8149b8e --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/RequestLoggingFilterConfig.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.infrastructure.web + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.filter.CommonsRequestLoggingFilter + +@Configuration +class RequestLoggingFilterConfig { + @ConditionalOnProperty(value = ["logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter"], havingValue = "debug") + @Bean + fun logFilter() = + CommonsRequestLoggingFilter().apply { + setIncludeQueryString(true) + setIncludePayload(true) + setMaxPayloadLength(10000) + setIncludeHeaders(true) + setAfterMessagePrefix("REQUEST DATA: ") + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt index 2276a20dc..965a399b4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt @@ -1,8 +1,11 @@ package org.gotson.komga.infrastructure.web +import jakarta.servlet.http.HttpServletRequest import org.springframework.http.CacheControl import org.springframework.http.MediaType import org.springframework.http.ResponseEntity +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes import java.net.URL import java.nio.file.Paths import java.util.concurrent.TimeUnit @@ -34,3 +37,5 @@ fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType { } return MediaType.APPLICATION_OCTET_STREAM } + +fun getCurrentRequest(): HttpServletRequest = (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes?)?.request ?: throw IllegalStateException("Could not get current request") diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt index b6bfb59e5..116b8a801 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/CommonBookController.kt @@ -321,6 +321,11 @@ class CommonBookController( fun getBookFile( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, + ): ResponseEntity = getBookFileInternal(principal, bookId) + + fun getBookFileInternal( + principal: KomgaPrincipal, + bookId: String, ): ResponseEntity = bookRepository.findByIdOrNull(bookId)?.let { book -> contentRestrictionChecker.checkContentRestriction(principal.user, book) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/KoboController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/KoboController.kt new file mode 100644 index 000000000..33aac8b5c --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/KoboController.kt @@ -0,0 +1,599 @@ +package org.gotson.komga.interfaces.api.kobo + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.treeToValue +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.lang3.RandomStringUtils +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.KomgaSyncToken +import org.gotson.komga.domain.model.R2Device +import org.gotson.komga.domain.model.R2Locator +import org.gotson.komga.domain.model.R2Progression +import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD +import org.gotson.komga.domain.model.SyncPoint +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.persistence.SyncPointRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.domain.service.SyncPointLifecycle +import org.gotson.komga.infrastructure.configuration.KomgaProperties +import org.gotson.komga.infrastructure.image.ImageConverter +import org.gotson.komga.infrastructure.image.ImageType +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_DEVICEID +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNC +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_SYNCTOKEN +import org.gotson.komga.infrastructure.kobo.KoboHeaders.X_KOBO_USERKEY +import org.gotson.komga.infrastructure.kobo.KoboProxy +import org.gotson.komga.infrastructure.kobo.KomgaSyncTokenGenerator +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.infrastructure.web.getCurrentRequest +import org.gotson.komga.interfaces.api.CommonBookController +import org.gotson.komga.interfaces.api.kobo.dto.AuthDto +import org.gotson.komga.interfaces.api.kobo.dto.BookEntitlementContainerDto +import org.gotson.komga.interfaces.api.kobo.dto.BookmarkDto +import org.gotson.komga.interfaces.api.kobo.dto.ChangedEntitlementDto +import org.gotson.komga.interfaces.api.kobo.dto.ChangedReadingStateDto +import org.gotson.komga.interfaces.api.kobo.dto.KoboBookMetadataDto +import org.gotson.komga.interfaces.api.kobo.dto.NewEntitlementDto +import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateDto +import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateStateUpdateDto +import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateUpdateResultDto +import org.gotson.komga.interfaces.api.kobo.dto.RequestResultDto +import org.gotson.komga.interfaces.api.kobo.dto.ResourcesDto +import org.gotson.komga.interfaces.api.kobo.dto.ResultDto +import org.gotson.komga.interfaces.api.kobo.dto.StatisticsDto +import org.gotson.komga.interfaces.api.kobo.dto.StatusDto +import org.gotson.komga.interfaces.api.kobo.dto.StatusInfoDto +import org.gotson.komga.interfaces.api.kobo.dto.SyncResultDto +import org.gotson.komga.interfaces.api.kobo.dto.TestsDto +import org.gotson.komga.interfaces.api.kobo.dto.WrappedReadingStateDto +import org.gotson.komga.interfaces.api.kobo.dto.toBookEntitlementDto +import org.gotson.komga.interfaces.api.kobo.dto.toDto +import org.gotson.komga.interfaces.api.kobo.persistence.KoboDtoRepository +import org.gotson.komga.language.toUTCZoned +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import org.springframework.web.servlet.support.ServletUriComponentsBuilder +import org.springframework.web.util.UriBuilder +import org.springframework.web.util.UriComponentsBuilder +import java.time.ZonedDateTime +import java.util.UUID + +private val logger = KotlinLogging.logger {} + +/** + * The following documentation is coming from the awesome work from [Calibre-web](https://github.com/gotson/calibre-web/blob/14b578dd3a15bd371102d5b9828da830e59b4557/cps/kobo_auth.py). + * + * **Log-in** + * + * When first booting a Kobo device the user must sign into a Kobo (or affiliate) account. + * Upon successful sign-in, the user is redirected to + * https://auth.kobobooks.com/CrossDomainSignIn?id= + * which serves the following response: + * + * ```html + * + * ``` + * + * And triggers the insertion of a userKey into the device's User table. + * + * Together, the device's DeviceId and UserKey act as an *irrevocable* authentication + * token to most (if not all) Kobo APIs. In fact, in most cases only the UserKey is + * required to authorize the API call. + * + * Changing Kobo password *does not* invalidate user keys! This is apparently a known + * issue for a few years now https://www.mobileread.com/forums/showpost.php?p=3476851&postcount=13 + * (although this poster hypothesised that Kobo could blacklist a DeviceId, many endpoints + * will still grant access given the userkey.) + * + * **Official Kobo Store Api authorization** + * + * * For most of the endpoints we care about (sync, metadata, tags, etc), the userKey is + * passed in the x-kobo-userkey header, and is sufficient to authorize the API call. + * * Some endpoints (e.g: AnnotationService) instead make use of Bearer tokens pass through + * an authorization header. To get a BearerToken, the device makes a POST request to the + * /v1/auth/device endpoint with the secret UserKey and the device's DeviceId. + * * The book download endpoint passes an auth token as a URL param instead of a header. + * + * **Komga implementation** + * + * To authenticate the user, an API key is added to the Komga URL when setting up the api_store + * setting on the device. + * Thus, every request from the device to the api_store will hit Komga with the + * API key in the url (e.g: https://mylibrary.com/kobo//v1/library/sync). + * + * In addition, once authenticated a session cookie is set on response, which will + * be sent back for the duration of the session to authorize subsequent API calls + * and avoid having to lookup the API key in database. + */ +@RestController +@RequestMapping(value = ["/kobo/{authToken}/"], produces = ["application/json; charset=utf-8"]) +class KoboController( + private val koboProxy: KoboProxy, + private val syncPointLifecycle: SyncPointLifecycle, + private val syncPointRepository: SyncPointRepository, + private val komgaSyncTokenGenerator: KomgaSyncTokenGenerator, + private val komgaProperties: KomgaProperties, + private val koboDtoRepository: KoboDtoRepository, + private val mapper: ObjectMapper, + private val commonBookController: CommonBookController, + private val bookLifecycle: BookLifecycle, + private val bookRepository: BookRepository, + private val readProgressRepository: ReadProgressRepository, + private val imageConverter: ImageConverter, +) { + @GetMapping("ping") + fun ping() = "pong" + + @GetMapping("v1/initialization") + fun initialization( + @PathVariable authToken: String, + ): ResponseEntity { + val resources = + try { + koboProxy.proxyCurrentRequest().body?.get("Resources") + } catch (e: Exception) { + logger.warn { "Failed to get response from Kobo /v1/initialization, fallback to noproxy" } + null + } ?: koboProxy.nativeKoboResources + + with(resources as ObjectNode) { + put("image_host", ServletUriComponentsBuilder.fromCurrentContextPath().toUriString()) + put("image_url_template", ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("kobo", authToken, "v1", "books", "{ImageId}", "thumbnail", "{Width}", "{Height}", "false", "image.jpg").build().toUriString()) + put("image_url_quality_template", ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("kobo", authToken, "v1", "books", "{ImageId}", "thumbnail", "{Width}", "{Height}", "{Quality}", "{IsGreyscale}", "image.jpg").build().toUriString()) + } + + return ResponseEntity.ok() + .header("x-kobo-apitoken", "e30=") + .body(ResourcesDto(resources)) + } + + /** + * @return an [AuthDto] + */ + @PostMapping("v1/auth/device") + fun authDevice( + @RequestBody body: JsonNode, + ): Any { + try { + return koboProxy.proxyCurrentRequest(body) + } catch (e: Exception) { + logger.warn { "Failed to get response from Kobo /v1/auth/device, fallback to noproxy" } + } + + /** + * Komga does not use the /v1/auth/device API call for authentication/authorization. + * Return dummy data to keep the device happy. + */ + return AuthDto( + accessToken = RandomStringUtils.randomAlphanumeric(24), + refreshToken = RandomStringUtils.randomAlphanumeric(24), + trackingId = UUID.randomUUID().toString(), + userKey = body.get("UserKey")?.asText() ?: "", + ) + } + + // @RequestMapping(value = ["/v1/analytics/gettests"], method = [RequestMethod.GET, RequestMethod.POST]) + fun analyticsGetTests( + @RequestHeader(name = X_KOBO_USERKEY, required = false) userKey: String?, + ) = TestsDto( + result = "Success", + testKey = userKey ?: "", + ) + + /** + * @return an array of [SyncResultDto] + */ + @GetMapping("v1/library/sync") + fun syncLibrary( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable authToken: String, + ): ResponseEntity> { + val syncTokenReceived = komgaSyncTokenGenerator.fromRequestHeaders(getCurrentRequest()) ?: KomgaSyncToken() + + // find the ongoing sync point, else create one + val toSyncPoint = + getSyncPointVerified(syncTokenReceived.ongoingSyncPointId, principal.user.id) + ?: syncPointLifecycle.createSyncPoint(principal.user, principal.apiKey?.id, null) // for now we sync all libraries + + // find the last successful sync, if any + val fromSyncPoint = getSyncPointVerified(syncTokenReceived.lastSuccessfulSyncPointId, principal.user.id) + + logger.debug { "Library sync from SyncPoint $fromSyncPoint, to SyncPoint: $toSyncPoint" } + + var shouldContinueSync: Boolean + val syncResultKomga: Collection = + if (fromSyncPoint != null) { + // find books added/changed/removed and map to DTO + var maxRemainingCount = komgaProperties.kobo.syncItemLimit + + val booksAdded = + syncPointLifecycle.takeBooksAdded(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = it.hasNext() + } + + val booksChanged = + if (booksAdded.isLast && maxRemainingCount > 0) + syncPointLifecycle.takeBooksChanged(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = shouldContinueSync || it.hasNext() + } + else + Page.empty() + + val booksRemoved = + if (booksChanged.isLast && maxRemainingCount > 0) + syncPointLifecycle.takeBooksRemoved(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = shouldContinueSync || it.hasNext() + } + else + Page.empty() + + val changedReadingState = + if (booksRemoved.isLast && maxRemainingCount > 0) + syncPointLifecycle.takeBooksReadProgressChanged(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also { + maxRemainingCount -= it.numberOfElements + shouldContinueSync = shouldContinueSync || it.hasNext() + } + else + Page.empty() + + logger.debug { "Library sync: ${booksAdded.numberOfElements} books added, ${booksChanged.numberOfElements} books changed, ${booksRemoved.numberOfElements} books removed, ${changedReadingState.numberOfElements} books with changed reading state" } + + val metadata = koboDtoRepository.findBookMetadataByIds((booksAdded.content + booksChanged.content).map { it.bookId }, getDownloadUrlBuilder(authToken)).associateBy { it.entitlementId } + val readProgress = readProgressRepository.findAllByBookIdsAndUserId((booksAdded.content + booksChanged.content + changedReadingState.content).map { it.bookId }, principal.user.id).associateBy { it.bookId } + + buildList { + addAll( + booksAdded.content.map { + NewEntitlementDto( + BookEntitlementContainerDto( + bookEntitlement = it.toBookEntitlementDto(false), + bookMetadata = metadata[it.bookId]!!, + readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate), + ), + ) + }, + ) + addAll( + booksChanged.content.map { + ChangedEntitlementDto( + BookEntitlementContainerDto( + bookEntitlement = it.toBookEntitlementDto(false), + bookMetadata = metadata[it.bookId]!!, + readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate), + ), + ) + }, + ) + addAll( + booksRemoved.content.map { + ChangedEntitlementDto( + BookEntitlementContainerDto( + bookEntitlement = it.toBookEntitlementDto(true), + bookMetadata = getMetadataForRemovedBook(it.bookId), + ), + ) + }, + ) + addAll( + // changed books are also passed as changed reading state because Kobo does not process ChangedEntitlement even if it contains a ReadingState + (booksChanged.content + changedReadingState.content).mapNotNull { book -> + readProgress[book.bookId]?.let { it -> + ChangedReadingStateDto( + WrappedReadingStateDto( + it.toDto(), + ), + ) + } + }, + ) + } + } else { + // no starting point, sync everything + val books = syncPointLifecycle.takeBooks(toSyncPoint.id, Pageable.ofSize(komgaProperties.kobo.syncItemLimit)) + shouldContinueSync = books.hasNext() + + logger.debug { "Library sync: ${books.numberOfElements} books" } + + val metadata = koboDtoRepository.findBookMetadataByIds(books.content.map { it.bookId }, getDownloadUrlBuilder(authToken)).associateBy { it.entitlementId } + val readProgress = readProgressRepository.findAllByBookIdsAndUserId(books.content.map { it.bookId }, principal.user.id).associateBy { it.bookId } + + books.content.map { + NewEntitlementDto( + BookEntitlementContainerDto( + bookEntitlement = it.toBookEntitlementDto(false), + bookMetadata = metadata[it.bookId]!!, + readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate), + ), + ) + } + } + + // merge Kobo store sync response, we only trigger this once all Komga updates have been processed (shouldContinueSync == false) + val (syncResultMerged, syncTokenMerged, shouldContinueSyncMerged) = + if (!shouldContinueSync && koboProxy.isEnabled()) { + try { + val koboStoreResponse = koboProxy.proxyCurrentRequest(includeSyncToken = true) + val syncResultsKobo = koboStoreResponse.body?.let { mapper.treeToValue>(it) } ?: emptyList() + val syncTokenKobo = koboStoreResponse.headers[X_KOBO_SYNCTOKEN]?.firstOrNull()?.let { komgaSyncTokenGenerator.fromBase64(it) } + val shouldContinueSyncKobo = koboStoreResponse.headers[X_KOBO_SYNC]?.firstOrNull()?.lowercase() == "continue" + + Triple(syncResultKomga + syncResultsKobo, syncTokenKobo ?: syncTokenReceived, shouldContinueSyncKobo) + } catch (e: Exception) { + logger.error(e) { "Kobo sync endpoint failure" } + Triple(syncResultKomga, syncTokenReceived, false) + } + } else { + Triple(syncResultKomga, syncTokenReceived, shouldContinueSync) + } + + // update synctoken to send back to Kobo device + val syncTokenUpdated = + if (shouldContinueSyncMerged) { + syncTokenMerged.copy(ongoingSyncPointId = toSyncPoint.id) + } else { + // cleanup old syncpoint if it exists + fromSyncPoint?.let { syncPointRepository.deleteOne(it.id) } + + syncTokenMerged.copy(ongoingSyncPointId = null, lastSuccessfulSyncPointId = toSyncPoint.id) + } + + return ResponseEntity + .ok() + .headers { + if (shouldContinueSyncMerged) it.set(X_KOBO_SYNC, "continue") + it.set(X_KOBO_SYNCTOKEN, komgaSyncTokenGenerator.toBase64(syncTokenUpdated)) + } + .body(syncResultMerged) + } + + /** + * @return an array of [KoboBookMetadataDto] + */ + @GetMapping("/v1/library/{bookId}/metadata") + fun getBookMetadata( + @PathVariable authToken: String, + @PathVariable bookId: String, + ): ResponseEntity<*> = + if (!bookRepository.existsById(bookId) && koboProxy.isEnabled()) + koboProxy.proxyCurrentRequest() + else + ResponseEntity.ok(koboDtoRepository.findBookMetadataByIds(listOf(bookId), getDownloadUrlBuilder(authToken))) + + /** + * @return an array of [ReadingStateDto] + */ + @GetMapping("/v1/library/{bookId}/state") + fun getState( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ResponseEntity<*> { + val book = + bookRepository.findByIdOrNull(bookId) + ?: if (koboProxy.isEnabled()) + return koboProxy.proxyCurrentRequest() + else + throw ResponseStatusException(HttpStatus.NOT_FOUND) + + val response = readProgressRepository.findByBookIdAndUserIdOrNull(bookId, principal.user.id)?.toDto() ?: getEmptyReadProgressForBook(book) + return ResponseEntity.ok(listOf(response)) + } + + /** + * @return a [RequestResultDto] + */ + @PutMapping("/v1/library/{bookId}/state") + fun updateState( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + @RequestBody body: ReadingStateStateUpdateDto, + @RequestHeader(name = X_KOBO_DEVICEID, required = false) koboDeviceId: String = "unknown", + ): ResponseEntity<*> { + val book = + bookRepository.findByIdOrNull(bookId) + ?: if (koboProxy.isEnabled()) + return koboProxy.proxyCurrentRequest(body) + else + throw ResponseStatusException(HttpStatus.NOT_FOUND) + + val koboUpdate = body.readingStates.firstOrNull() ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST) + if (koboUpdate.currentBookmark.location == null || koboUpdate.currentBookmark.contentSourceProgressPercent == null) throw ResponseStatusException(HttpStatus.BAD_REQUEST) + + // convert the Kobo update request to an R2Progression + val r2Progression = + R2Progression( + modified = koboUpdate.lastModified, + device = + R2Device( + id = principal.apiKey?.id ?: "unknown", + name = principal.apiKey?.comment ?: "unknown", + ), + locator = + R2Locator( + href = koboUpdate.currentBookmark.location.source, + // assume default, will be overwritten by the correct type when saved + type = "application/xhtml+xml", + locations = + R2Locator.Location( + progression = koboUpdate.currentBookmark.contentSourceProgressPercent / 100, + ), + ), + ) + + val response = + try { + bookLifecycle.markProgression(book, principal.user, r2Progression) + + RequestResultDto( + requestResult = ResultDto.SUCCESS, + updateResults = + listOf( + ReadingStateUpdateResultDto( + entitlementId = bookId, + currentBookmarkResult = ResultDto.SUCCESS.wrapped(), + statisticsResult = ResultDto.IGNORED.wrapped(), + statusInfoResult = ResultDto.SUCCESS.wrapped(), + ), + ), + ) + } catch (e: Exception) { + logger.error(e) { "Could not update progression" } + RequestResultDto( + requestResult = ResultDto.FAILURE, + updateResults = + listOf( + ReadingStateUpdateResultDto( + entitlementId = bookId, + currentBookmarkResult = ResultDto.FAILURE.wrapped(), + statisticsResult = ResultDto.FAILURE.wrapped(), + statusInfoResult = ResultDto.FAILURE.wrapped(), + ), + ), + ) + } + + return ResponseEntity.ok(response) + } + + @GetMapping( + value = ["v1/books/{bookId}/file/epub"], + produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE], + ) + @PreAuthorize("hasRole('$ROLE_FILE_DOWNLOAD')") + fun getBookFile( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + ): ResponseEntity = commonBookController.getBookFileInternal(principal, bookId) + + @GetMapping( + value = [ + "v1/books/{bookId}/thumbnail/{width}/{height}/{isGreyScale}/image.jpg", + "v1/books/{bookId}/thumbnail/{width}/{height}/{quality}/{isGreyScale}/image.jpg", + ], + produces = [MediaType.IMAGE_JPEG_VALUE], + ) + fun getBookCover( + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable bookId: String, + @PathVariable width: String?, + @PathVariable height: String?, + @PathVariable quality: String?, + @PathVariable isGreyScale: String?, + ): ResponseEntity = + if (!bookRepository.existsById(bookId) && koboProxy.isEnabled()) { + ResponseEntity + .status(HttpStatus.TEMPORARY_REDIRECT) + .location(UriComponentsBuilder.fromHttpUrl(koboProxy.imageHostUrl).buildAndExpand(bookId, width, height).toUri()) + .build() + } else { + val poster = bookLifecycle.getThumbnailBytes(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + val posterBytes = + if (poster.mediaType != ImageType.JPEG.mediaType) + imageConverter.convertImage(poster.bytes, ImageType.JPEG.imageIOFormat) + else + poster.bytes + ResponseEntity.ok(posterBytes) + } + + @RequestMapping( + value = ["{*path}"], + method = [RequestMethod.GET, RequestMethod.PUT, RequestMethod.POST, RequestMethod.DELETE, RequestMethod.PATCH], + ) + fun catchAll( + @RequestBody body: Any?, + ): ResponseEntity { + return if (koboProxy.isEnabled()) + koboProxy.proxyCurrentRequest(body) + else + ResponseEntity.ok().body(mapper.createObjectNode()) + } + + private fun getDownloadUrlBuilder(token: String): UriBuilder = + ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("kobo", token, "v1", "books", "{bookId}", "file", "epub") + + /** + * Retrieve a SyncPoint by ID, and verifies it belongs to the same userId + */ + private fun getSyncPointVerified( + syncPointId: String?, + userId: String, + ): SyncPoint? { + if (syncPointId != null) { + val syncPoint = syncPointRepository.findByIdOrNull(syncPointId) + // verify that the SyncPoint is owned by the user + if (syncPoint?.userId == userId) return syncPoint + } + return null + } + + private fun getMetadataForRemovedBook(bookId: String) = + KoboBookMetadataDto( + coverImageId = bookId, + crossRevisionId = bookId, + entitlementId = bookId, + revisionId = bookId, + workId = bookId, + title = bookId, + ) + + private fun getEmptyReadProgressForBook(book: Book): ReadingStateDto { + val createdDateUTC = book.createdDate.toUTCZoned() + return ReadingStateDto( + created = createdDateUTC, + lastModified = createdDateUTC, + priorityTimestamp = createdDateUTC, + entitlementId = book.id, + currentBookmark = BookmarkDto(createdDateUTC), + statistics = StatisticsDto(createdDateUTC), + statusInfo = + StatusInfoDto( + lastModified = createdDateUTC, + status = StatusDto.READY_TO_READ, + timesStartedReading = 0, + ), + ) + } + + private fun getEmptyReadProgressForBook( + bookId: String, + createdDate: ZonedDateTime, + ): ReadingStateDto { + return ReadingStateDto( + created = createdDate, + lastModified = createdDate, + priorityTimestamp = createdDate, + entitlementId = bookId, + currentBookmark = BookmarkDto(createdDate), + statistics = StatisticsDto(createdDate), + statusInfo = + StatusInfoDto( + lastModified = createdDate, + status = StatusDto.READY_TO_READ, + timesStartedReading = 0, + ), + ) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AmountDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AmountDto.kt new file mode 100644 index 000000000..cf1df62d0 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AmountDto.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class AmountDto( + val currencyCode: String? = null, + val totalAmount: Int, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AuthDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AuthDto.kt new file mode 100644 index 000000000..01408cc8b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/AuthDto.kt @@ -0,0 +1,13 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class AuthDto( + val accessToken: String, + val refreshToken: String, + val tokenType: String = "Bearer", + val trackingId: String, + val userKey: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementContainerDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementContainerDto.kt new file mode 100644 index 000000000..739080754 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementContainerDto.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class BookEntitlementContainerDto( + val bookEntitlement: BookEntitlementDto, + val bookMetadata: KoboBookMetadataDto, + val readingState: ReadingStateDto? = null, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementDto.kt new file mode 100644 index 000000000..21dd20129 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookEntitlementDto.kt @@ -0,0 +1,37 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import org.gotson.komga.domain.model.SyncPoint +import java.time.ZoneId +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class BookEntitlementDto( + val accessibility: String = "Full", + val activePeriod: PeriodDto, + val created: ZonedDateTime, + val crossRevisionId: String, + val id: String, + val isHiddenFromArchive: Boolean = false, + val isLocked: Boolean = false, + /** + * True if the book has been deleted or is not available + */ + val isRemoved: Boolean, + val lastModified: ZonedDateTime, + val originCategory: String = "Imported", + val revisionId: String, + val status: String = "Active", +) + +fun SyncPoint.Book.toBookEntitlementDto(isRemoved: Boolean) = + BookEntitlementDto( + activePeriod = ZonedDateTime.now(ZoneId.of("Z")).toPeriodDto(), + created = createdDate, + crossRevisionId = bookId, + revisionId = bookId, + id = bookId, + isRemoved = isRemoved, + lastModified = fileLastModified, + ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookmarkDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookmarkDto.kt new file mode 100644 index 000000000..ffba4abd6 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/BookmarkDto.kt @@ -0,0 +1,23 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class BookmarkDto( + val lastModified: ZonedDateTime, + /** + * Total progression in the book. + * Between 0 and 100. + */ + val progressPercent: Float? = null, + /** + * Progression within the resource. + * Between 0 and 100. + */ + val contentSourceProgressPercent: Float? = null, + val location: LocationDto? = null, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ContributorDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ContributorDto.kt new file mode 100644 index 000000000..0f51c4e7f --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ContributorDto.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ContributorDto( + val name: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/DownloadUrlDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/DownloadUrlDto.kt new file mode 100644 index 000000000..55ef77fa5 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/DownloadUrlDto.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class DownloadUrlDto( + val drmType: String = "None", + val format: FormatDto, + val size: Long, + val platform: String = "Generic", + val url: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/FormatDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/FormatDto.kt new file mode 100644 index 000000000..ace0d3413 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/FormatDto.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +enum class FormatDto { + EPUB3FL, + EPUB, + EPUB3, + KEPUB, +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboBookMetadataDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboBookMetadataDto.kt new file mode 100644 index 000000000..2ef43d0da --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboBookMetadataDto.kt @@ -0,0 +1,43 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +const val DUMMY_ID = "00000000-0000-0000-0000-000000000001" + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class KoboBookMetadataDto( + val categories: List = listOf(DUMMY_ID), + val contributorRoles: List = emptyList(), + val contributors: List = emptyList(), + val coverImageId: String, + val crossRevisionId: String, + val currentDisplayPrice: AmountDto = AmountDto("USD", 0), + val currentLoveDisplayPrice: AmountDto = AmountDto(totalAmount = 0), + val description: String? = null, + val downloadUrls: List = emptyList(), + val entitlementId: String, + val externalIds: List = emptyList(), + val genre: String = DUMMY_ID, + val isEligibleForKoboLove: Boolean = false, + val isInternetArchive: Boolean = false, + val isPreOrder: Boolean = false, + val isSocialEnabled: Boolean = true, + val isbn: String? = null, + /** + * 2-letter code + */ + val language: String = "en", + val phoneticPronunciations: Map = emptyMap(), + val publicationDate: ZonedDateTime? = null, + val publisher: PublisherDto? = null, + val revisionId: String, + val series: KoboSeriesDto? = null, + val slug: String? = null, + val subTitle: String? = null, + val title: String, + val workId: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboSeriesDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboSeriesDto.kt new file mode 100644 index 000000000..7a40344b9 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/KoboSeriesDto.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class KoboSeriesDto( + val id: String, + val name: String, + val number: String, + val numberFloat: Float, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/LocationDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/LocationDto.kt new file mode 100644 index 000000000..0beb12ac6 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/LocationDto.kt @@ -0,0 +1,20 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class LocationDto( + /** + * For type=KoboSpan values are in the form "kobo.x.y" + */ + val value: String? = null, + /** + * Typically "KoboSpan" + */ + val type: String? = null, + /** + * The epub HTML resource + */ + val source: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PeriodDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PeriodDto.kt new file mode 100644 index 000000000..0d546ddeb --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PeriodDto.kt @@ -0,0 +1,12 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class PeriodDto( + val from: ZonedDateTime, +) + +fun ZonedDateTime.toPeriodDto() = PeriodDto(this) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PublisherDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PublisherDto.kt new file mode 100644 index 000000000..dc73239c3 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/PublisherDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class PublisherDto( + val imprint: String = "", + val name: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateDto.kt new file mode 100644 index 000000000..28d5aca51 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateDto.kt @@ -0,0 +1,56 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import org.gotson.komga.domain.model.ReadProgress +import org.gotson.komga.language.toUTCZoned +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ReadingStateDto( + val created: ZonedDateTime? = null, + val currentBookmark: BookmarkDto, + val entitlementId: String, + val lastModified: ZonedDateTime, + /** + * From CW: apparently always equals to lastModified + */ + val priorityTimestamp: ZonedDateTime? = null, + val statistics: StatisticsDto, + val statusInfo: StatusInfoDto, +) + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class WrappedReadingStateDto( + val readingState: ReadingStateDto, +) + +fun ReadProgress.toDto() = + ReadingStateDto( + created = this.createdDate.toUTCZoned(), + lastModified = this.lastModifiedDate.toUTCZoned(), + priorityTimestamp = this.lastModifiedDate.toUTCZoned(), + entitlementId = this.bookId, + currentBookmark = + BookmarkDto( + lastModified = this.lastModifiedDate.toUTCZoned(), + progressPercent = this.locator?.locations?.totalProgression?.times(100), + contentSourceProgressPercent = this.locator?.locations?.progression?.times(100), + location = this.locator?.let { LocationDto(source = it.href) }, + ), + statistics = + StatisticsDto( + lastModified = this.lastModifiedDate.toUTCZoned(), + ), + statusInfo = + StatusInfoDto( + lastModified = this.lastModifiedDate.toUTCZoned(), + status = + when { + this.completed -> StatusDto.FINISHED + !this.completed -> StatusDto.READING + else -> StatusDto.READY_TO_READ + }, + timesStartedReading = 1, + ), + ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateStateUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateStateUpdateDto.kt new file mode 100644 index 000000000..dd9150e78 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateStateUpdateDto.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ReadingStateStateUpdateDto( + val readingStates: Collection = emptyList(), +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateUpdateResultDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateUpdateResultDto.kt new file mode 100644 index 000000000..5c257d7ea --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ReadingStateUpdateResultDto.kt @@ -0,0 +1,25 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class RequestResultDto( + val requestResult: ResultDto, + val updateResults: Collection, +) + +interface UpdateResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ReadingStateUpdateResultDto( + val entitlementId: String, + val currentBookmarkResult: WrappedResultDto, + val statisticsResult: WrappedResultDto, + val statusInfoResult: WrappedResultDto, +) : UpdateResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class WrappedResultDto( + val result: ResultDto, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResourcesDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResourcesDto.kt new file mode 100644 index 000000000..7f716e8e4 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResourcesDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ResourcesDto( + val resources: JsonNode, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResultDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResultDto.kt new file mode 100644 index 000000000..edfc3489c --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/ResultDto.kt @@ -0,0 +1,18 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +enum class ResultDto { + @JsonProperty("Success") + SUCCESS, + + // Not sure what Kobo accepts exactly, so I made up my own + @JsonProperty("Failure") + FAILURE, + + @JsonProperty("Ignored") + IGNORED, + ; + + fun wrapped() = WrappedResultDto(this) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatisticsDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatisticsDto.kt new file mode 100644 index 000000000..b8b972304 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatisticsDto.kt @@ -0,0 +1,14 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class StatisticsDto( + val lastModified: ZonedDateTime, + val remainingTimeMinutes: Int? = null, + val spentReadingMinutes: Int? = null, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusDto.kt new file mode 100644 index 000000000..dabedde8c --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusDto.kt @@ -0,0 +1,17 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +enum class StatusDto { + @JsonProperty("ReadyToRead") + READY_TO_READ, + + @JsonProperty("Finished") + FINISHED, + + @JsonProperty("Reading") + READING, +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusInfoDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusInfoDto.kt new file mode 100644 index 000000000..1a784555a --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/StatusInfoDto.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +@JsonInclude(JsonInclude.Include.NON_NULL) +data class StatusInfoDto( + val lastModified: ZonedDateTime, + val status: StatusDto, + val timesStartedReading: Int? = null, + val lastTimeFinished: ZonedDateTime? = null, + val lastTimeStartedReading: ZonedDateTime? = null, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/SyncResultDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/SyncResultDto.kt new file mode 100644 index 000000000..26ff01313 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/SyncResultDto.kt @@ -0,0 +1,31 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +interface SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class NewEntitlementDto( + val newEntitlement: BookEntitlementContainerDto, +) : SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ChangedEntitlementDto( + val changedEntitlement: BookEntitlementContainerDto, +) : SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class NewTagDto( + val newTag: TagDto, +) : SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ChangedTagDto( + val changedTag: TagDto, +) : SyncResultDto + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class ChangedReadingStateDto( + val changedReadingState: WrappedReadingStateDto, +) : SyncResultDto diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagDto.kt new file mode 100644 index 000000000..c87709159 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagDto.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.ZonedDateTime + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class TagDto( + val id: String, + val created: ZonedDateTime, + val lastModified: ZonedDateTime, + val name: String, + val type: TagTypeDto, + val items: List? = null, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagItemDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagItemDto.kt new file mode 100644 index 000000000..cb1124dae --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagItemDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class TagItemDto( + val revisionId: String, + val type: String = "ProductRevisionTagItem", +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagTypeDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagTypeDto.kt new file mode 100644 index 000000000..65b86ff0b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TagTypeDto.kt @@ -0,0 +1,10 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +enum class TagTypeDto { + SYSTEM_TAG, + USER_TAG, +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TestsDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TestsDto.kt new file mode 100644 index 000000000..56863decc --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/dto/TestsDto.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.interfaces.api.kobo.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class) +data class TestsDto( + val result: String, + val testKey: String, + val tests: Map = emptyMap(), +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/persistence/KoboDtoRepository.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/persistence/KoboDtoRepository.kt new file mode 100644 index 000000000..81743593f --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kobo/persistence/KoboDtoRepository.kt @@ -0,0 +1,11 @@ +package org.gotson.komga.interfaces.api.kobo.persistence + +import org.gotson.komga.interfaces.api.kobo.dto.KoboBookMetadataDto +import org.springframework.web.util.UriBuilder + +interface KoboDtoRepository { + fun findBookMetadataByIds( + bookIds: Collection, + downloadUriBuilder: UriBuilder, + ): Collection +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SettingsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SettingsController.kt index d49422f65..f5a1a08f3 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SettingsController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SettingsController.kt @@ -40,6 +40,7 @@ class SettingsController( komgaSettingsProvider.taskPoolSize, SettingMultiSource(configServerPort, komgaSettingsProvider.serverPort, serverSettings.effectiveServerPort), SettingMultiSource(configServerContextPath, komgaSettingsProvider.serverContextPath, serverSettings.effectiveServletContextPath), + komgaSettingsProvider.koboProxy, ) @PatchMapping @@ -57,5 +58,7 @@ class SettingsController( if (newSettings.isSet("serverPort")) komgaSettingsProvider.serverPort = newSettings.serverPort if (newSettings.isSet("serverContextPath")) komgaSettingsProvider.serverContextPath = newSettings.serverContextPath + + newSettings.koboProxy?.let { komgaSettingsProvider.koboProxy = it } } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SyncPointController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SyncPointController.kt new file mode 100644 index 000000000..7f9c002ae --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SyncPointController.kt @@ -0,0 +1,30 @@ +package org.gotson.komga.interfaces.api.rest + +import org.gotson.komga.domain.persistence.SyncPointRepository +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("api/v1/syncpoints", produces = [MediaType.APPLICATION_JSON_VALUE]) +class SyncPointController( + private val syncPointRepository: SyncPointRepository, +) { + @DeleteMapping("me") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteMySyncPointsByApiKey( + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(required = false, name = "key_id") keyIds: Collection?, + ) { + if (keyIds.isNullOrEmpty()) + syncPointRepository.deleteByUserId(principal.user.id) + else + syncPointRepository.deleteByUserIdAndApiKeyIds(principal.user.id, keyIds) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsDto.kt index 4312b035e..df3f10e5b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsDto.kt @@ -8,6 +8,7 @@ data class SettingsDto( val taskPoolSize: Int, val serverPort: SettingMultiSource, val serverContextPath: SettingMultiSource, + val koboProxy: Boolean, ) data class SettingMultiSource( diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsUpdateDto.kt index bacb4ee4a..dc5f11c73 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsUpdateDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/SettingsUpdateDto.kt @@ -36,4 +36,6 @@ class SettingsUpdateDto { by Delegates.observable(null) { prop, _, _ -> isSet[prop.name] = true } + + var koboProxy: Boolean? = null } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt index 1a670dbed..67c409724 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/UserDto.kt @@ -7,6 +7,7 @@ import org.gotson.komga.domain.model.AllowExclude import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD +import org.gotson.komga.domain.model.ROLE_KOBO_SYNC import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING import org.gotson.komga.infrastructure.security.KomgaPrincipal @@ -54,6 +55,7 @@ data class UserCreationDto( roleAdmin = roles.contains(ROLE_ADMIN), roleFileDownload = roles.contains(ROLE_FILE_DOWNLOAD), rolePageStreaming = roles.contains(ROLE_PAGE_STREAMING), + roleKoboSync = roles.contains(ROLE_KOBO_SYNC), ) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/InitialUserController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/InitialUserController.kt index 476726fc2..7ebc64f21 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/InitialUserController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/scheduler/InitialUserController.kt @@ -39,7 +39,7 @@ class InitialUsersDevConfiguration { @Bean fun initialUsers(): List = listOf( - KomgaUser("admin@example.org", "admin", roleAdmin = true), + KomgaUser("admin@example.org", "admin", roleAdmin = true, roleKoboSync = true), KomgaUser("user@example.org", "user", roleAdmin = false), ) } diff --git a/komga/src/main/kotlin/org/gotson/komga/language/LanguageUtils.kt b/komga/src/main/kotlin/org/gotson/komga/language/LanguageUtils.kt index b520a733a..e19de966a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/language/LanguageUtils.kt +++ b/komga/src/main/kotlin/org/gotson/komga/language/LanguageUtils.kt @@ -56,11 +56,33 @@ fun String.stripAccents(): String = StringUtils.stripAccents(this) fun LocalDate.toDate(): Date = Date.from(this.atStartOfDay(ZoneId.of("Z")).toInstant()) +/** + * Converts a LocalDateTime (current timezone) to a LocalDateTime (UTC) + * Warning: this is not idempotent + */ fun LocalDateTime.toUTC(): LocalDateTime = atZone(ZoneId.systemDefault()).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime() +/** + * Converts a LocalDateTime (current timezone) to a ZonedDateTime + */ +fun LocalDateTime.toUTCZoned(): ZonedDateTime = + atZone(ZoneId.systemDefault()).withZoneSameInstant(ZoneOffset.UTC) + +/** + * Converts a LocalDateTime (UTC) to a ZonedDateTime + */ fun LocalDateTime.toZonedDateTime(): ZonedDateTime = this.atZone(ZoneId.of("Z")).withZoneSameInstant(ZoneId.systemDefault()) +/** + * Converts a LocalDateTime (UTC) to a LocalDateTime (current timezone) + */ fun LocalDateTime.toCurrentTimeZone(): LocalDateTime = this.atZone(ZoneId.of("Z")).withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime() + +fun Iterable.contains( + s: String, + ignoreCase: Boolean = false, +): Boolean = + any { it.equals(s, ignoreCase) } diff --git a/komga/src/main/resources/application-dev.yml b/komga/src/main/resources/application-dev.yml index 6c7430005..6ab95bf76 100644 --- a/komga/src/main/resources/application-dev.yml +++ b/komga/src/main/resources/application-dev.yml @@ -11,12 +11,15 @@ logging: level: org.apache.activemq.audit.message: WARN org.gotson.komga: DEBUG +# org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG +# reactor.netty.http.client: DEBUG # org.jooq: DEBUG # org.jooq.tools.LoggerListener: DEBUG # web: DEBUG # com.zaxxer.hikari: DEBUG # org.springframework.boot.autoconfigure: DEBUG # org.springframework.security.web.FilterChainProxy: DEBUG +# org.springframework.security.web.authentication.rememberme: DEBUG logback: rollingpolicy: max-history: 1 diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/SyncPointLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/SyncPointLifecycleTest.kt new file mode 100644 index 000000000..420777875 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/SyncPointLifecycleTest.kt @@ -0,0 +1,272 @@ +package org.gotson.komga.domain.service + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within +import org.gotson.komga.domain.model.AgeRestriction +import org.gotson.komga.domain.model.AllowExclude +import org.gotson.komga.domain.model.ContentRestrictions +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.BookMetadataRepository +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.persistence.SyncPointRepository +import org.gotson.komga.language.toZonedDateTime +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@SpringBootTest +class SyncPointLifecycleTest( + @Autowired private val syncPointLifecycle: SyncPointLifecycle, + @Autowired private val syncPointRepository: SyncPointRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val bookRepository: BookRepository, + @Autowired private val bookMetadataRepository: BookMetadataRepository, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val mediaRepository: MediaRepository, +) { + @Autowired + private lateinit var bookLifecycle: BookLifecycle + private val library1 = makeLibrary() + private val library2 = makeLibrary() + private val library3 = makeLibrary() + private val user1 = + KomgaUser( + "user1@example.org", + "", + false, + sharedLibrariesIds = setOf(library1.id, library2.id), + restrictions = + ContentRestrictions( + ageRestriction = AgeRestriction(18, AllowExclude.EXCLUDE), + labelsExclude = setOf("exclude"), + ), + ) + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library1) + libraryRepository.insert(library2) + libraryRepository.insert(library3) + userRepository.insert(user1) + } + + @AfterAll + fun teardown() { + libraryRepository.deleteAll() + syncPointRepository.deleteAll() + userRepository.deleteAll() + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Test + fun `given user when creating syncpoint then all in-scope books are included`() { + // given + val bookValid = makeBook("valid", libraryId = library1.id).copy(fileHash = "hash", fileSize = 12, fileLastModified = LocalDateTime.now()) + val bookExcludedByAge = makeBook("age restriction", libraryId = library1.id) + val bookExcludedByLabel = makeBook("label restriction", libraryId = library1.id) + val bookDeleted = makeBook("deleted", libraryId = library1.id).copy(deletedDate = LocalDateTime.now()) + val bookNotReady = makeBook("media not ready", libraryId = library1.id) + val bookNotEpub = makeBook("not epub", libraryId = library1.id) + val bookOtherLibrary = makeBook("lib not in list", libraryId = library2.id) + val bookUnauthorizedLibrary = makeBook("unauthorized lib", libraryId = library3.id) + + makeSeries(name = "series1", libraryId = library1.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(bookValid, bookDeleted, bookNotReady, bookNotEpub)) + } + } + makeSeries(name = "series age restricted", libraryId = library1.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(bookExcludedByAge)) + } + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(ageRating = 20)) } + } + makeSeries(name = "series label restricted", libraryId = library1.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(bookExcludedByLabel)) + } + seriesMetadataRepository.findById(series.id).let { seriesMetadataRepository.update(it.copy(sharingLabels = setOf("exclude"))) } + } + makeSeries(name = "series2", libraryId = library2.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(bookOtherLibrary)) + } + } + makeSeries(name = "series3", libraryId = library3.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(bookUnauthorizedLibrary)) + } + } + + bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } } + mediaRepository.findById(bookNotReady.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.ERROR)) } + mediaRepository.findById(bookNotEpub.id).let { media -> mediaRepository.update(media.copy(mediaType = MediaType.ZIP.type)) } + + // when + val syncPoint = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + val syncPointBooks = syncPointRepository.findBooksById(syncPoint.id, false, Pageable.unpaged()) + + // then + assertThat(syncPoint.userId).isEqualTo(user1.id) + assertThat(syncPointBooks).hasSize(1) + with(syncPointBooks.first()) { + assertThat(this.bookId).isEqualTo(bookValid.id) + assertThat(this.fileHash).isEqualTo(bookValid.fileHash) + assertThat(this.fileSize).isEqualTo(bookValid.fileSize) + assertThat(this.fileLastModified).isCloseTo(bookValid.fileLastModified.toZonedDateTime(), within(1, ChronoUnit.SECONDS)) + } + } + + @Test + fun `given syncpoint when adding new books then syncpoint diff contains new books`() { + // given + val book1 = makeBook("valid", libraryId = library1.id) + + val series = + makeSeries(name = "series1", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(book1)) + } + } + + mediaRepository.findById(book1.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } + + val syncPoint1 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + + // when + val book2 = makeBook("valid", libraryId = library1.id) + val book3 = makeBook("valid", libraryId = library1.id) + seriesLifecycle.addBooks(series, listOf(book2, book3)) + mediaRepository.findById(book2.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } + mediaRepository.findById(book3.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } + + val syncPoint2 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + val booksAdded = syncPointRepository.findBooksAdded(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged()) + val page1 = syncPointLifecycle.takeBooksAdded(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1)) + val page2 = syncPointLifecycle.takeBooksAdded(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1)) + + // then + assertThat(booksAdded).hasSize(2) + assertThat(booksAdded.map { it.bookId }).containsExactlyInAnyOrder(book2.id, book3.id) + assertThat(page1).hasSize(1) + assertThat(page1.map { it.bookId }) + .containsAnyElementsOf(listOf(book2.id, book3.id)) + .doesNotContainAnyElementsOf(page2.map { it.bookId }) + assertThat(page2).hasSize(1) + assertThat(page2.map { it.bookId }) + .containsAnyElementsOf(listOf(book2.id, book3.id)) + .doesNotContainAnyElementsOf(page1.map { it.bookId }) + } + + @Test + fun `given syncpoint when deleting books then syncpoint diff contains removed books`() { + // given + val book1 = makeBook("valid1", libraryId = library1.id) + val book2 = makeBook("valid2", libraryId = library1.id) + val book3 = makeBook("valid3", libraryId = library1.id) + + val series = + makeSeries(name = "series1", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(book1, book2, book3)) + } + } + + bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } } + + val syncPoint1 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + + // when + bookLifecycle.softDeleteMany(listOf(bookRepository.findByIdOrNull(book2.id)!!)) + bookLifecycle.deleteOne(bookRepository.findByIdOrNull(book3.id)!!) + + val syncPoint2 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + val booksRemoved = syncPointRepository.findBooksRemoved(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged()) + val page1 = syncPointLifecycle.takeBooksRemoved(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1)) + val page2 = syncPointLifecycle.takeBooksRemoved(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1)) + + // then + assertThat(booksRemoved).hasSize(2) + assertThat(booksRemoved.map { it.bookId }).containsExactlyInAnyOrder(book2.id, book3.id) + assertThat(page1).hasSize(1) + assertThat(page1.map { it.bookId }) + .containsAnyElementsOf(listOf(book2.id, book3.id)) + .doesNotContainAnyElementsOf(page2.map { it.bookId }) + assertThat(page2).hasSize(1) + assertThat(page2.map { it.bookId }) + .containsAnyElementsOf(listOf(book2.id, book3.id)) + .doesNotContainAnyElementsOf(page1.map { it.bookId }) + } + + @Test + fun `given syncpoint when changing books then syncpoint diff contains changed books`() { + // given + val book1 = makeBook("valid1", libraryId = library1.id) + val book2 = makeBook("valid2", libraryId = library1.id).copy(fileSize = 1, fileHash = "hash1") + val book3 = makeBook("valid3", libraryId = library1.id) + val book4 = makeBook("no hash to hash", libraryId = library1.id) + + val series = + makeSeries(name = "series1", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(book1, book2, book3)) + } + } + + bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } } + + val syncPoint1 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + + // when + bookRepository.findByIdOrNull(book1.id)?.let { bookRepository.update(it.copy(fileLastModified = LocalDateTime.of(2020, 1, 1, 1, 1))) } + bookRepository.findByIdOrNull(book2.id)?.let { bookRepository.update(it.copy(fileHash = "hash2")) } + bookMetadataRepository.findById(book3.id).let { bookMetadataRepository.update(it.copy(title = "changed")) } + bookRepository.findByIdOrNull(book4.id)?.let { bookRepository.update(it.copy(fileHash = "hash")) } // not included in changed books if it had no hash before + + val syncPoint2 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id)) + val booksChanged = syncPointRepository.findBooksChanged(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged()) + val page1 = syncPointLifecycle.takeBooksChanged(syncPoint1.id, syncPoint2.id, PageRequest.ofSize(1)) + val page2 = syncPointLifecycle.takeBooksChanged(syncPoint1.id, syncPoint2.id, PageRequest.ofSize(1)) + val page3 = syncPointLifecycle.takeBooksChanged(syncPoint1.id, syncPoint2.id, PageRequest.ofSize(1)) + + // then + assertThat(booksChanged).hasSize(3) + assertThat(booksChanged.map { it.bookId }).containsExactlyInAnyOrder(book1.id, book2.id, book3.id) + assertThat(page1).hasSize(1) + assertThat(page1.map { it.bookId }) + .containsAnyElementsOf(listOf(book1.id, book2.id, book3.id)) + .doesNotContainAnyElementsOf((page2 + page3).map { it.bookId }) + assertThat(page2).hasSize(1) + assertThat(page2.map { it.bookId }) + .containsAnyElementsOf(listOf(book1.id, book2.id, book3.id)) + .doesNotContainAnyElementsOf((page1 + page3).map { it.bookId }) + assertThat(page3).hasSize(1) + assertThat(page3.map { it.bookId }) + .containsAnyElementsOf(listOf(book1.id, book2.id, book3.id)) + .doesNotContainAnyElementsOf((page1 + page2).map { it.bookId }) + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDaoTest.kt index 85968ffe3..1187f1068 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDaoTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDaoTest.kt @@ -47,6 +47,9 @@ class KomgaUserDaoTest( email = "user@example.org", password = "password", roleAdmin = false, + rolePageStreaming = false, + roleFileDownload = false, + roleKoboSync = false, sharedLibrariesIds = setOf(library.id), sharedAllLibraries = false, ) @@ -61,6 +64,9 @@ class KomgaUserDaoTest( assertThat(email).isEqualTo("user@example.org") assertThat(password).isEqualTo("password") assertThat(roleAdmin).isFalse + assertThat(rolePageStreaming).isFalse + assertThat(roleFileDownload).isFalse + assertThat(roleKoboSync).isFalse assertThat(sharedLibrariesIds).containsExactly(library.id) assertThat(sharedAllLibraries).isFalse assertThat(restrictions.ageRestriction).isNull() @@ -76,6 +82,9 @@ class KomgaUserDaoTest( email = "user@example.org", password = "password", roleAdmin = false, + rolePageStreaming = false, + roleFileDownload = false, + roleKoboSync = false, sharedLibrariesIds = setOf(library.id), sharedAllLibraries = false, restrictions = @@ -101,6 +110,9 @@ class KomgaUserDaoTest( email = "user2@example.org", password = "password2", roleAdmin = true, + rolePageStreaming = true, + roleFileDownload = true, + roleKoboSync = true, sharedLibrariesIds = emptySet(), sharedAllLibraries = true, restrictions = @@ -123,6 +135,9 @@ class KomgaUserDaoTest( assertThat(email).isEqualTo("user2@example.org") assertThat(password).isEqualTo("password2") assertThat(roleAdmin).isTrue + assertThat(rolePageStreaming).isTrue + assertThat(roleFileDownload).isTrue + assertThat(roleKoboSync).isTrue assertThat(sharedLibrariesIds).isEmpty() assertThat(sharedAllLibraries).isTrue assertThat(restrictions.ageRestriction).isNotNull diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGeneratorTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGeneratorTest.kt new file mode 100644 index 000000000..95aac33a0 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/kobo/KomgaSyncTokenGeneratorTest.kt @@ -0,0 +1,80 @@ +package org.gotson.komga.infrastructure.kobo + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.KomgaSyncToken +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.util.Base64 + +@SpringBootTest +class KomgaSyncTokenGeneratorTest( + @Autowired private val tokenGenerator: KomgaSyncTokenGenerator, +) { + private val base64Encoder by lazy { Base64.getEncoder().withoutPadding() } + + fun encodeToBase64(token: String): String = base64Encoder.encodeToString(token.toByteArray()) + + @Test + fun `given Kobo store token when getting token then it contains the kobo store token`() { + // given + val koboToken = "fake.token" + + // when + val komgaToken = tokenGenerator.fromBase64(koboToken) + + // then + assertThat(komgaToken.rawKoboSyncToken).isEqualTo(koboToken) + } + + @Test + fun `given calibre web token when getting token then it contains the kobo store token`() { + // given + val calibreWebToken = + """ + { + "data": { + "archive_last_modified": -62135596800.0, + "books_last_created": -62135596800.0, + "books_last_modified": -62135596800.0, + "raw_kobo_store_token": "fake.token", + "reading_state_last_modified": -62135596800.0, + "tags_last_modified": -62135596800.0 + }, + "version": "1-1-0" + } + """.trimIndent() + + // when + val komgaToken = tokenGenerator.fromBase64(encodeToBase64(calibreWebToken)) + + // then + assertThat(komgaToken.rawKoboSyncToken).isEqualTo("fake.token") + } + + @Test + fun `given Komga token when getting token then it contains the kobo store token`() { + // given + val komgaToken = KomgaSyncToken(rawKoboSyncToken = "fake.token") + + // when + val encodedToken = tokenGenerator.toBase64(komgaToken) + val decodedToken = tokenGenerator.fromBase64(encodedToken) + + // then + assertThat(decodedToken.rawKoboSyncToken).isEqualTo("fake.token") + assertThat(encodedToken).startsWith("KOMGA.") + } + + @Test + fun `given unidentified token when getting token then it is empty`() { + // given + val token = "unrecognized" + + // when + val komgaToken = tokenGenerator.fromBase64(token) + + // then + assertThat(komgaToken.rawKoboSyncToken).isEmpty() + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kobo/KoboControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kobo/KoboControllerTest.kt new file mode 100644 index 000000000..0e2849066 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kobo/KoboControllerTest.kt @@ -0,0 +1,131 @@ +package org.gotson.komga.interfaces.api.kobo + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaType +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.model.makeLibrary +import org.gotson.komga.domain.model.makeSeries +import org.gotson.komga.domain.persistence.KomgaUserRepository +import org.gotson.komga.domain.persistence.LibraryRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.KomgaUserLifecycle +import org.gotson.komga.domain.service.SeriesLifecycle +import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider +import org.gotson.komga.infrastructure.kobo.KoboHeaders +import org.gotson.komga.infrastructure.kobo.KomgaSyncTokenGenerator +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get + +@SpringBootTest(properties = ["komga.kobo.sync-item-limit=1"]) +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class KoboControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val komgaUserLifecycle: KomgaUserLifecycle, + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val komgaSyncTokenGenerator: KomgaSyncTokenGenerator, + @Autowired private val komgaSettingsProvider: KomgaSettingsProvider, +) { + private val library1 = makeLibrary() + private val user1 = + KomgaUser( + "user@example.org", + "", + false, + roleKoboSync = true, + ) + private lateinit var apiKey: String + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library1) + userRepository.insert(user1) + apiKey = komgaUserLifecycle.createApiKey(user1, "test")!!.key + } + + @AfterAll + fun teardown() { + libraryRepository.deleteAll() + komgaUserLifecycle.deleteUser(user1) + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Test + fun `given user when syncing for the first time then books are synced`() { + // given + val book1 = makeBook("valid", libraryId = library1.id) + val book2 = makeBook("valid", libraryId = library1.id) + + val series = + makeSeries(name = "series1", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).let { created -> + seriesLifecycle.addBooks(created, listOf(book1, book2)) + } + } + + mediaRepository.findById(book1.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } + mediaRepository.findById(book2.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } + + // first sync + val mvcResult1 = + mockMvc.get("/kobo/$apiKey/v1/library/sync") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + header { string(KoboHeaders.X_KOBO_SYNC, "continue") } + }.andReturn() + + val syncToken1Base64 = mvcResult1.response.getHeaderValue(KoboHeaders.X_KOBO_SYNCTOKEN) as String + val syncToken1 = komgaSyncTokenGenerator.fromBase64(syncToken1Base64) + assertThat(syncToken1.ongoingSyncPointId).isNotEmpty() + assertThat(syncToken1.lastSuccessfulSyncPointId).isNull() + + // second sync + val mvcResult2 = + mockMvc.get("/kobo/$apiKey/v1/library/sync") { + header(KoboHeaders.X_KOBO_SYNCTOKEN, syncToken1Base64) + } + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + header { doesNotExist(KoboHeaders.X_KOBO_SYNC) } + }.andReturn() + + val syncToken2 = komgaSyncTokenGenerator.fromBase64(mvcResult2.response.getHeaderValue(KoboHeaders.X_KOBO_SYNCTOKEN) as String) + assertThat(syncToken2.ongoingSyncPointId).isNull() + assertThat(syncToken2.lastSuccessfulSyncPointId).isEqualTo(syncToken1.ongoingSyncPointId) + } + + @Test + fun `given kobo proxy is enabled when requesting book cover for non-existent book then redirect response is returned`() { + komgaSettingsProvider.koboProxy = true + + try { + mockMvc.get("/kobo/$apiKey/v1/books/nonexistent/thumbnail/800/800/false/image.jpg") + .andExpect { + status { isTemporaryRedirect() } + header { string(HttpHeaders.LOCATION, "https://cdn.kobo.com/book-images/nonexistent/800/800/false/image.jpg") } + } + } finally { + komgaSettingsProvider.koboProxy = false + } + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt index de1583907..f1c8b9244 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt @@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.ContentRestrictions import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD +import org.gotson.komga.domain.model.ROLE_KOBO_SYNC import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationToken @@ -20,7 +21,7 @@ import org.springframework.security.test.context.support.WithSecurityContextFact @WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory::class, setupBefore = TestExecutionEvent.TEST_EXECUTION) annotation class WithMockCustomUser( val email: String = "user@example.org", - val roles: Array = [ROLE_FILE_DOWNLOAD, ROLE_PAGE_STREAMING], + val roles: Array = [ROLE_FILE_DOWNLOAD, ROLE_PAGE_STREAMING, ROLE_KOBO_SYNC], val sharedAllLibraries: Boolean = true, val sharedLibraries: Array = [], val id: String = "0", @@ -43,6 +44,7 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory