diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 087d3b7756..0565d6c3f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -145,6 +145,10 @@ cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5" # cron-utils cronUtils = "com.cronutils:cron-utils:9.2.1" +# User +bcrypt = "at.favre.lib:bcrypt:0.10.2" +jwt = "com.auth0:java-jwt:4.4.0" + # lint - used for renovate to update ktlint version ktlint = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlint" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index be86deefdc..d0e0ffcdcc 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -94,6 +94,9 @@ dependencies { implementation(libs.cron4j) implementation(libs.cronUtils) + + implementation(libs.bcrypt) + implementation(libs.jwt) } application { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/controller/GlobalMetaController.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/controller/GlobalMetaController.kt index d3c9da408c..92cb839531 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/global/controller/GlobalMetaController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/controller/GlobalMetaController.kt @@ -9,6 +9,9 @@ package suwayomi.tachidesk.global.controller import io.javalin.http.HttpStatus import suwayomi.tachidesk.global.impl.GlobalMeta +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.JavalinSetup.getAttribute +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.formParam import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.withOperation @@ -24,7 +27,8 @@ object GlobalMetaController { } }, behaviorOf = { ctx -> - ctx.json(GlobalMeta.getMetaMap()) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + ctx.json(GlobalMeta.getMetaMap(userId)) ctx.status(200) }, withResults = { @@ -44,7 +48,8 @@ object GlobalMetaController { } }, behaviorOf = { ctx, key, value -> - GlobalMeta.modifyMeta(key, value) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + GlobalMeta.modifyMeta(userId, key, value) ctx.status(200) }, withResults = { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/controller/SettingsController.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/controller/SettingsController.kt index c456f517c0..f419546ac7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/global/controller/SettingsController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/controller/SettingsController.kt @@ -12,7 +12,10 @@ import suwayomi.tachidesk.global.impl.About import suwayomi.tachidesk.global.impl.AboutDataClass import suwayomi.tachidesk.global.impl.AppUpdate import suwayomi.tachidesk.global.impl.UpdateDataClass +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.JavalinSetup.getAttribute +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.withOperation @@ -28,6 +31,7 @@ object SettingsController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.json(About.getAbout()) }, withResults = { @@ -45,6 +49,7 @@ object SettingsController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { AppUpdate.checkUpdate() } .thenApply { ctx.json(it) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/impl/GlobalMeta.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/GlobalMeta.kt index 0feacc200a..c13cfe49fa 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/global/impl/GlobalMeta.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/GlobalMeta.kt @@ -1,5 +1,6 @@ package suwayomi.tachidesk.global.impl +import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction @@ -15,32 +16,35 @@ import suwayomi.tachidesk.global.model.table.GlobalMetaTable object GlobalMeta { fun modifyMeta( + userId: Int, key: String, value: String, ) { transaction { val meta = transaction { - GlobalMetaTable.selectAll().where { GlobalMetaTable.key eq key } + GlobalMetaTable.selectAll().where { GlobalMetaTable.key eq key and (GlobalMetaTable.user eq userId) } }.firstOrNull() if (meta == null) { GlobalMetaTable.insert { it[GlobalMetaTable.key] = key it[GlobalMetaTable.value] = value + it[GlobalMetaTable.user] = userId } } else { - GlobalMetaTable.update({ GlobalMetaTable.key eq key }) { + GlobalMetaTable.update({ GlobalMetaTable.key eq key and (GlobalMetaTable.user eq userId) }) { it[GlobalMetaTable.value] = value } } } } - fun getMetaMap(): Map = + fun getMetaMap(userId: Int): Map = transaction { GlobalMetaTable .selectAll() + .where { GlobalMetaTable.user eq userId } .associate { it[GlobalMetaTable.key] to it[GlobalMetaTable.value] } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/impl/util/Bcrypt.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/util/Bcrypt.kt new file mode 100644 index 0000000000..0d493a77ad --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/util/Bcrypt.kt @@ -0,0 +1,15 @@ +package suwayomi.tachidesk.global.impl.util + +import at.favre.lib.crypto.bcrypt.BCrypt + +object Bcrypt { + private val hasher = BCrypt.with(BCrypt.Version.VERSION_2B) + private val verifyer = BCrypt.verifyer(BCrypt.Version.VERSION_2B) + + fun encryptPassword(password: String): String = hasher.hashToString(12, password.toCharArray()) + + fun verify( + hash: String, + password: String, + ): Boolean = verifyer.verify(password.toCharArray(), hash.toCharArray()).verified +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/impl/util/Jwt.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/util/Jwt.kt new file mode 100644 index 0000000000..bb2d124f08 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/util/Jwt.kt @@ -0,0 +1,135 @@ +package suwayomi.tachidesk.global.impl.util + +import com.auth0.jwt.JWT +import com.auth0.jwt.JWTVerifier +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.exceptions.JWTVerificationException +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.global.model.table.UserPermissionsTable +import suwayomi.tachidesk.global.model.table.UserRolesTable +import suwayomi.tachidesk.server.user.Permissions +import suwayomi.tachidesk.server.user.UserType +import java.security.SecureRandom +import java.time.Instant +import javax.crypto.spec.SecretKeySpec +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +object Jwt { + private const val ALGORITHM = "HmacSHA256" + private val accessTokenExpiry = 1.hours + private val refreshTokenExpiry = 60.days + private const val ISSUER = "tachidesk" + private const val AUDIENCE = "" // todo audience + + @OptIn(ExperimentalEncodingApi::class) + fun generateSecret(): String { + val keyBytes = ByteArray(32) + SecureRandom().nextBytes(keyBytes) + + val secretKey = SecretKeySpec(keyBytes, ALGORITHM) + + return Base64.encode(secretKey.encoded) + } + + private val algorithm: Algorithm = Algorithm.HMAC256(generateSecret()) // todo store secret + private val verifier: JWTVerifier = JWT.require(algorithm).build() + + class JwtTokens( + val accessToken: String, + val refreshToken: String, + ) + + fun generateJwt(userId: Int): JwtTokens { + val accessToken = createAccessToken(userId) + val refreshToken = createRefreshToken(userId) + + return JwtTokens( + accessToken = accessToken, + refreshToken = refreshToken, + ) + } + + fun refreshJwt(refreshToken: String): JwtTokens { + val jwt = verifier.verify(refreshToken) + require(jwt.getClaim("token_type").asString() == "refresh") { + "Cannot use access token to refresh" + } + return generateJwt(jwt.subject.toInt()) + } + + fun verifyJwt(jwt: String): UserType { + try { + val decodedJWT = verifier.verify(jwt) + + require(decodedJWT.getClaim("token_type").asString() == "access") { + "Cannot use refresh token to access" + } + + val user = decodedJWT.subject.toInt() + val roles: List = decodedJWT.getClaim("roles").asList(String::class.java) + val permissions: List = decodedJWT.getClaim("permissions").asList(String::class.java) + + return if (roles.any { it.equals("admin", ignoreCase = true) }) { + UserType.Admin(user) + } else { + UserType.User( + id = user, + permissions = + permissions.mapNotNull { permission -> + Permissions.entries.find { it.name == permission } + }, + ) + } + } catch (e: JWTVerificationException) { + return UserType.Visitor + } + } + + private fun createAccessToken(userId: Int): String { + val jwt = + JWT + .create() + .withIssuer(ISSUER) + .withAudience(AUDIENCE) + .withSubject(userId.toString()) + .withClaim("token_type", "access") + .withExpiresAt(Instant.now().plusSeconds(accessTokenExpiry.inWholeSeconds)) + + val roles = + transaction { + UserRolesTable + .selectAll() + .where { UserRolesTable.user eq userId } + .toList() + .map { it[UserRolesTable.role] } + } + val permissions = + transaction { + UserPermissionsTable + .selectAll() + .where { UserPermissionsTable.user eq userId } + .toList() + .map { it[UserPermissionsTable.permission] } + } + + jwt.withClaim("roles", roles) + + jwt.withClaim("permissions", permissions) + + return jwt.sign(algorithm) + } + + private fun createRefreshToken(userId: Int): String = + JWT + .create() + .withIssuer(ISSUER) + .withAudience(AUDIENCE) + .withSubject(userId.toString()) + .withClaim("token_type", "refresh") + .withExpiresAt(Instant.now().plusSeconds(refreshTokenExpiry.inWholeSeconds)) + .sign(algorithm) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/GlobalMetaTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/GlobalMetaTable.kt index 4872f5c157..ea94e990c8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/GlobalMetaTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/GlobalMetaTable.kt @@ -8,6 +8,7 @@ package suwayomi.tachidesk.global.model.table * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.ReferenceOption /** * Metadata storage for clients, server/global level. @@ -15,4 +16,5 @@ import org.jetbrains.exposed.dao.id.IntIdTable object GlobalMetaTable : IntIdTable() { val key = varchar("key", 256) val value = varchar("value", 4096) + val user = reference("user", UserTable, ReferenceOption.CASCADE) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/UserPermissionsTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/UserPermissionsTable.kt new file mode 100644 index 0000000000..1b0eb07b9b --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/UserPermissionsTable.kt @@ -0,0 +1,19 @@ +package suwayomi.tachidesk.global.model.table + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.Table + +/** + * Users registered in Tachidesk. + */ +object UserPermissionsTable : Table() { + val user = reference("user", UserTable, ReferenceOption.CASCADE) + val permission = varchar("permission", 128) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/UserRolesTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/UserRolesTable.kt new file mode 100644 index 0000000000..551b386f61 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/UserRolesTable.kt @@ -0,0 +1,19 @@ +package suwayomi.tachidesk.global.model.table + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.Table + +/** + * Users registered in Tachidesk. + */ +object UserRolesTable : Table() { + val user = reference("user", UserTable, ReferenceOption.CASCADE) + val role = varchar("role", 24) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/UserTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/UserTable.kt new file mode 100644 index 0000000000..2c86faa5c6 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/model/table/UserTable.kt @@ -0,0 +1,18 @@ +package suwayomi.tachidesk.global.model.table + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import org.jetbrains.exposed.dao.id.IntIdTable + +/** + * Users registered in Tachidesk. + */ +object UserTable : IntIdTable() { + val username = varchar("username", 64) + val password = varchar("password", 90) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/CategoryDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/CategoryDataLoader.kt index 12c83b6257..ad4fd8f62a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/CategoryDataLoader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/CategoryDataLoader.kt @@ -13,14 +13,18 @@ import org.dataloader.DataLoader import org.dataloader.DataLoaderFactory import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger import org.jetbrains.exposed.sql.addLogger +import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.CategoryNodeList import suwayomi.tachidesk.graphql.types.CategoryNodeList.Companion.toNodeList import suwayomi.tachidesk.graphql.types.CategoryType import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryTable +import suwayomi.tachidesk.server.JavalinSetup import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser class CategoryDataLoader : KotlinDataLoader { override val dataLoaderName = "CategoryDataLoader" @@ -28,12 +32,13 @@ class CategoryDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val categories = CategoryTable .selectAll() - .where { CategoryTable.id inList ids } + .where { CategoryTable.id inList ids and (CategoryTable.user eq userId) } .map { CategoryType(it) } .associateBy { it.id } ids.map { categories[it] } @@ -48,10 +53,20 @@ class CategoryForIdsDataLoader : KotlinDataLoader, CategoryNodeList> { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader, CategoryNodeList> = DataLoaderFactory.newDataLoader { categoryIds -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val ids = categoryIds.flatten().distinct() - val categories = CategoryTable.selectAll().where { CategoryTable.id inList ids }.map { CategoryType(it) } + val categories = + CategoryTable + .selectAll() + .where { + CategoryTable.id inList ids and (CategoryTable.user eq userId) + }.map { + CategoryType( + it, + ) + } categoryIds.map { categoryIds -> categories.filter { it.id in categoryIds }.toNodeList() } @@ -66,14 +81,18 @@ class CategoriesForMangaDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val itemsByRef = CategoryMangaTable .innerJoin(CategoryTable) .selectAll() - .where { CategoryMangaTable.manga inList ids } - .map { Pair(it[CategoryMangaTable.manga].value, CategoryType(it)) } + .where { + CategoryMangaTable.manga inList ids and + (CategoryMangaTable.user eq userId) and + (CategoryTable.user eq userId) + }.map { Pair(it[CategoryMangaTable.manga].value, CategoryType(it)) } .groupBy { it.first } .mapValues { it.value.map { pair -> pair.second } } ids.map { (itemsByRef[it] ?: emptyList()).toNodeList() } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ChapterDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ChapterDataLoader.kt index ad768decf8..c11c0eceeb 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ChapterDataLoader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ChapterDataLoader.kt @@ -18,11 +18,16 @@ import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.count import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.ChapterNodeList import suwayomi.tachidesk.graphql.types.ChapterNodeList.Companion.toNodeList import suwayomi.tachidesk.graphql.types.ChapterType import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ChapterUserTable +import suwayomi.tachidesk.manga.model.table.getWithUserData +import suwayomi.tachidesk.server.JavalinSetup import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser class ChapterDataLoader : KotlinDataLoader { override val dataLoaderName = "ChapterDataLoader" @@ -30,10 +35,12 @@ class ChapterDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val chapters = ChapterTable + .getWithUserData(userId) .selectAll() .where { ChapterTable.id inList ids } .map { ChapterType(it) } @@ -50,10 +57,12 @@ class ChaptersForMangaDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val chaptersByMangaId = ChapterTable + .getWithUserData(userId) .selectAll() .where { ChapterTable.manga inList ids } .map { ChapterType(it) } @@ -92,16 +101,18 @@ class UnreadChapterCountForMangaDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val unreadChapterCountByMangaId = ChapterTable - .select(ChapterTable.manga, ChapterTable.isRead.count()) + .getWithUserData(userId) + .select(ChapterTable.manga, ChapterUserTable.isRead.count()) .where { (ChapterTable.manga inList ids) and - (ChapterTable.isRead eq false) + (ChapterUserTable.isRead eq false) }.groupBy(ChapterTable.manga) - .associate { it[ChapterTable.manga].value to it[ChapterTable.isRead.count()] } + .associate { it[ChapterTable.manga].value to it[ChapterUserTable.isRead.count()] } ids.map { unreadChapterCountByMangaId[it]?.toInt() ?: 0 } } } @@ -114,16 +125,18 @@ class BookmarkedChapterCountForMangaDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val bookmarkedChapterCountByMangaId = ChapterTable - .select(ChapterTable.manga, ChapterTable.isBookmarked.count()) + .getWithUserData(userId) + .select(ChapterTable.manga, ChapterUserTable.isBookmarked.count()) .where { (ChapterTable.manga inList ids) and - (ChapterTable.isBookmarked eq true) + (ChapterUserTable.isBookmarked eq true) }.groupBy(ChapterTable.manga) - .associate { it[ChapterTable.manga].value to it[ChapterTable.isBookmarked.count()] } + .associate { it[ChapterTable.manga].value to it[ChapterUserTable.isBookmarked.count()] } ids.map { bookmarkedChapterCountByMangaId[it]?.toInt() ?: 0 } } } @@ -163,13 +176,15 @@ class LastReadChapterForMangaDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val lastReadChaptersByMangaId = ChapterTable + .getWithUserData(userId) .selectAll() .where { (ChapterTable.manga inList ids) } - .orderBy(ChapterTable.lastReadAt to SortOrder.DESC) + .orderBy(ChapterUserTable.lastReadAt to SortOrder.DESC) .groupBy { it[ChapterTable.manga].value } ids.map { id -> lastReadChaptersByMangaId[id]?.let { chapters -> ChapterType(chapters.first()) } } } @@ -183,12 +198,14 @@ class LatestReadChapterForMangaDataLoader : KotlinDataLoader override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val latestReadChaptersByMangaId = ChapterTable + .getWithUserData(userId) .selectAll() - .where { (ChapterTable.manga inList ids) and (ChapterTable.isRead eq true) } + .where { (ChapterTable.manga inList ids) and (ChapterUserTable.isRead eq true) } .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) .groupBy { it[ChapterTable.manga].value } ids.map { id -> latestReadChaptersByMangaId[id]?.let { chapters -> ChapterType(chapters.first()) } } @@ -243,12 +260,14 @@ class FirstUnreadChapterForMangaDataLoader : KotlinDataLoader override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val firstUnreadChaptersByMangaId = ChapterTable + .getWithUserData(userId) .selectAll() - .where { (ChapterTable.manga inList ids) and (ChapterTable.isRead eq false) } + .where { (ChapterTable.manga inList ids) and (ChapterUserTable.isRead eq false) } .orderBy(ChapterTable.sourceOrder to SortOrder.ASC) .groupBy { it[ChapterTable.manga].value } ids.map { id -> firstUnreadChaptersByMangaId[id]?.let { chapters -> ChapterType(chapters.first()) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MangaDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MangaDataLoader.kt index 0775d71ad8..7f127fee57 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MangaDataLoader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MangaDataLoader.kt @@ -14,16 +14,22 @@ import org.dataloader.DataLoaderFactory import org.dataloader.DataLoaderOptions import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger import org.jetbrains.exposed.sql.addLogger +import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.andWhere import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.graphql.cache.CustomCacheMap +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.MangaNodeList import suwayomi.tachidesk.graphql.types.MangaNodeList.Companion.toNodeList import suwayomi.tachidesk.graphql.types.MangaType import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable +import suwayomi.tachidesk.manga.model.table.getWithUserData +import suwayomi.tachidesk.server.JavalinSetup import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser class MangaDataLoader : KotlinDataLoader { override val dataLoaderName = "MangaDataLoader" @@ -31,10 +37,12 @@ class MangaDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val manga = MangaTable + .getWithUserData(userId) .selectAll() .where { MangaTable.id inList ids } .map { MangaType(it) } @@ -51,14 +59,16 @@ class MangaForCategoryDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val itemsByRef = if (ids.contains(0)) { MangaTable + .getWithUserData(userId) .leftJoin(CategoryMangaTable) .selectAll() - .where { MangaTable.inLibrary eq true } + .where { MangaUserTable.inLibrary eq true and (CategoryMangaTable.user eq userId) } .andWhere { CategoryMangaTable.manga.isNull() } .map { MangaType(it) } .let { @@ -70,7 +80,7 @@ class MangaForCategoryDataLoader : KotlinDataLoader { CategoryMangaTable .innerJoin(MangaTable) .selectAll() - .where { CategoryMangaTable.category inList ids } + .where { CategoryMangaTable.category inList ids and (CategoryMangaTable.user eq userId) } .map { Pair(it[CategoryMangaTable.category].value, MangaType(it)) } .groupBy { it.first } .mapValues { it.value.map { pair -> pair.second } } @@ -87,10 +97,12 @@ class MangaForSourceDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val mangaBySourceId = MangaTable + .getWithUserData(userId) .selectAll() .where { MangaTable.sourceReference inList ids } .map { MangaType(it) } @@ -107,12 +119,14 @@ class MangaForIdsDataLoader : KotlinDataLoader, MangaNodeList> { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader, MangaNodeList> = DataLoaderFactory.newDataLoader( { mangaIds -> + val userId = graphQLContext.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() future { transaction { addLogger(Slf4jSqlDebugLogger) val ids = mangaIds.flatten().distinct() val manga = MangaTable + .getWithUserData(userId) .selectAll() .where { MangaTable.id inList ids } .map { MangaType(it) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MetaDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MetaDataLoader.kt index a01f2392ca..e311969189 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MetaDataLoader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MetaDataLoader.kt @@ -6,9 +6,11 @@ import org.dataloader.DataLoader import org.dataloader.DataLoaderFactory import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger import org.jetbrains.exposed.sql.addLogger +import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.global.model.table.GlobalMetaTable +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.CategoryMetaType import suwayomi.tachidesk.graphql.types.ChapterMetaType import suwayomi.tachidesk.graphql.types.GlobalMetaType @@ -18,7 +20,9 @@ import suwayomi.tachidesk.manga.model.table.CategoryMetaTable import suwayomi.tachidesk.manga.model.table.ChapterMetaTable import suwayomi.tachidesk.manga.model.table.MangaMetaTable import suwayomi.tachidesk.manga.model.table.SourceMetaTable +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser class GlobalMetaDataLoader : KotlinDataLoader { override val dataLoaderName = "GlobalMetaDataLoader" @@ -26,12 +30,13 @@ class GlobalMetaDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val metasByRefId = GlobalMetaTable .selectAll() - .where { GlobalMetaTable.key inList ids } + .where { GlobalMetaTable.key inList ids and (GlobalMetaTable.user eq userId) } .map { GlobalMetaType(it) } .associateBy { it.key } ids.map { metasByRefId[it] } @@ -46,12 +51,13 @@ class ChapterMetaDataLoader : KotlinDataLoader> { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader> = DataLoaderFactory.newDataLoader> { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val metasByRefId = ChapterMetaTable .selectAll() - .where { ChapterMetaTable.ref inList ids } + .where { ChapterMetaTable.ref inList ids and (ChapterMetaTable.user eq userId) } .map { ChapterMetaType(it) } .groupBy { it.chapterId } ids.map { metasByRefId[it].orEmpty() } @@ -66,12 +72,13 @@ class MangaMetaDataLoader : KotlinDataLoader> { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader> = DataLoaderFactory.newDataLoader> { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val metasByRefId = MangaMetaTable .selectAll() - .where { MangaMetaTable.ref inList ids } + .where { MangaMetaTable.ref inList ids and (MangaMetaTable.user eq userId) } .map { MangaMetaType(it) } .groupBy { it.mangaId } ids.map { metasByRefId[it].orEmpty() } @@ -86,12 +93,13 @@ class CategoryMetaDataLoader : KotlinDataLoader> { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader> = DataLoaderFactory.newDataLoader> { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val metasByRefId = CategoryMetaTable .selectAll() - .where { CategoryMetaTable.ref inList ids } + .where { CategoryMetaTable.ref inList ids and (CategoryMetaTable.user eq userId) } .map { CategoryMetaType(it) } .groupBy { it.categoryId } ids.map { metasByRefId[it].orEmpty() } @@ -106,12 +114,13 @@ class SourceMetaDataLoader : KotlinDataLoader> { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader> = DataLoaderFactory.newDataLoader> { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val metasByRefId = SourceMetaTable .selectAll() - .where { SourceMetaTable.ref inList ids } + .where { SourceMetaTable.ref inList ids and (SourceMetaTable.user eq userId) } .map { SourceMetaType(it) } .groupBy { it.sourceId } ids.map { metasByRefId[it].orEmpty() } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt index 6a900a2c5d..e76e852bd9 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt @@ -13,8 +13,10 @@ import org.dataloader.DataLoader import org.dataloader.DataLoaderFactory import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger import org.jetbrains.exposed.sql.addLogger +import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.TrackRecordNodeList import suwayomi.tachidesk.graphql.types.TrackRecordNodeList.Companion.toNodeList import suwayomi.tachidesk.graphql.types.TrackRecordType @@ -23,7 +25,9 @@ import suwayomi.tachidesk.graphql.types.TrackerType import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack import suwayomi.tachidesk.manga.model.table.TrackRecordTable +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser class TrackerDataLoader : KotlinDataLoader { override val dataLoaderName = "TrackerDataLoader" @@ -31,8 +35,9 @@ class TrackerDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() ids.map { id -> - TrackerManager.getTracker(id)?.let { TrackerType(it) } + TrackerManager.getTracker(id)?.let { TrackerType(it, userId) } } } } @@ -61,8 +66,9 @@ class TrackerScoresDataLoader : KotlinDataLoader> { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader> = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() ids.map { id -> - TrackerManager.getTracker(id)?.getScoreList() + TrackerManager.getTracker(id)?.getScoreList(userId) } } } @@ -74,8 +80,9 @@ class TrackerTokenExpiredDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() ids.map { id -> - TrackerManager.getTracker(id)?.getIfAuthExpired() + TrackerManager.getTracker(id)?.getIfAuthExpired(userId) } } } @@ -87,12 +94,13 @@ class TrackRecordsForMangaIdDataLoader : KotlinDataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val trackRecordsByMangaId = TrackRecordTable .selectAll() - .where { TrackRecordTable.mangaId inList ids } + .where { TrackRecordTable.mangaId inList ids and (TrackRecordTable.user eq userId) } .map { TrackRecordType(it) } .groupBy { it.mangaId } ids.map { (trackRecordsByMangaId[it] ?: emptyList()).toNodeList() } @@ -107,16 +115,17 @@ class DisplayScoreForTrackRecordDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val trackRecords = TrackRecordTable .selectAll() - .where { TrackRecordTable.id inList ids } + .where { TrackRecordTable.id inList ids and (TrackRecordTable.user eq userId) } .toList() .map { it.toTrack() } .associateBy { it.id!! } - .mapValues { TrackerManager.getTracker(it.value.sync_id)?.displayScore(it.value) } + .mapValues { TrackerManager.getTracker(it.value.sync_id)?.displayScore(userId, it.value) } ids.map { trackRecords[it] } } @@ -130,12 +139,13 @@ class TrackRecordsForTrackerIdDataLoader : KotlinDataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val trackRecordsBySyncId = TrackRecordTable .selectAll() - .where { TrackRecordTable.trackerId inList ids } + .where { TrackRecordTable.trackerId inList ids and (TrackRecordTable.user eq userId) } .map { TrackRecordType(it) } .groupBy { it.trackerId } ids.map { (trackRecordsBySyncId[it] ?: emptyList()).toNodeList() } @@ -150,12 +160,13 @@ class TrackRecordDataLoader : KotlinDataLoader { override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = DataLoaderFactory.newDataLoader { ids -> future { + val userId = graphQLContext.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val trackRecordsId = TrackRecordTable .selectAll() - .where { TrackRecordTable.id inList ids } + .where { TrackRecordTable.id inList ids and (TrackRecordTable.user eq userId) } .map { TrackRecordType(it) } .associateBy { it.id } ids.map { trackRecordsId[it] } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/BackupMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/BackupMutation.kt index 916f8a4385..3bc611147c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/BackupMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/BackupMutation.kt @@ -1,16 +1,21 @@ package suwayomi.tachidesk.graphql.mutations +import graphql.schema.DataFetchingEnvironment import io.javalin.http.UploadedFile +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeout import suwayomi.tachidesk.graphql.server.TemporaryFileStorage +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.BackupRestoreStatus import suwayomi.tachidesk.graphql.types.toStatus import suwayomi.tachidesk.manga.impl.backup.BackupFlags import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture import kotlin.time.Duration.Companion.seconds @@ -26,11 +31,16 @@ class BackupMutation { val status: BackupRestoreStatus?, ) - fun restoreBackup(input: RestoreBackupInput): CompletableFuture { + @OptIn(DelicateCoroutinesApi::class) + fun restoreBackup( + dataFetchingEnvironment: DataFetchingEnvironment, + input: RestoreBackupInput, + ): CompletableFuture { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, backup) = input return future { - val restoreId = ProtoBackupImport.restore(backup.content()) + val restoreId = ProtoBackupImport.restore(userId, backup.content()) withTimeout(10.seconds) { ProtoBackupImport.notifyFlow.first { @@ -53,11 +63,16 @@ class BackupMutation { val url: String, ) - fun createBackup(input: CreateBackupInput? = null): CreateBackupPayload { + fun createBackup( + dataFetchingEnvironment: DataFetchingEnvironment, + input: CreateBackupInput? = null, + ): CreateBackupPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val filename = Backup.getFilename() val backup = ProtoBackupExport.createBackup( + userId, BackupFlags( includeManga = true, includeCategories = input?.includeCategories ?: true, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/CategoryMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/CategoryMutation.kt index e22e05fa5f..b2cbde1109 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/CategoryMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/CategoryMutation.kt @@ -1,6 +1,7 @@ package suwayomi.tachidesk.graphql.mutations import graphql.execution.DataFetcherResult +import graphql.schema.DataFetchingEnvironment import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList import org.jetbrains.exposed.sql.SqlExpressionBuilder.minus @@ -13,6 +14,7 @@ import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update import suwayomi.tachidesk.graphql.asDataFetcherResult +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.CategoryMetaType import suwayomi.tachidesk.graphql.types.CategoryType import suwayomi.tachidesk.graphql.types.MangaType @@ -25,6 +27,9 @@ import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryMetaTable import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.getWithUserData +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.user.requireUser class CategoryMutation { data class SetCategoryMetaInput( @@ -37,11 +42,15 @@ class CategoryMutation { val meta: CategoryMetaType, ) - fun setCategoryMeta(input: SetCategoryMetaInput): DataFetcherResult = + fun setCategoryMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetCategoryMetaInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, meta) = input - Category.modifyMeta(meta.categoryId, meta.key, meta.value) + Category.modifyMeta(userId, meta.categoryId, meta.key, meta.value) SetCategoryMetaPayload(clientMutationId, meta) } @@ -58,8 +67,12 @@ class CategoryMutation { val category: CategoryType, ) - fun deleteCategoryMeta(input: DeleteCategoryMetaInput): DataFetcherResult = + fun deleteCategoryMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteCategoryMetaInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, categoryId, key) = input val (meta, category) = @@ -67,10 +80,17 @@ class CategoryMutation { val meta = CategoryMetaTable .selectAll() - .where { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) } - .firstOrNull() - - CategoryMetaTable.deleteWhere { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) } + .where { + CategoryMetaTable.user eq userId and + (CategoryMetaTable.ref eq categoryId) and + (CategoryMetaTable.key eq key) + }.firstOrNull() + + CategoryMetaTable.deleteWhere { + CategoryMetaTable.user eq userId and + (CategoryMetaTable.ref eq categoryId) and + (CategoryMetaTable.key eq key) + } val category = transaction { @@ -117,26 +137,27 @@ class CategoryMutation { ) private fun updateCategories( + userId: Int, ids: List, patch: UpdateCategoryPatch, ) { transaction { if (patch.name != null) { - CategoryTable.update({ CategoryTable.id inList ids }) { update -> + CategoryTable.update({ CategoryTable.id inList ids and (CategoryTable.user eq userId) }) { update -> patch.name.also { update[name] = it } } } if (patch.default != null) { - CategoryTable.update({ CategoryTable.id inList ids }) { update -> + CategoryTable.update({ CategoryTable.id inList ids and (CategoryTable.user eq userId) }) { update -> patch.default.also { update[isDefault] = it } } } if (patch.includeInUpdate != null) { - CategoryTable.update({ CategoryTable.id inList ids }) { update -> + CategoryTable.update({ CategoryTable.id inList ids and (CategoryTable.user eq userId) }) { update -> patch.includeInUpdate.also { update[includeInUpdate] = it.value } @@ -152,11 +173,15 @@ class CategoryMutation { } } - fun updateCategory(input: UpdateCategoryInput): DataFetcherResult = + fun updateCategory( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateCategoryInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, id, patch) = input - updateCategories(listOf(id), patch) + updateCategories(userId, listOf(id), patch) val category = transaction { @@ -169,11 +194,15 @@ class CategoryMutation { ) } - fun updateCategories(input: UpdateCategoriesInput): DataFetcherResult = + fun updateCategories( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateCategoriesInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, ids, patch) = input - updateCategories(ids, patch) + updateCategories(userId, ids, patch) val categories = transaction { @@ -197,8 +226,12 @@ class CategoryMutation { val position: Int, ) - fun updateCategoryOrder(input: UpdateCategoryOrderInput): DataFetcherResult = + fun updateCategoryOrder( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateCategoryOrderInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, categoryId, position) = input require(position > 0) { "'order' must not be <= 0" @@ -208,31 +241,35 @@ class CategoryMutation { val currentOrder = CategoryTable .selectAll() - .where { CategoryTable.id eq categoryId } + .where { CategoryTable.id eq categoryId and (CategoryTable.user eq userId) } .first()[CategoryTable.order] if (currentOrder != position) { if (position < currentOrder) { - CategoryTable.update({ CategoryTable.order greaterEq position }) { + CategoryTable.update({ CategoryTable.order greaterEq position and (CategoryTable.user eq userId) }) { it[CategoryTable.order] = CategoryTable.order + 1 } } else { - CategoryTable.update({ CategoryTable.order lessEq position }) { + CategoryTable.update({ CategoryTable.order lessEq position and (CategoryTable.user eq userId) }) { it[CategoryTable.order] = CategoryTable.order - 1 } } - CategoryTable.update({ CategoryTable.id eq categoryId }) { + CategoryTable.update({ CategoryTable.id eq categoryId and (CategoryTable.user eq userId) }) { it[CategoryTable.order] = position } } } - Category.normalizeCategories() + Category.normalizeCategories(userId) val categories = transaction { - CategoryTable.selectAll().orderBy(CategoryTable.order).map { CategoryType(it) } + CategoryTable + .selectAll() + .where { CategoryTable.user eq userId } + .orderBy(CategoryTable.order) + .map { CategoryType(it) } } UpdateCategoryOrderPayload( @@ -255,11 +292,15 @@ class CategoryMutation { val category: CategoryType, ) - fun createCategory(input: CreateCategoryInput): DataFetcherResult = + fun createCategory( + dataFetchingEnvironment: DataFetchingEnvironment, + input: CreateCategoryInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, name, order, default, includeInUpdate, includeInDownload) = input transaction { - require(CategoryTable.selectAll().where { CategoryTable.name eq input.name }.isEmpty()) { + require(CategoryTable.selectAll().where { CategoryTable.name eq input.name and (CategoryTable.user eq userId) }.isEmpty()) { "'name' must be unique" } } @@ -275,13 +316,14 @@ class CategoryMutation { val category = transaction { if (order != null) { - CategoryTable.update({ CategoryTable.order greaterEq order }) { + CategoryTable.update({ CategoryTable.order greaterEq order and (CategoryTable.user eq userId) }) { it[CategoryTable.order] = CategoryTable.order + 1 } } val id = CategoryTable.insertAndGetId { + it[CategoryTable.user] = userId it[CategoryTable.name] = input.name it[CategoryTable.order] = order ?: Int.MAX_VALUE if (default != null) { @@ -295,9 +337,9 @@ class CategoryMutation { } } - Category.normalizeCategories() + Category.normalizeCategories(userId) - CategoryType(CategoryTable.selectAll().where { CategoryTable.id eq id }.first()) + CategoryType(CategoryTable.selectAll().where { CategoryTable.id eq id and (CategoryTable.user eq userId) }.first()) } CreateCategoryPayload(clientMutationId, category) @@ -314,8 +356,12 @@ class CategoryMutation { val mangas: List, ) - fun deleteCategory(input: DeleteCategoryInput): DataFetcherResult { + fun deleteCategory( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteCategoryInput, + ): DataFetcherResult { return asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, categoryId) = input if (categoryId == 0) { // Don't delete default category return@asDataFetcherResult DeleteCategoryPayload( @@ -336,15 +382,16 @@ class CategoryMutation { val mangas = transaction { MangaTable + .getWithUserData(userId) .innerJoin(CategoryMangaTable) .selectAll() - .where { CategoryMangaTable.category eq categoryId } + .where { CategoryMangaTable.category eq categoryId and (CategoryMangaTable.user eq userId) } .map { MangaType(it) } } - CategoryTable.deleteWhere { CategoryTable.id eq categoryId } + CategoryTable.deleteWhere { CategoryTable.id eq categoryId and (CategoryTable.user eq userId) } - Category.normalizeCategories() + Category.normalizeCategories(userId) if (category != null) { CategoryType(category) @@ -386,15 +433,18 @@ class CategoryMutation { ) private fun updateMangas( + userId: Int, ids: List, patch: UpdateMangaCategoriesPatch, ) { transaction { if (patch.clearCategories == true) { - CategoryMangaTable.deleteWhere { CategoryMangaTable.manga inList ids } + CategoryMangaTable.deleteWhere { CategoryMangaTable.manga inList ids and (CategoryMangaTable.user eq userId) } } else if (!patch.removeFromCategories.isNullOrEmpty()) { CategoryMangaTable.deleteWhere { - (CategoryMangaTable.manga inList ids) and (CategoryMangaTable.category inList patch.removeFromCategories) + (CategoryMangaTable.manga inList ids) and + (CategoryMangaTable.category inList patch.removeFromCategories) and + (CategoryMangaTable.user eq userId) } } if (!patch.addToCategories.isNullOrEmpty()) { @@ -406,7 +456,9 @@ class CategoryMutation { CategoryMangaTable .selectAll() .where { - (CategoryMangaTable.manga eq mangaId) and (CategoryMangaTable.category eq categoryId) + (CategoryMangaTable.manga eq mangaId) and + (CategoryMangaTable.category eq categoryId) and + (CategoryMangaTable.user eq userId) }.isNotEmpty() if (!existingMapping) { @@ -419,16 +471,21 @@ class CategoryMutation { CategoryMangaTable.batchInsert(newCategories) { (manga, category) -> this[CategoryMangaTable.manga] = manga this[CategoryMangaTable.category] = category + this[CategoryMangaTable.user] = userId } } } } - fun updateMangaCategories(input: UpdateMangaCategoriesInput): DataFetcherResult = + fun updateMangaCategories( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateMangaCategoriesInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, id, patch) = input - updateMangas(listOf(id), patch) + updateMangas(userId, listOf(id), patch) val manga = transaction { @@ -441,11 +498,15 @@ class CategoryMutation { ) } - fun updateMangasCategories(input: UpdateMangasCategoriesInput): DataFetcherResult = + fun updateMangasCategories( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateMangasCategoriesInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, ids, patch) = input - updateMangas(ids, patch) + updateMangas(userId, ids, patch) val mangas = transaction { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt index 5c80929336..1693dc5823 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt @@ -1,21 +1,27 @@ package suwayomi.tachidesk.graphql.mutations import graphql.execution.DataFetcherResult -import org.jetbrains.exposed.dao.id.EntityID +import graphql.schema.DataFetchingEnvironment import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.batchInsert import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.selectAll -import org.jetbrains.exposed.sql.statements.BatchUpdateStatement import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.sql.update import suwayomi.tachidesk.graphql.asDataFetcherResult +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.ChapterMetaType import suwayomi.tachidesk.graphql.types.ChapterType import suwayomi.tachidesk.manga.impl.Chapter import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyById import suwayomi.tachidesk.manga.model.table.ChapterMetaTable import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ChapterUserTable +import suwayomi.tachidesk.manga.model.table.getWithUserData +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser import java.time.Instant import java.util.concurrent.CompletableFuture @@ -54,6 +60,7 @@ class ChapterMutation { ) private fun updateChapters( + userId: Int, ids: List, patch: UpdateChapterPatch, ) { @@ -72,38 +79,54 @@ class ChapterMutation { } else { emptyMap() } + val currentChapterUserItems = + ChapterUserTable + .select(ChapterUserTable.chapter) + .where { ChapterUserTable.chapter inList ids } + .map { it[ChapterUserTable.chapter].value } + if (currentChapterUserItems.size < ids.size) { + ChapterUserTable.batchInsert(ids - currentChapterUserItems.toSet()) { + this[ChapterUserTable.user] = userId + this[ChapterUserTable.chapter] = it + } + } if (patch.isRead != null || patch.isBookmarked != null || patch.lastPageRead != null) { val now = Instant.now().epochSecond - - BatchUpdateStatement(ChapterTable).apply { - ids.forEach { chapterId -> - addBatch(EntityID(chapterId, ChapterTable)) - patch.isRead?.also { - this[ChapterTable.isRead] = it - } - patch.isBookmarked?.also { - this[ChapterTable.isBookmarked] = it - } - patch.lastPageRead?.also { - this[ChapterTable.lastPageRead] = it.coerceAtMost(chapterIdToPageCount[chapterId] ?: 0).coerceAtLeast(0) - this[ChapterTable.lastReadAt] = now - } + ChapterUserTable.update({ ChapterUserTable.chapter inList ids }) { update -> + patch.isRead?.also { + update[isRead] = it + } + patch.isBookmarked?.also { + update[isBookmarked] = it + } + patch.lastPageRead?.also { + update[lastPageRead] = it // todo user accounts it.coerceAtMost(chapterIdToPageCount[this.chapter] ?: 0).coerceAtLeast(0) + update[lastReadAt] = now } - execute(this@transaction) } } } } - fun updateChapter(input: UpdateChapterInput): DataFetcherResult = + fun updateChapter( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateChapterInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, id, patch) = input - updateChapters(listOf(id), patch) + updateChapters(userId, listOf(id), patch) val chapter = transaction { - ChapterType(ChapterTable.selectAll().where { ChapterTable.id eq id }.first()) + ChapterType( + ChapterTable + .getWithUserData(userId) + .selectAll() + .where { ChapterTable.id eq id } + .first(), + ) } UpdateChapterPayload( @@ -112,15 +135,23 @@ class ChapterMutation { ) } - fun updateChapters(input: UpdateChaptersInput): DataFetcherResult = + fun updateChapters( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateChaptersInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, ids, patch) = input - updateChapters(ids, patch) + updateChapters(userId, ids, patch) val chapters = transaction { - ChapterTable.selectAll().where { ChapterTable.id inList ids }.map { ChapterType(it) } + ChapterTable + .getWithUserData(userId) + .selectAll() + .where { ChapterTable.id inList ids } + .map { ChapterType(it) } } UpdateChaptersPayload( @@ -139,16 +170,21 @@ class ChapterMutation { val chapters: List, ) - fun fetchChapters(input: FetchChaptersInput): CompletableFuture> { + fun fetchChapters( + dataFetchingEnvironment: DataFetchingEnvironment, + input: FetchChaptersInput, + ): CompletableFuture> { val (clientMutationId, mangaId) = input return future { asDataFetcherResult { - Chapter.fetchChapterList(mangaId) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + Chapter.fetchChapterList(userId, mangaId) val chapters = transaction { ChapterTable + .getWithUserData(userId) .selectAll() .where { ChapterTable.manga eq mangaId } .orderBy(ChapterTable.sourceOrder) @@ -173,11 +209,15 @@ class ChapterMutation { val meta: ChapterMetaType, ) - fun setChapterMeta(input: SetChapterMetaInput): DataFetcherResult = + fun setChapterMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetChapterMetaInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, meta) = input - Chapter.modifyChapterMeta(meta.chapterId, meta.key, meta.value) + Chapter.modifyChapterMeta(userId, meta.chapterId, meta.key, meta.value) SetChapterMetaPayload(clientMutationId, meta) } @@ -194,8 +234,12 @@ class ChapterMutation { val chapter: ChapterType, ) - fun deleteChapterMeta(input: DeleteChapterMetaInput): DataFetcherResult = + fun deleteChapterMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteChapterMetaInput, + ): DataFetcherResult = asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, chapterId, key) = input val (meta, chapter) = @@ -203,14 +247,27 @@ class ChapterMutation { val meta = ChapterMetaTable .selectAll() - .where { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } - .firstOrNull() - - ChapterMetaTable.deleteWhere { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } + .where { + ChapterMetaTable.user eq userId and + (ChapterMetaTable.ref eq chapterId) and + (ChapterMetaTable.key eq key) + }.firstOrNull() + + ChapterMetaTable.deleteWhere { + ChapterMetaTable.user eq userId and + (ChapterMetaTable.ref eq chapterId) and + (ChapterMetaTable.key eq key) + } val chapter = transaction { - ChapterType(ChapterTable.selectAll().where { ChapterTable.id eq chapterId }.first()) + ChapterType( + ChapterTable + .getWithUserData(userId) + .selectAll() + .where { ChapterTable.id eq chapterId } + .first(), + ) } if (meta != null) { @@ -234,12 +291,16 @@ class ChapterMutation { val chapter: ChapterType, ) - fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture> { + fun fetchChapterPages( + dataFetchingEnvironment: DataFetchingEnvironment, + input: FetchChapterPagesInput, + ): CompletableFuture> { val (clientMutationId, chapterId) = input return future { asDataFetcherResult { - val chapter = getChapterDownloadReadyById(chapterId) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + val chapter = getChapterDownloadReadyById(userId, chapterId) FetchChapterPagesPayload( clientMutationId = clientMutationId, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt index 1d326f63ff..9fcbd9401a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt @@ -1,11 +1,13 @@ package suwayomi.tachidesk.graphql.mutations import graphql.execution.DataFetcherResult +import graphql.schema.DataFetchingEnvironment import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeout import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.graphql.asDataFetcherResult +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.ChapterType import suwayomi.tachidesk.graphql.types.DownloadStatus import suwayomi.tachidesk.manga.impl.Chapter @@ -13,7 +15,9 @@ import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.download.model.DownloadUpdateType.DEQUEUED import suwayomi.tachidesk.manga.impl.download.model.Status import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture import kotlin.time.Duration.Companion.seconds @@ -28,10 +32,14 @@ class DownloadMutation { val chapters: List, ) - fun deleteDownloadedChapters(input: DeleteDownloadedChaptersInput): DataFetcherResult { + fun deleteDownloadedChapters( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteDownloadedChaptersInput, + ): DataFetcherResult { val (clientMutationId, chapters) = input return asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() Chapter.deleteChapters(chapters) DeleteDownloadedChaptersPayload( @@ -57,10 +65,14 @@ class DownloadMutation { val chapters: ChapterType, ) - fun deleteDownloadedChapter(input: DeleteDownloadedChapterInput): DataFetcherResult { + fun deleteDownloadedChapter( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteDownloadedChapterInput, + ): DataFetcherResult { val (clientMutationId, chapter) = input return asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() Chapter.deleteChapters(listOf(chapter)) DeleteDownloadedChapterPayload( @@ -84,12 +96,14 @@ class DownloadMutation { ) fun enqueueChapterDownloads( + dataFetchingEnvironment: DataFetchingEnvironment, input: EnqueueChapterDownloadsInput, ): CompletableFuture> { val (clientMutationId, chapters) = input return future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.enqueue(DownloadManager.EnqueueInput(chapters)) EnqueueChapterDownloadsPayload( @@ -118,11 +132,15 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun enqueueChapterDownload(input: EnqueueChapterDownloadInput): CompletableFuture> { + fun enqueueChapterDownload( + dataFetchingEnvironment: DataFetchingEnvironment, + input: EnqueueChapterDownloadInput, + ): CompletableFuture> { val (clientMutationId, chapter) = input return future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.enqueue(DownloadManager.EnqueueInput(listOf(chapter))) EnqueueChapterDownloadPayload( @@ -151,12 +169,14 @@ class DownloadMutation { ) fun dequeueChapterDownloads( + dataFetchingEnvironment: DataFetchingEnvironment, input: DequeueChapterDownloadsInput, ): CompletableFuture> { val (clientMutationId, chapters) = input return future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.dequeue(DownloadManager.EnqueueInput(chapters)) DequeueChapterDownloadsPayload( @@ -187,11 +207,15 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun dequeueChapterDownload(input: DequeueChapterDownloadInput): CompletableFuture> { + fun dequeueChapterDownload( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DequeueChapterDownloadInput, + ): CompletableFuture> { val (clientMutationId, chapter) = input return future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.dequeue(DownloadManager.EnqueueInput(listOf(chapter))) DequeueChapterDownloadPayload( @@ -221,9 +245,13 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun startDownloader(input: StartDownloaderInput): CompletableFuture> = + fun startDownloader( + dataFetchingEnvironment: DataFetchingEnvironment, + input: StartDownloaderInput, + ): CompletableFuture> = future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.start() StartDownloaderPayload( @@ -249,9 +277,13 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun stopDownloader(input: StopDownloaderInput): CompletableFuture> = + fun stopDownloader( + dataFetchingEnvironment: DataFetchingEnvironment, + input: StopDownloaderInput, + ): CompletableFuture> = future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.stop() StopDownloaderPayload( @@ -277,9 +309,13 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun clearDownloader(input: ClearDownloaderInput): CompletableFuture> = + fun clearDownloader( + dataFetchingEnvironment: DataFetchingEnvironment, + input: ClearDownloaderInput, + ): CompletableFuture> = future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.clear() ClearDownloaderPayload( @@ -307,11 +343,15 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun reorderChapterDownload(input: ReorderChapterDownloadInput): CompletableFuture> { + fun reorderChapterDownload( + dataFetchingEnvironment: DataFetchingEnvironment, + input: ReorderChapterDownloadInput, + ): CompletableFuture> { val (clientMutationId, chapter, to) = input return future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.reorder(chapter, to) ReorderChapterDownloadPayload( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ExtensionMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ExtensionMutation.kt index d74b7b1dbc..602eb92858 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ExtensionMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ExtensionMutation.kt @@ -2,15 +2,19 @@ package suwayomi.tachidesk.graphql.mutations import eu.kanade.tachiyomi.source.local.LocalSource import graphql.execution.DataFetcherResult +import graphql.schema.DataFetchingEnvironment import io.javalin.http.UploadedFile import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.graphql.asDataFetcherResult +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.ExtensionType import suwayomi.tachidesk.manga.impl.extension.Extension import suwayomi.tachidesk.manga.impl.extension.ExtensionsList import suwayomi.tachidesk.manga.model.table.ExtensionTable +import suwayomi.tachidesk.server.JavalinSetup import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture class ExtensionMutation { @@ -73,11 +77,15 @@ class ExtensionMutation { } } - fun updateExtension(input: UpdateExtensionInput): CompletableFuture> { + fun updateExtension( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateExtensionInput, + ): CompletableFuture> { val (clientMutationId, id, patch) = input return future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() updateExtensions(listOf(id), patch) val extension = @@ -97,11 +105,15 @@ class ExtensionMutation { } } - fun updateExtensions(input: UpdateExtensionsInput): CompletableFuture> { + fun updateExtensions( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateExtensionsInput, + ): CompletableFuture> { val (clientMutationId, ids, patch) = input return future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() updateExtensions(ids, patch) val extensions = @@ -129,11 +141,15 @@ class ExtensionMutation { val extensions: List, ) - fun fetchExtensions(input: FetchExtensionsInput): CompletableFuture> { + fun fetchExtensions( + dataFetchingEnvironment: DataFetchingEnvironment, + input: FetchExtensionsInput, + ): CompletableFuture> { val (clientMutationId) = input return future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() ExtensionsList.fetchExtensions() val extensions = @@ -163,12 +179,14 @@ class ExtensionMutation { ) fun installExternalExtension( + dataFetchingEnvironment: DataFetchingEnvironment, input: InstallExternalExtensionInput, ): CompletableFuture> { val (clientMutationId, extensionFile) = input return future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() Extension.installExternalExtension(extensionFile.content(), extensionFile.filename()) val dbExtension = diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/InfoMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/InfoMutation.kt index 7880929f1e..18701d10f4 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/InfoMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/InfoMutation.kt @@ -1,14 +1,18 @@ package suwayomi.tachidesk.graphql.mutations import graphql.execution.DataFetcherResult +import graphql.schema.DataFetchingEnvironment import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeout import suwayomi.tachidesk.graphql.asDataFetcherResult +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.UpdateState.DOWNLOADING import suwayomi.tachidesk.graphql.types.UpdateState.ERROR import suwayomi.tachidesk.graphql.types.UpdateState.IDLE import suwayomi.tachidesk.graphql.types.WebUIUpdateStatus +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.WebInterfaceManager import suwayomi.tachidesk.server.util.WebUIFlavor import java.util.concurrent.CompletableFuture @@ -24,9 +28,13 @@ class InfoMutation { val updateStatus: WebUIUpdateStatus, ) - fun updateWebUI(input: WebUIUpdateInput): CompletableFuture> { + fun updateWebUI( + dataFetchingEnvironment: DataFetchingEnvironment, + input: WebUIUpdateInput, + ): CompletableFuture> { return future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() withTimeout(30.seconds) { if (WebInterfaceManager.status.value.state === DOWNLOADING) { return@withTimeout WebUIUpdatePayload(input.clientMutationId, WebInterfaceManager.status.value) @@ -59,9 +67,12 @@ class InfoMutation { } } - fun resetWebUIUpdateStatus(): CompletableFuture> = + fun resetWebUIUpdateStatus( + dataFetchingEnvironment: DataFetchingEnvironment, + ): CompletableFuture> = future { asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() withTimeout(30.seconds) { val isUpdateFinished = WebInterfaceManager.status.value.state != DOWNLOADING if (!isUpdateFinished) { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt index 1e7322a342..15674bfa09 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt @@ -1,13 +1,16 @@ package suwayomi.tachidesk.graphql.mutations import graphql.execution.DataFetcherResult +import graphql.schema.DataFetchingEnvironment import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.batchInsert import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update import suwayomi.tachidesk.graphql.asDataFetcherResult +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.MangaMetaType import suwayomi.tachidesk.graphql.types.MangaType import suwayomi.tachidesk.manga.impl.Library @@ -15,8 +18,12 @@ import suwayomi.tachidesk.manga.impl.Manga import suwayomi.tachidesk.manga.impl.update.IUpdater import suwayomi.tachidesk.manga.model.table.MangaMetaTable import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable +import suwayomi.tachidesk.manga.model.table.getWithUserData import suwayomi.tachidesk.manga.model.table.toDataClass +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser import uy.kohesive.injekt.injectLazy import java.time.Instant import java.util.concurrent.CompletableFuture @@ -56,12 +63,26 @@ class MangaMutation { ) private suspend fun updateMangas( + userId: Int, ids: List, patch: UpdateMangaPatch, ) { transaction { + val currentMangaUserItems = + MangaUserTable + .select(MangaUserTable.manga) + .where { MangaUserTable.manga inList ids } + .map { it[MangaUserTable.manga].value } + if (currentMangaUserItems.size < ids.size) { + MangaUserTable.batchInsert(ids - currentMangaUserItems.toSet()) { + this[MangaUserTable.user] = userId + this[MangaUserTable.manga] = it + } + } + if (patch.inLibrary != null) { - MangaTable.update({ MangaTable.id inList ids }) { update -> + val now = Instant.now().epochSecond + MangaUserTable.update({ MangaUserTable.manga inList ids }) { update -> patch.inLibrary.also { update[inLibrary] = it if (it) update[inLibraryAt] = Instant.now().epochSecond @@ -77,29 +98,39 @@ class MangaMutation { MangaTable .selectAll() .where { (MangaTable.id inList ids) and (MangaTable.initialized eq false) } - .map { MangaTable.toDataClass(it) } + .map { MangaTable.toDataClass(userId, it) } } updater.addMangasToQueue(mangas) } ids.forEach { - Library.handleMangaThumbnail(it, patch.inLibrary) + Library.handleMangaThumbnail(it) } } } } - fun updateManga(input: UpdateMangaInput): CompletableFuture> { + fun updateManga( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateMangaInput, + ): CompletableFuture> { val (clientMutationId, id, patch) = input return future { asDataFetcherResult { - updateMangas(listOf(id), patch) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + updateMangas(userId, listOf(id), patch) val manga = transaction { - MangaType(MangaTable.selectAll().where { MangaTable.id eq id }.first()) + MangaType( + MangaTable + .getWithUserData(userId) + .selectAll() + .where { MangaTable.id eq id } + .first(), + ) } UpdateMangaPayload( @@ -110,16 +141,24 @@ class MangaMutation { } } - fun updateMangas(input: UpdateMangasInput): CompletableFuture> { + fun updateMangas( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateMangasInput, + ): CompletableFuture> { val (clientMutationId, ids, patch) = input return future { asDataFetcherResult { - updateMangas(ids, patch) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + updateMangas(userId, ids, patch) val mangas = transaction { - MangaTable.selectAll().where { MangaTable.id inList ids }.map { MangaType(it) } + MangaTable + .getWithUserData(userId) + .selectAll() + .where { MangaTable.id inList ids } + .map { MangaType(it) } } UpdateMangasPayload( @@ -140,16 +179,24 @@ class MangaMutation { val manga: MangaType, ) - fun fetchManga(input: FetchMangaInput): CompletableFuture> { + fun fetchManga( + dataFetchingEnvironment: DataFetchingEnvironment, + input: FetchMangaInput, + ): CompletableFuture> { val (clientMutationId, id) = input return future { asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() Manga.fetchManga(id) val manga = transaction { - MangaTable.selectAll().where { MangaTable.id eq id }.first() + MangaTable + .getWithUserData(userId) + .selectAll() + .where { MangaTable.id eq id } + .first() } FetchMangaPayload( clientMutationId = clientMutationId, @@ -169,11 +216,15 @@ class MangaMutation { val meta: MangaMetaType, ) - fun setMangaMeta(input: SetMangaMetaInput): DataFetcherResult { + fun setMangaMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetMangaMetaInput, + ): DataFetcherResult { val (clientMutationId, meta) = input return asDataFetcherResult { - Manga.modifyMangaMeta(meta.mangaId, meta.key, meta.value) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + Manga.modifyMangaMeta(userId, meta.mangaId, meta.key, meta.value) SetMangaMetaPayload(clientMutationId, meta) } @@ -191,23 +242,40 @@ class MangaMutation { val manga: MangaType, ) - fun deleteMangaMeta(input: DeleteMangaMetaInput): DataFetcherResult { + fun deleteMangaMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteMangaMetaInput, + ): DataFetcherResult { val (clientMutationId, mangaId, key) = input return asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (meta, manga) = transaction { val meta = MangaMetaTable .selectAll() - .where { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) } - .firstOrNull() - - MangaMetaTable.deleteWhere { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) } + .where { + MangaMetaTable.user eq userId and + (MangaMetaTable.ref eq mangaId) and + (MangaMetaTable.key eq key) + }.firstOrNull() + + MangaMetaTable.deleteWhere { + MangaMetaTable.user eq userId and + (MangaMetaTable.ref eq mangaId) and + (MangaMetaTable.key eq key) + } val manga = transaction { - MangaType(MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()) + MangaType( + MangaTable + .getWithUserData(userId) + .selectAll() + .where { MangaTable.id eq mangaId } + .first(), + ) } if (meta != null) { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MetaMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MetaMutation.kt index e83b1cf850..510dbdefb2 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MetaMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MetaMutation.kt @@ -1,14 +1,19 @@ package suwayomi.tachidesk.graphql.mutations import graphql.execution.DataFetcherResult +import graphql.schema.DataFetchingEnvironment import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.global.impl.GlobalMeta import suwayomi.tachidesk.global.model.table.GlobalMetaTable import suwayomi.tachidesk.graphql.asDataFetcherResult +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.GlobalMetaType +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.user.requireUser class MetaMutation { data class SetGlobalMetaInput( @@ -21,11 +26,15 @@ class MetaMutation { val meta: GlobalMetaType, ) - fun setGlobalMeta(input: SetGlobalMetaInput): DataFetcherResult { + fun setGlobalMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetGlobalMetaInput, + ): DataFetcherResult { val (clientMutationId, meta) = input return asDataFetcherResult { - GlobalMeta.modifyMeta(meta.key, meta.value) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + GlobalMeta.modifyMeta(userId, meta.key, meta.value) SetGlobalMetaPayload(clientMutationId, meta) } @@ -41,19 +50,23 @@ class MetaMutation { val meta: GlobalMetaType?, ) - fun deleteGlobalMeta(input: DeleteGlobalMetaInput): DataFetcherResult { + fun deleteGlobalMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteGlobalMetaInput, + ): DataFetcherResult { val (clientMutationId, key) = input return asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val meta = transaction { val meta = GlobalMetaTable .selectAll() - .where { GlobalMetaTable.key eq key } + .where { GlobalMetaTable.key eq key and (GlobalMetaTable.user eq userId) } .firstOrNull() - GlobalMetaTable.deleteWhere { GlobalMetaTable.key eq key } + GlobalMetaTable.deleteWhere { GlobalMetaTable.key eq key and (GlobalMetaTable.user eq userId) } if (meta != null) { GlobalMetaType(meta) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt index 503c0f94e2..6c73972f6d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt @@ -1,13 +1,17 @@ package suwayomi.tachidesk.graphql.mutations +import graphql.schema.DataFetchingEnvironment import kotlinx.coroutines.flow.MutableStateFlow +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.PartialSettingsType import suwayomi.tachidesk.graphql.types.Settings import suwayomi.tachidesk.graphql.types.SettingsType import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.repoMatchRegex +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.SERVER_CONFIG_MODULE_NAME import suwayomi.tachidesk.server.ServerConfig import suwayomi.tachidesk.server.serverConfig +import suwayomi.tachidesk.server.user.requireUser import xyz.nulldev.ts.config.GlobalConfigManager import java.io.File @@ -153,6 +157,7 @@ class SettingsMutation { updateSetting(settings.basicAuthEnabled, serverConfig.basicAuthEnabled) updateSetting(settings.basicAuthUsername, serverConfig.basicAuthUsername) updateSetting(settings.basicAuthPassword, serverConfig.basicAuthPassword) + updateSetting(settings.multiUser, serverConfig.multiUser) // misc updateSetting(settings.debugLogsEnabled, serverConfig.debugLogsEnabled) @@ -179,7 +184,11 @@ class SettingsMutation { updateSetting(settings.flareSolverrAsResponseFallback, serverConfig.flareSolverrAsResponseFallback) } - fun setSettings(input: SetSettingsInput): SetSettingsPayload { + fun setSettings( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetSettingsInput, + ): SetSettingsPayload { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, settings) = input validateSettings(settings) @@ -197,7 +206,11 @@ class SettingsMutation { val settings: SettingsType, ) - fun resetSettings(input: ResetSettingsInput): ResetSettingsPayload { + fun resetSettings( + dataFetchingEnvironment: DataFetchingEnvironment, + input: ResetSettingsInput, + ): ResetSettingsPayload { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId) = input GlobalConfigManager.resetUserConfig() diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SourceMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SourceMutation.kt index f47d054134..b7bb8ca142 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SourceMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SourceMutation.kt @@ -6,12 +6,14 @@ import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.SwitchPreferenceCompat import graphql.execution.DataFetcherResult +import graphql.schema.DataFetchingEnvironment import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.graphql.asDataFetcherResult +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.FilterChange import suwayomi.tachidesk.graphql.types.MangaType import suwayomi.tachidesk.graphql.types.Preference @@ -25,7 +27,10 @@ import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.SourceMetaTable import suwayomi.tachidesk.manga.model.table.SourceTable +import suwayomi.tachidesk.manga.model.table.getWithUserData +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture class SourceMutation { @@ -39,11 +44,15 @@ class SourceMutation { val meta: SourceMetaType, ) - fun setSourceMeta(input: SetSourceMetaInput): DataFetcherResult { + fun setSourceMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetSourceMetaInput, + ): DataFetcherResult { val (clientMutationId, meta) = input return asDataFetcherResult { - Source.modifyMeta(meta.sourceId, meta.key, meta.value) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + Source.modifyMeta(userId, meta.sourceId, meta.key, meta.value) SetSourceMetaPayload(clientMutationId, meta) } @@ -61,19 +70,30 @@ class SourceMutation { val source: SourceType?, ) - fun deleteSourceMeta(input: DeleteSourceMetaInput): DataFetcherResult { + fun deleteSourceMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteSourceMetaInput, + ): DataFetcherResult { val (clientMutationId, sourceId, key) = input return asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (meta, source) = transaction { val meta = SourceMetaTable .selectAll() - .where { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) } - .firstOrNull() - - SourceMetaTable.deleteWhere { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) } + .where { + (SourceMetaTable.user eq userId) and + (SourceMetaTable.ref eq sourceId) and + (SourceMetaTable.key eq key) + }.firstOrNull() + + SourceMetaTable.deleteWhere { + (SourceMetaTable.user eq userId) and + (SourceMetaTable.ref eq sourceId) and + (SourceMetaTable.key eq key) + } val source = transaction { @@ -116,11 +136,15 @@ class SourceMutation { val hasNextPage: Boolean, ) - fun fetchSourceManga(input: FetchSourceMangaInput): CompletableFuture> { + fun fetchSourceManga( + dataFetchingEnvironment: DataFetchingEnvironment, + input: FetchSourceMangaInput, + ): CompletableFuture> { val (clientMutationId, sourceId, type, page, query, filters) = input return future { asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val source = GetCatalogueSource.getCatalogueSourceOrNull(sourceId)!! val mangasPage = when (type) { @@ -140,11 +164,12 @@ class SourceMutation { } } - val mangaIds = mangasPage.insertOrUpdate(sourceId) + val mangaIds = mangasPage.insertOrUpdate(userId, sourceId) val mangas = transaction { MangaTable + .getWithUserData(userId) .selectAll() .where { MangaTable.id inList mangaIds } .map { MangaType(it) } @@ -182,10 +207,14 @@ class SourceMutation { val source: SourceType, ) - fun updateSourcePreference(input: UpdateSourcePreferenceInput): DataFetcherResult { + fun updateSourcePreference( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateSourcePreferenceInput, + ): DataFetcherResult { val (clientMutationId, sourceId, change) = input return asDataFetcherResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() Source.setSourcePreference(sourceId, change.position, "") { preference -> when (preference) { is SwitchPreferenceCompat -> change.switchState diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt index 5e2f264809..98df167326 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt @@ -3,16 +3,20 @@ package suwayomi.tachidesk.graphql.mutations import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated import com.expediagroup.graphql.generator.annotations.GraphQLDescription import graphql.execution.DataFetcherResult +import graphql.schema.DataFetchingEnvironment import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.graphql.asDataFetcherResult +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.TrackRecordType import suwayomi.tachidesk.graphql.types.TrackerType import suwayomi.tachidesk.manga.impl.track.Track import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager import suwayomi.tachidesk.manga.model.table.TrackRecordTable +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture class TrackMutation { @@ -28,14 +32,18 @@ class TrackMutation { val tracker: TrackerType, ) - fun loginTrackerOAuth(input: LoginTrackerOAuthInput): CompletableFuture { + fun loginTrackerOAuth( + dataFetchingEnvironment: DataFetchingEnvironment, + input: LoginTrackerOAuthInput, + ): CompletableFuture { val tracker = requireNotNull(TrackerManager.getTracker(input.trackerId)) { "Could not find tracker" } return future { - tracker.authCallback(input.callbackUrl) - val trackerType = TrackerType(tracker) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + tracker.authCallback(userId, input.callbackUrl) + val trackerType = TrackerType(tracker, userId) LoginTrackerOAuthPayload( input.clientMutationId, trackerType.isLoggedIn, @@ -57,14 +65,18 @@ class TrackMutation { val tracker: TrackerType, ) - fun loginTrackerCredentials(input: LoginTrackerCredentialsInput): CompletableFuture { + fun loginTrackerCredentials( + dataFetchingEnvironment: DataFetchingEnvironment, + input: LoginTrackerCredentialsInput, + ): CompletableFuture { val tracker = requireNotNull(TrackerManager.getTracker(input.trackerId)) { "Could not find tracker" } return future { - tracker.login(input.username, input.password) - val trackerType = TrackerType(tracker) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + tracker.login(userId, input.username, input.password) + val trackerType = TrackerType(tracker, userId) LoginTrackerCredentialsPayload( input.clientMutationId, trackerType.isLoggedIn, @@ -84,17 +96,21 @@ class TrackMutation { val tracker: TrackerType, ) - fun logoutTracker(input: LogoutTrackerInput): CompletableFuture { + fun logoutTracker( + dataFetchingEnvironment: DataFetchingEnvironment, + input: LogoutTrackerInput, + ): CompletableFuture { val tracker = requireNotNull(TrackerManager.getTracker(input.trackerId)) { "Could not find tracker" } - require(tracker.isLoggedIn) { - "Cannot logout of a tracker that is not logged-in" - } return future { - tracker.logout() - val trackerType = TrackerType(tracker) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + require(tracker.isLoggedIn(userId)) { + "Cannot logout of a tracker that is not logged-in" + } + tracker.logout(userId) + val trackerType = TrackerType(tracker, userId) LogoutTrackerPayload( input.clientMutationId, trackerType.isLoggedIn, @@ -115,11 +131,16 @@ class TrackMutation { val trackRecord: TrackRecordType, ) - fun bindTrack(input: BindTrackInput): CompletableFuture { + fun bindTrack( + dataFetchingEnvironment: DataFetchingEnvironment, + input: BindTrackInput, + ): CompletableFuture { val (clientMutationId, mangaId, trackerId, remoteId) = input return future { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() Track.bind( + userId, mangaId, trackerId, remoteId, @@ -129,7 +150,9 @@ class TrackMutation { TrackRecordTable .selectAll() .where { - TrackRecordTable.mangaId eq mangaId and (TrackRecordTable.trackerId eq trackerId) + TrackRecordTable.mangaId eq mangaId and + (TrackRecordTable.trackerId eq trackerId) and + (TrackRecordTable.user eq userId) }.first() } BindTrackPayload( @@ -149,17 +172,22 @@ class TrackMutation { val trackRecord: TrackRecordType, ) - fun fetchTrack(input: FetchTrackInput): CompletableFuture { + fun fetchTrack( + dataFetchingEnvironment: DataFetchingEnvironment, + input: FetchTrackInput, + ): CompletableFuture { val (clientMutationId, recordId) = input return future { - Track.refresh(recordId) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + Track.refresh(userId, recordId) val trackRecord = transaction { TrackRecordTable .selectAll() .where { - TrackRecordTable.id eq recordId + TrackRecordTable.id eq recordId and + (TrackRecordTable.user eq userId) }.first() } FetchTrackPayload( @@ -181,17 +209,22 @@ class TrackMutation { val trackRecord: TrackRecordType?, ) - fun unbindTrack(input: UnbindTrackInput): CompletableFuture { + fun unbindTrack( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UnbindTrackInput, + ): CompletableFuture { val (clientMutationId, recordId, deleteRemoteTrack) = input return future { - Track.unbind(recordId, deleteRemoteTrack) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + Track.unbind(userId, recordId, deleteRemoteTrack) val trackRecord = transaction { TrackRecordTable .selectAll() .where { - TrackRecordTable.id eq recordId + TrackRecordTable.id eq recordId and + (TrackRecordTable.user eq userId) }.firstOrNull() } UnbindTrackPayload( @@ -211,18 +244,24 @@ class TrackMutation { val trackRecords: List, ) - fun trackProgress(input: TrackProgressInput): CompletableFuture> { + fun trackProgress( + dataFetchingEnvironment: DataFetchingEnvironment, + input: TrackProgressInput, + ): CompletableFuture> { val (clientMutationId, mangaId) = input return future { asDataFetcherResult { - Track.trackChapter(mangaId) + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + Track.trackChapter(userId, mangaId) val trackRecords = transaction { TrackRecordTable .selectAll() - .where { TrackRecordTable.mangaId eq mangaId } - .toList() + .where { + TrackRecordTable.mangaId eq mangaId and + (TrackRecordTable.user eq userId) + }.toList() } TrackProgressPayload( clientMutationId, @@ -249,9 +288,14 @@ class TrackMutation { val trackRecord: TrackRecordType?, ) - fun updateTrack(input: UpdateTrackInput): CompletableFuture = + fun updateTrack( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateTrackInput, + ): CompletableFuture = future { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() Track.update( + userId, Track.UpdateInput( input.recordId, input.status, @@ -268,7 +312,8 @@ class TrackMutation { TrackRecordTable .selectAll() .where { - TrackRecordTable.id eq input.recordId + TrackRecordTable.id eq input.recordId and + (TrackRecordTable.user eq userId) }.firstOrNull() } UpdateTrackPayload( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/UpdateMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/UpdateMutation.kt index 08a3cb0a74..581ba0b883 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/UpdateMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/UpdateMutation.kt @@ -1,17 +1,22 @@ package suwayomi.tachidesk.graphql.mutations import graphql.execution.DataFetcherResult +import graphql.schema.DataFetchingEnvironment import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeout +import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.graphql.asDataFetcherResult +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.UpdateStatus import suwayomi.tachidesk.manga.impl.Category import suwayomi.tachidesk.manga.impl.update.IUpdater import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.toDataClass +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser import uy.kohesive.injekt.injectLazy import java.util.concurrent.CompletableFuture import kotlin.time.Duration.Companion.seconds @@ -28,15 +33,19 @@ class UpdateMutation { val updateStatus: UpdateStatus, ) - fun updateLibraryManga(input: UpdateLibraryMangaInput): CompletableFuture> { - updater.addCategoriesToUpdateQueue( - Category.getCategoryList(), - clear = true, - forceAll = false, - ) - - return future { + fun updateLibraryManga( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateLibraryMangaInput, + ): CompletableFuture> = + future { asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + updater.addCategoriesToUpdateQueue( + Category.getCategoryList(userId), + clear = true, + forceAll = false, + ) + UpdateLibraryMangaPayload( input.clientMutationId, updateStatus = @@ -46,7 +55,6 @@ class UpdateMutation { ) } } - } data class UpdateCategoryMangaInput( val clientMutationId: String? = null, @@ -58,17 +66,25 @@ class UpdateMutation { val updateStatus: UpdateStatus, ) - fun updateCategoryManga(input: UpdateCategoryMangaInput): CompletableFuture> { - val categories = - transaction { - CategoryTable.selectAll().where { CategoryTable.id inList input.categories }.map { - CategoryTable.toDataClass(it) - } - } - updater.addCategoriesToUpdateQueue(categories, clear = true, forceAll = true) - - return future { + fun updateCategoryManga( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateCategoryMangaInput, + ): CompletableFuture> = + future { asDataFetcherResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + val categories = + transaction { + CategoryTable + .selectAll() + .where { + CategoryTable.id inList input.categories and (CategoryTable.user eq userId) + }.map { + CategoryTable.toDataClass(it) + } + } + updater.addCategoriesToUpdateQueue(categories, clear = true, forceAll = true) + UpdateCategoryMangaPayload( input.clientMutationId, updateStatus = @@ -78,7 +94,6 @@ class UpdateMutation { ) } } - } data class UpdateStopInput( val clientMutationId: String? = null, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/UserMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/UserMutation.kt new file mode 100644 index 0000000000..77f121d3b7 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/UserMutation.kt @@ -0,0 +1,144 @@ +package suwayomi.tachidesk.graphql.mutations + +import graphql.schema.DataFetchingEnvironment +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.lowerCase +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.sql.update +import suwayomi.tachidesk.global.impl.util.Bcrypt +import suwayomi.tachidesk.global.impl.util.Jwt +import suwayomi.tachidesk.global.model.table.UserTable +import suwayomi.tachidesk.graphql.server.getAttribute +import suwayomi.tachidesk.manga.impl.util.lang.isNotEmpty +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.user.Permissions +import suwayomi.tachidesk.server.user.UserType +import suwayomi.tachidesk.server.user.requirePermissions +import suwayomi.tachidesk.server.user.requireUser + +class UserMutation { + data class LoginInput( + val clientMutationId: String? = null, + val username: String, + val password: String, + ) + + data class LoginPayload( + val clientMutationId: String?, + val accessToken: String, + val refreshToken: String, + ) + + fun login( + dataFetchingEnvironment: DataFetchingEnvironment, + input: LoginInput, + ): LoginPayload { + if (dataFetchingEnvironment.getAttribute(Attribute.TachideskUser) !is UserType.Visitor) { + throw IllegalArgumentException("Cannot login while already logged-in") + } + val user = + transaction { + UserTable + .selectAll() + .where { UserTable.username.lowerCase() eq input.username.lowercase() } + .firstOrNull() + } + if (user != null && Bcrypt.verify(user[UserTable.password], input.password)) { + val jwt = Jwt.generateJwt(user[UserTable.id].value) + return LoginPayload( + clientMutationId = input.clientMutationId, + accessToken = jwt.accessToken, + refreshToken = jwt.refreshToken, + ) + } else { + throw Exception("Incorrect username or password.") + } + } + + data class RefreshTokenInput( + val clientMutationId: String? = null, + val refreshToken: String, + ) + + data class RefreshTokenPayload( + val clientMutationId: String?, + val accessToken: String, + val refreshToken: String, + ) + + fun refreshToken(input: RefreshTokenInput): RefreshTokenPayload { + val jwt = Jwt.refreshJwt(input.refreshToken) + + return RefreshTokenPayload( + clientMutationId = input.clientMutationId, + accessToken = jwt.accessToken, + refreshToken = jwt.refreshToken, + ) + } + + data class RegisterInput( + val clientMutationId: String? = null, + val username: String, + val password: String, + ) + + data class RegisterPayload( + val clientMutationId: String?, + ) + + fun register( + dataFetchingEnvironment: DataFetchingEnvironment, + input: RegisterInput, + ): RegisterPayload { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requirePermissions(Permissions.CREATE_USER) + + val (clientMutationId, username, password) = input + transaction { + val userExists = + UserTable + .selectAll() + .where { UserTable.username.lowerCase() eq username.lowercase() } + .isNotEmpty() + if (userExists) { + throw Exception("Username already exists") + } else { + UserTable.insert { + it[UserTable.username] = username + it[UserTable.password] = Bcrypt.encryptPassword(password) + } + } + } + + return RegisterPayload( + clientMutationId = clientMutationId, + ) + } + + data class SetPasswordInput( + val clientMutationId: String? = null, + val password: String, + ) + + data class SetPasswordPayload( + val clientMutationId: String?, + ) + + fun setPassword( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetPasswordInput, + ): SetPasswordPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + + val (clientMutationId, password) = input + transaction { + UserTable.update({ UserTable.id eq userId }) { + it[UserTable.password] = Bcrypt.encryptPassword(password) + } + } + + return SetPasswordPayload( + clientMutationId = clientMutationId, + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/BackupQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/BackupQuery.kt index ba286c009f..3e4ef5da64 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/BackupQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/BackupQuery.kt @@ -1,10 +1,14 @@ package suwayomi.tachidesk.graphql.queries +import graphql.schema.DataFetchingEnvironment import io.javalin.http.UploadedFile +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.BackupRestoreStatus import suwayomi.tachidesk.graphql.types.toStatus import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.user.requireUser class BackupQuery { data class ValidateBackupInput( @@ -25,13 +29,23 @@ class BackupQuery { val missingTrackers: List, ) - fun validateBackup(input: ValidateBackupInput): ValidateBackupResult { - val result = ProtoBackupValidator.validate(input.backup.content()) + fun validateBackup( + dataFetchingEnvironment: DataFetchingEnvironment, + input: ValidateBackupInput, + ): ValidateBackupResult { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + val result = ProtoBackupValidator.validate(userId, input.backup.content()) return ValidateBackupResult( result.missingSourceIds.map { ValidateBackupSource(it.first, it.second) }, result.missingTrackers.map { ValidateBackupTracker(it) }, ) } - fun restoreStatus(id: String): BackupRestoreStatus? = ProtoBackupImport.getRestoreState(id)?.toStatus() + fun restoreStatus( + dataFetchingEnvironment: DataFetchingEnvironment, + id: String, + ): BackupRestoreStatus? { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return ProtoBackupImport.getRestoreState(id)?.toStatus() + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/CategoryQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/CategoryQuery.kt index 46526bf4aa..343e73b8b8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/CategoryQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/CategoryQuery.kt @@ -27,6 +27,7 @@ import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString import suwayomi.tachidesk.graphql.queries.filter.applyOps +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.server.primitives.Cursor import suwayomi.tachidesk.graphql.server.primitives.Order import suwayomi.tachidesk.graphql.server.primitives.OrderBy @@ -39,13 +40,18 @@ import suwayomi.tachidesk.graphql.server.primitives.maybeSwap import suwayomi.tachidesk.graphql.types.CategoryNodeList import suwayomi.tachidesk.graphql.types.CategoryType import suwayomi.tachidesk.manga.model.table.CategoryTable +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture class CategoryQuery { fun category( dataFetchingEnvironment: DataFetchingEnvironment, id: Int, - ): CompletableFuture = dataFetchingEnvironment.getValueFromDataLoader("CategoryDataLoader", id) + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return dataFetchingEnvironment.getValueFromDataLoader("CategoryDataLoader", id) + } enum class CategoryOrderBy( override val column: Column<*>, @@ -121,6 +127,7 @@ class CategoryQuery { } fun categories( + dataFetchingEnvironment: DataFetchingEnvironment, condition: CategoryCondition? = null, filter: CategoryFilter? = null, @GraphQLDeprecated( @@ -140,9 +147,10 @@ class CategoryQuery { last: Int? = null, offset: Int? = null, ): CategoryNodeList { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val queryResults = transaction { - val res = CategoryTable.selectAll() + val res = CategoryTable.selectAll().where { CategoryTable.user eq userId } res.applyOps(condition, filter) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt index 9f5776acb9..f4c579fa45 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt @@ -13,9 +13,11 @@ import graphql.schema.DataFetchingEnvironment import org.jetbrains.exposed.sql.Column import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater import org.jetbrains.exposed.sql.SqlExpressionBuilder.less import org.jetbrains.exposed.sql.andWhere +import org.jetbrains.exposed.sql.innerJoin import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter @@ -30,6 +32,7 @@ import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString import suwayomi.tachidesk.graphql.queries.filter.applyOps +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.server.primitives.Cursor import suwayomi.tachidesk.graphql.server.primitives.Order import suwayomi.tachidesk.graphql.server.primitives.OrderBy @@ -42,7 +45,12 @@ import suwayomi.tachidesk.graphql.server.primitives.maybeSwap import suwayomi.tachidesk.graphql.types.ChapterNodeList import suwayomi.tachidesk.graphql.types.ChapterType import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ChapterUserTable import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable +import suwayomi.tachidesk.manga.model.table.getWithUserData +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture /** @@ -54,7 +62,10 @@ class ChapterQuery { fun chapter( dataFetchingEnvironment: DataFetchingEnvironment, id: Int, - ): CompletableFuture = dataFetchingEnvironment.getValueFromDataLoader("ChapterDataLoader", id) + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return dataFetchingEnvironment.getValueFromDataLoader("ChapterDataLoader", id) + } enum class ChapterOrderBy( override val column: Column<*>, @@ -64,7 +75,7 @@ class ChapterQuery { NAME(ChapterTable.name), UPLOAD_DATE(ChapterTable.date_upload), CHAPTER_NUMBER(ChapterTable.chapter_number), - LAST_READ_AT(ChapterTable.lastReadAt), + LAST_READ_AT(ChapterUserTable.lastReadAt), FETCHED_AT(ChapterTable.fetchedAt), ; @@ -75,7 +86,7 @@ class ChapterQuery { NAME -> greaterNotUnique(ChapterTable.name, ChapterTable.id, cursor, String::toString) UPLOAD_DATE -> greaterNotUnique(ChapterTable.date_upload, ChapterTable.id, cursor, String::toLong) CHAPTER_NUMBER -> greaterNotUnique(ChapterTable.chapter_number, ChapterTable.id, cursor, String::toFloat) - LAST_READ_AT -> greaterNotUnique(ChapterTable.lastReadAt, ChapterTable.id, cursor, String::toLong) + LAST_READ_AT -> greaterNotUnique(ChapterUserTable.lastReadAt, ChapterTable.id, cursor, String::toLong) FETCHED_AT -> greaterNotUnique(ChapterTable.fetchedAt, ChapterTable.id, cursor, String::toLong) } @@ -86,7 +97,7 @@ class ChapterQuery { NAME -> lessNotUnique(ChapterTable.name, ChapterTable.id, cursor, String::toString) UPLOAD_DATE -> lessNotUnique(ChapterTable.date_upload, ChapterTable.id, cursor, String::toLong) CHAPTER_NUMBER -> lessNotUnique(ChapterTable.chapter_number, ChapterTable.id, cursor, String::toFloat) - LAST_READ_AT -> lessNotUnique(ChapterTable.lastReadAt, ChapterTable.id, cursor, String::toLong) + LAST_READ_AT -> lessNotUnique(ChapterUserTable.lastReadAt, ChapterTable.id, cursor, String::toLong) FETCHED_AT -> lessNotUnique(ChapterTable.fetchedAt, ChapterTable.id, cursor, String::toLong) } @@ -137,10 +148,10 @@ class ChapterQuery { opAnd.eq(chapterNumber, ChapterTable.chapter_number) opAnd.eq(scanlator, ChapterTable.scanlator) opAnd.eq(mangaId, ChapterTable.manga) - opAnd.eq(isRead, ChapterTable.isRead) - opAnd.eq(isBookmarked, ChapterTable.isBookmarked) - opAnd.eq(lastPageRead, ChapterTable.lastPageRead) - opAnd.eq(lastReadAt, ChapterTable.lastReadAt) + opAnd.eq(isRead, ChapterUserTable.isRead) + opAnd.eq(isBookmarked, ChapterUserTable.isBookmarked) + opAnd.eq(lastPageRead, ChapterUserTable.lastPageRead) + opAnd.eq(lastReadAt, ChapterUserTable.lastReadAt) opAnd.eq(sourceOrder, ChapterTable.sourceOrder) opAnd.eq(realUrl, ChapterTable.realUrl) opAnd.eq(fetchedAt, ChapterTable.fetchedAt) @@ -182,10 +193,10 @@ class ChapterQuery { andFilterWithCompare(ChapterTable.chapter_number, chapterNumber), andFilterWithCompareString(ChapterTable.scanlator, scanlator), andFilterWithCompareEntity(ChapterTable.manga, mangaId), - andFilterWithCompare(ChapterTable.isRead, isRead), - andFilterWithCompare(ChapterTable.isBookmarked, isBookmarked), - andFilterWithCompare(ChapterTable.lastPageRead, lastPageRead), - andFilterWithCompare(ChapterTable.lastReadAt, lastReadAt), + andFilterWithCompare(ChapterUserTable.isRead, isRead), + andFilterWithCompare(ChapterUserTable.isBookmarked, isBookmarked), + andFilterWithCompare(ChapterUserTable.lastPageRead, lastPageRead), + andFilterWithCompare(ChapterUserTable.lastReadAt, lastReadAt), andFilterWithCompare(ChapterTable.sourceOrder, sourceOrder), andFilterWithCompareString(ChapterTable.realUrl, realUrl), andFilterWithCompare(ChapterTable.fetchedAt, fetchedAt), @@ -193,10 +204,11 @@ class ChapterQuery { andFilterWithCompare(ChapterTable.pageCount, pageCount), ) - fun getLibraryOp() = andFilterWithCompare(MangaTable.inLibrary, inLibrary) + fun getLibraryOp() = andFilterWithCompare(MangaUserTable.inLibrary, inLibrary) } fun chapters( + dataFetchingEnvironment: DataFetchingEnvironment, condition: ChapterCondition? = null, filter: ChapterFilter? = null, @GraphQLDeprecated( @@ -216,14 +228,15 @@ class ChapterQuery { last: Int? = null, offset: Int? = null, ): ChapterNodeList { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val queryResults = transaction { - val res = ChapterTable.selectAll() + val res = ChapterTable.getWithUserData(userId).selectAll() val libraryOp = filter?.getLibraryOp() if (libraryOp != null) { res.adjustColumnSet { - innerJoin(MangaTable) + innerJoin(MangaTable.getWithUserData(userId)) } res.andWhere { libraryOp } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt index 10f855ee19..800244244f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt @@ -28,6 +28,7 @@ import suwayomi.tachidesk.graphql.queries.filter.StringFilter import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString import suwayomi.tachidesk.graphql.queries.filter.applyOps +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.server.primitives.Cursor import suwayomi.tachidesk.graphql.server.primitives.Order import suwayomi.tachidesk.graphql.server.primitives.OrderBy @@ -40,13 +41,18 @@ import suwayomi.tachidesk.graphql.server.primitives.maybeSwap import suwayomi.tachidesk.graphql.types.ExtensionNodeList import suwayomi.tachidesk.graphql.types.ExtensionType import suwayomi.tachidesk.manga.model.table.ExtensionTable +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture class ExtensionQuery { fun extension( dataFetchingEnvironment: DataFetchingEnvironment, pkgName: String, - ): CompletableFuture = dataFetchingEnvironment.getValueFromDataLoader("ExtensionDataLoader", pkgName) + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return dataFetchingEnvironment.getValueFromDataLoader("ExtensionDataLoader", pkgName) + } enum class ExtensionOrderBy( override val column: Column<*>, @@ -153,6 +159,7 @@ class ExtensionQuery { } fun extensions( + dataFetchingEnvironment: DataFetchingEnvironment, condition: ExtensionCondition? = null, filter: ExtensionFilter? = null, @GraphQLDeprecated( @@ -172,6 +179,7 @@ class ExtensionQuery { last: Int? = null, offset: Int? = null, ): ExtensionNodeList { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val queryResults = transaction { val res = ExtensionTable.selectAll() diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt index 9aa68d07c9..a0011cd761 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt @@ -1,12 +1,16 @@ package suwayomi.tachidesk.graphql.queries +import graphql.schema.DataFetchingEnvironment import suwayomi.tachidesk.global.impl.AppUpdate +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.AboutWebUI import suwayomi.tachidesk.graphql.types.WebUIUpdateCheck import suwayomi.tachidesk.graphql.types.WebUIUpdateStatus +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future import suwayomi.tachidesk.server.generated.BuildConfig import suwayomi.tachidesk.server.serverConfig +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.WebInterfaceManager import suwayomi.tachidesk.server.util.WebUIFlavor import java.util.concurrent.CompletableFuture @@ -22,8 +26,10 @@ class InfoQuery { val discord: String, ) - fun aboutServer(): AboutServerPayload = - AboutServerPayload( + fun aboutServer(dataFetchingEnvironment: DataFetchingEnvironment): AboutServerPayload { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + + return AboutServerPayload( BuildConfig.NAME, BuildConfig.VERSION, BuildConfig.REVISION, @@ -32,6 +38,7 @@ class InfoQuery { BuildConfig.GITHUB, BuildConfig.DISCORD, ) + } data class CheckForServerUpdatesPayload( /** [channel] mirrors [suwayomi.tachidesk.server.BuildConfig.BUILD_TYPE] */ @@ -40,8 +47,9 @@ class InfoQuery { val url: String, ) - fun checkForServerUpdates(): CompletableFuture> = + fun checkForServerUpdates(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture> = future { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() AppUpdate.checkUpdate().map { CheckForServerUpdatesPayload( channel = it.channel, @@ -56,8 +64,9 @@ class InfoQuery { WebInterfaceManager.getAboutInfo() } - fun checkForWebUIUpdate(): CompletableFuture = + fun checkForWebUIUpdate(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture = future { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable(WebUIFlavor.current, raiseError = true) WebUIUpdateCheck( channel = serverConfig.webUIChannel.value, @@ -66,5 +75,9 @@ class InfoQuery { ) } - fun getWebUIUpdateStatus(): WebUIUpdateStatus = WebInterfaceManager.status.value + fun getWebUIUpdateStatus(dataFetchingEnvironment: DataFetchingEnvironment): WebUIUpdateStatus { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + + return WebInterfaceManager.status.value + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MangaQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MangaQuery.kt index d823f90db4..44699d3cb9 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MangaQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MangaQuery.kt @@ -28,6 +28,7 @@ import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString import suwayomi.tachidesk.graphql.queries.filter.applyOps +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.server.primitives.Cursor import suwayomi.tachidesk.graphql.server.primitives.Order import suwayomi.tachidesk.graphql.server.primitives.OrderBy @@ -42,20 +43,27 @@ import suwayomi.tachidesk.graphql.types.MangaType import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable +import suwayomi.tachidesk.manga.model.table.getWithUserData +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture class MangaQuery { fun manga( dataFetchingEnvironment: DataFetchingEnvironment, id: Int, - ): CompletableFuture = dataFetchingEnvironment.getValueFromDataLoader("MangaDataLoader", id) + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return dataFetchingEnvironment.getValueFromDataLoader("MangaDataLoader", id) + } enum class MangaOrderBy( override val column: Column<*>, ) : OrderBy { ID(MangaTable.id), TITLE(MangaTable.title), - IN_LIBRARY_AT(MangaTable.inLibraryAt), + IN_LIBRARY_AT(MangaUserTable.inLibraryAt), LAST_FETCHED_AT(MangaTable.lastFetchedAt), ; @@ -63,7 +71,7 @@ class MangaQuery { when (this) { ID -> MangaTable.id greater cursor.value.toInt() TITLE -> greaterNotUnique(MangaTable.title, MangaTable.id, cursor, String::toString) - IN_LIBRARY_AT -> greaterNotUnique(MangaTable.inLibraryAt, MangaTable.id, cursor, String::toLong) + IN_LIBRARY_AT -> greaterNotUnique(MangaUserTable.inLibraryAt, MangaTable.id, cursor, String::toLong) LAST_FETCHED_AT -> greaterNotUnique(MangaTable.lastFetchedAt, MangaTable.id, cursor, String::toLong) } @@ -71,7 +79,7 @@ class MangaQuery { when (this) { ID -> MangaTable.id less cursor.value.toInt() TITLE -> lessNotUnique(MangaTable.title, MangaTable.id, cursor, String::toString) - IN_LIBRARY_AT -> lessNotUnique(MangaTable.inLibraryAt, MangaTable.id, cursor, String::toLong) + IN_LIBRARY_AT -> lessNotUnique(MangaUserTable.inLibraryAt, MangaTable.id, cursor, String::toLong) LAST_FETCHED_AT -> lessNotUnique(MangaTable.lastFetchedAt, MangaTable.id, cursor, String::toLong) } @@ -124,8 +132,8 @@ class MangaQuery { opAnd.eq(description, MangaTable.description) opAnd.andWhereAll(genre) { MangaTable.genre like "%$it%" } opAnd.eq(status?.value, MangaTable.status) - opAnd.eq(inLibrary, MangaTable.inLibrary) - opAnd.eq(inLibraryAt, MangaTable.inLibraryAt) + opAnd.eq(inLibrary, MangaUserTable.inLibrary) + opAnd.eq(inLibraryAt, MangaUserTable.inLibraryAt) opAnd.eq(realUrl, MangaTable.realUrl) opAnd.eq(lastFetchedAt, MangaTable.lastFetchedAt) opAnd.eq(chaptersLastFetchedAt, MangaTable.chaptersLastFetchedAt) @@ -206,8 +214,8 @@ class MangaQuery { andFilterWithCompareString(MangaTable.description, description), andFilterWithCompareString(MangaTable.genre, genre), andFilterWithCompare(MangaTable.status, status?.asIntFilter()), - andFilterWithCompare(MangaTable.inLibrary, inLibrary), - andFilterWithCompare(MangaTable.inLibraryAt, inLibraryAt), + andFilterWithCompare(MangaUserTable.inLibrary, inLibrary), + andFilterWithCompare(MangaUserTable.inLibraryAt, inLibraryAt), andFilterWithCompareString(MangaTable.realUrl, realUrl), andFilterWithCompare(MangaTable.lastFetchedAt, lastFetchedAt), andFilterWithCompare(MangaTable.chaptersLastFetchedAt, chaptersLastFetchedAt), @@ -216,6 +224,7 @@ class MangaQuery { } fun mangas( + dataFetchingEnvironment: DataFetchingEnvironment, condition: MangaCondition? = null, filter: MangaFilter? = null, @GraphQLDeprecated( @@ -235,12 +244,15 @@ class MangaQuery { last: Int? = null, offset: Int? = null, ): MangaNodeList { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val queryResults = transaction { val res = MangaTable + .getWithUserData(userId) .leftJoin(CategoryMangaTable) .select(MangaTable.columns) + .where { CategoryMangaTable.user eq userId } .withDistinctOn(MangaTable.id) res.applyOps(condition, filter) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MetaQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MetaQuery.kt index a00b3a5a19..459cf4813c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MetaQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MetaQuery.kt @@ -24,6 +24,7 @@ import suwayomi.tachidesk.graphql.queries.filter.OpAnd import suwayomi.tachidesk.graphql.queries.filter.StringFilter import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString import suwayomi.tachidesk.graphql.queries.filter.applyOps +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.server.primitives.Cursor import suwayomi.tachidesk.graphql.server.primitives.Order import suwayomi.tachidesk.graphql.server.primitives.OrderBy @@ -35,13 +36,18 @@ import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique import suwayomi.tachidesk.graphql.server.primitives.maybeSwap import suwayomi.tachidesk.graphql.types.GlobalMetaNodeList import suwayomi.tachidesk.graphql.types.GlobalMetaType +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture class MetaQuery { fun meta( dataFetchingEnvironment: DataFetchingEnvironment, key: String, - ): CompletableFuture = dataFetchingEnvironment.getValueFromDataLoader("GlobalMetaDataLoader", key) + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return dataFetchingEnvironment.getValueFromDataLoader("GlobalMetaDataLoader", key) + } enum class MetaOrderBy( override val column: Column<*>, @@ -105,6 +111,7 @@ class MetaQuery { } fun metas( + dataFetchingEnvironment: DataFetchingEnvironment, condition: MetaCondition? = null, filter: MetaFilter? = null, @GraphQLDeprecated( @@ -124,9 +131,10 @@ class MetaQuery { last: Int? = null, offset: Int? = null, ): GlobalMetaNodeList { + val userId = dataFetchingEnvironment.graphQlContext.getAttribute(Attribute.TachideskUser).requireUser() val queryResults = transaction { - val res = GlobalMetaTable.selectAll() + val res = GlobalMetaTable.selectAll().where { GlobalMetaTable.user eq userId } res.applyOps(condition, filter) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SettingsQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SettingsQuery.kt index 6d9481d0c5..9574eee104 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SettingsQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SettingsQuery.kt @@ -1,7 +1,14 @@ package suwayomi.tachidesk.graphql.queries +import graphql.schema.DataFetchingEnvironment +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.SettingsType +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.user.requireUser class SettingsQuery { - fun settings(): SettingsType = SettingsType() + fun settings(dataFetchingEnvironment: DataFetchingEnvironment): SettingsType { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return SettingsType() + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SourceQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SourceQuery.kt index 8c0667fcb3..8a3ce6cfe4 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SourceQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SourceQuery.kt @@ -27,6 +27,7 @@ import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString import suwayomi.tachidesk.graphql.queries.filter.applyOps +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.server.primitives.Cursor import suwayomi.tachidesk.graphql.server.primitives.Order import suwayomi.tachidesk.graphql.server.primitives.OrderBy @@ -39,13 +40,19 @@ import suwayomi.tachidesk.graphql.server.primitives.maybeSwap import suwayomi.tachidesk.graphql.types.SourceNodeList import suwayomi.tachidesk.graphql.types.SourceType import suwayomi.tachidesk.manga.model.table.SourceTable +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.JavalinSetup.getAttribute +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture class SourceQuery { fun source( dataFetchingEnvironment: DataFetchingEnvironment, id: Long, - ): CompletableFuture = dataFetchingEnvironment.getValueFromDataLoader("SourceDataLoader", id) + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return dataFetchingEnvironment.getValueFromDataLoader("SourceDataLoader", id) + } enum class SourceOrderBy( override val column: Column<*>, @@ -121,6 +128,7 @@ class SourceQuery { } fun sources( + dataFetchingEnvironment: DataFetchingEnvironment, condition: SourceCondition? = null, filter: SourceFilter? = null, @GraphQLDeprecated( @@ -140,6 +148,7 @@ class SourceQuery { last: Int? = null, offset: Int? = null, ): SourceNodeList { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (queryResults, resultsAsType) = transaction { val res = SourceTable.selectAll() diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/TrackQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/TrackQuery.kt index 22ebec1b41..5d168624d5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/TrackQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/TrackQuery.kt @@ -22,6 +22,7 @@ import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString import suwayomi.tachidesk.graphql.queries.filter.applyOps +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.server.primitives.Cursor import suwayomi.tachidesk.graphql.server.primitives.Order import suwayomi.tachidesk.graphql.server.primitives.OrderBy @@ -39,7 +40,9 @@ import suwayomi.tachidesk.graphql.types.TrackerType import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager import suwayomi.tachidesk.manga.model.table.TrackRecordTable import suwayomi.tachidesk.manga.model.table.insertAll +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser import java.util.concurrent.CompletableFuture class TrackQuery { @@ -115,6 +118,7 @@ class TrackQuery { ) fun trackers( + dataFetchingEnvironment: DataFetchingEnvironment, condition: TrackerCondition? = null, @GraphQLDeprecated( "Replaced with order", @@ -135,7 +139,8 @@ class TrackQuery { ): TrackerNodeList { val (queryResults, resultsAsType) = run { - var res = TrackerManager.services.map { TrackerType(it) } + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + var res = TrackerManager.services.map { TrackerType(it, userId) } if (condition != null) { res = @@ -381,6 +386,7 @@ class TrackQuery { } fun trackRecords( + dataFetchingEnvironment: DataFetchingEnvironment, condition: TrackRecordCondition? = null, filter: TrackRecordFilter? = null, @GraphQLDeprecated( @@ -402,7 +408,8 @@ class TrackQuery { ): TrackRecordNodeList { val queryResults = transaction { - val res = TrackRecordTable.selectAll() + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + val res = TrackRecordTable.selectAll().where { TrackRecordTable.user eq userId } res.applyOps(condition, filter) @@ -482,17 +489,21 @@ class TrackQuery { val trackSearches: List, ) - fun searchTracker(input: SearchTrackerInput): CompletableFuture = + fun searchTracker( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SearchTrackerInput, + ): CompletableFuture = future { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val tracker = requireNotNull(TrackerManager.getTracker(input.trackerId)) { "Tracker not found" } - require(tracker.isLoggedIn) { + require(tracker.isLoggedIn(userId)) { "Tracker needs to be logged-in to search" } SearchTrackerPayload( - tracker.search(input.query).insertAll().map { + tracker.search(userId, input.query).insertAll().map { TrackSearchType(it) }, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/UpdateQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/UpdateQuery.kt index daa4cea64e..f8a5542852 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/UpdateQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/UpdateQuery.kt @@ -1,20 +1,30 @@ package suwayomi.tachidesk.graphql.queries +import graphql.schema.DataFetchingEnvironment import kotlinx.coroutines.flow.first +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.UpdateStatus import suwayomi.tachidesk.manga.impl.update.IUpdater +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser import uy.kohesive.injekt.injectLazy import java.util.concurrent.CompletableFuture class UpdateQuery { private val updater: IUpdater by injectLazy() - fun updateStatus(): CompletableFuture = future { UpdateStatus(updater.status.first()) } + fun updateStatus(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return future { UpdateStatus(updater.status.first()) } + } data class LastUpdateTimestampPayload( val timestamp: Long, ) - fun lastUpdateTimestamp(): LastUpdateTimestampPayload = LastUpdateTimestampPayload(updater.getLastUpdateTimestamp()) + fun lastUpdateTimestamp(dataFetchingEnvironment: DataFetchingEnvironment): LastUpdateTimestampPayload { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return LastUpdateTimestampPayload(updater.getLastUpdateTimestamp()) + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataFetcherExceptionHandler.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataFetcherExceptionHandler.kt new file mode 100644 index 0000000000..79fe73b224 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataFetcherExceptionHandler.kt @@ -0,0 +1,35 @@ +package suwayomi.tachidesk.graphql.server + +import com.expediagroup.graphql.server.extensions.getFromContext +import graphql.ExceptionWhileDataFetching +import graphql.execution.DataFetcherExceptionHandlerParameters +import graphql.execution.DataFetcherExceptionHandlerResult +import graphql.execution.SimpleDataFetcherExceptionHandler +import io.javalin.http.Context +import io.javalin.http.HttpStatus +import suwayomi.tachidesk.server.user.ForbiddenException +import suwayomi.tachidesk.server.user.UnauthorizedException +import java.util.concurrent.CompletableFuture + +class TachideskDataFetcherExceptionHandler : SimpleDataFetcherExceptionHandler() { + override fun handleException( + handlerParameters: DataFetcherExceptionHandlerParameters, + ): CompletableFuture { + val exception = handlerParameters.exception + if (exception is UnauthorizedException) { + val error = ExceptionWhileDataFetching(handlerParameters.path, exception, handlerParameters.sourceLocation) + logException(error, exception) + // Set the HTTP status code to 401 + handlerParameters.dataFetchingEnvironment.getFromContext()?.status(HttpStatus.UNAUTHORIZED) + return CompletableFuture.completedFuture(DataFetcherExceptionHandlerResult.newResult().error(error).build()) + } + if (exception is ForbiddenException) { + val error = ExceptionWhileDataFetching(handlerParameters.path, exception, handlerParameters.sourceLocation) + logException(error, exception) + // Set the HTTP status code to 403 + handlerParameters.dataFetchingEnvironment.getFromContext()?.status(HttpStatus.FORBIDDEN) + return CompletableFuture.completedFuture(DataFetcherExceptionHandlerResult.newResult().error(error).build()) + } + return super.handleException(handlerParameters) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLContextFactory.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLContextFactory.kt index bb7c2d91f7..999400fd21 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLContextFactory.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLContextFactory.kt @@ -9,34 +9,45 @@ package suwayomi.tachidesk.graphql.server import com.expediagroup.graphql.server.execution.GraphQLContextFactory import graphql.GraphQLContext +import graphql.schema.DataFetchingEnvironment import io.javalin.http.Context import io.javalin.websocket.WsContext +import org.dataloader.BatchLoaderEnvironment +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.JavalinSetup.getAttribute /** * Custom logic for how Suwayomi-Server should create its context given the [Context] */ class TachideskGraphQLContextFactory : GraphQLContextFactory { - override suspend fun generateContext(request: Context): GraphQLContext = emptyMap().toGraphQLContext() -// mutableMapOf( -// "user" to User( -// email = "fake@site.com", -// firstName = "Someone", -// lastName = "You Don't know", -// universityId = 4 -// ) -// ).also { map -> -// request.headers["my-custom-header"]?.let { customHeader -> -// map["customHeader"] = customHeader -// } -// }.toGraphQLContext() - - fun generateContextMap( - @Suppress("UNUSED_PARAMETER") request: WsContext, - ): Map<*, Any> = emptyMap() + override suspend fun generateContext(request: Context): GraphQLContext = + mapOf( + Context::class to request, + request.getPair(Attribute.TachideskUser), + ).toGraphQLContext() + + fun generateContextMap(request: WsContext): Map<*, Any> = + mapOf( + Context::class to request, + request.getPair(Attribute.TachideskUser), + ) + + private fun Context.getPair(attribute: Attribute) = attribute to getAttribute(attribute) + + private fun WsContext.getPair(attribute: Attribute) = attribute to getAttribute(attribute) } /** * Create a [GraphQLContext] from [this] map * @return a new [GraphQLContext] */ -fun Map<*, Any?>.toGraphQLContext(): graphql.GraphQLContext = graphql.GraphQLContext.of(this) +fun Map<*, Any?>.toGraphQLContext(): GraphQLContext = GraphQLContext.of(this) + +fun GraphQLContext.getAttribute(attribute: Attribute): T = get(attribute) + +fun DataFetchingEnvironment.getAttribute(attribute: Attribute): T = graphQlContext.get(attribute) + +val BatchLoaderEnvironment.graphQlContext: GraphQLContext + get() = keyContextsList.filterIsInstance().first() + +fun BatchLoaderEnvironment.getAttribute(attribute: Attribute): T = graphQlContext.getAttribute(attribute) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt index ae2ce14686..fe020ff939 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt @@ -26,6 +26,7 @@ import suwayomi.tachidesk.graphql.mutations.SettingsMutation import suwayomi.tachidesk.graphql.mutations.SourceMutation import suwayomi.tachidesk.graphql.mutations.TrackMutation import suwayomi.tachidesk.graphql.mutations.UpdateMutation +import suwayomi.tachidesk.graphql.mutations.UserMutation import suwayomi.tachidesk.graphql.queries.BackupQuery import suwayomi.tachidesk.graphql.queries.CategoryQuery import suwayomi.tachidesk.graphql.queries.ChapterQuery @@ -96,6 +97,7 @@ val schema = TopLevelObject(SourceMutation()), TopLevelObject(TrackMutation()), TopLevelObject(UpdateMutation()), + TopLevelObject(UserMutation()), ), subscriptions = listOf( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLServer.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLServer.kt index 6fd65c1280..4db789be05 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLServer.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLServer.kt @@ -49,6 +49,7 @@ class TachideskGraphQLServer( .newGraphQL(schema) .subscriptionExecutionStrategy(FlowSubscriptionExecutionStrategy()) .mutationExecutionStrategy(AsyncExecutionStrategy()) + .defaultDataFetcherExceptionHandler(TachideskDataFetcherExceptionHandler()) .build() fun create(): TachideskGraphQLServer { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt index e12d4b7c42..8dc7185476 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt @@ -17,6 +17,7 @@ import suwayomi.tachidesk.graphql.server.primitives.NodeList import suwayomi.tachidesk.graphql.server.primitives.PageInfo import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ChapterUserTable import java.util.concurrent.CompletableFuture class ChapterType( @@ -59,10 +60,10 @@ class ChapterType( row[ChapterTable.chapter_number], row[ChapterTable.scanlator], row[ChapterTable.manga].value, - row[ChapterTable.isRead], - row[ChapterTable.isBookmarked], - row[ChapterTable.lastPageRead], - row[ChapterTable.lastReadAt], + row.getOrNull(ChapterUserTable.isRead) ?: false, + row.getOrNull(ChapterUserTable.isBookmarked) ?: false, + row.getOrNull(ChapterUserTable.lastPageRead) ?: 0, + row.getOrNull(ChapterUserTable.lastReadAt) ?: 0, row[ChapterTable.sourceOrder], row[ChapterTable.realUrl], row[ChapterTable.fetchedAt], diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt index b96c9b740a..74fb47d80e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt @@ -22,6 +22,7 @@ import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.toGenreList import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable import java.time.Instant import java.util.concurrent.CompletableFuture @@ -98,8 +99,8 @@ class MangaType( row[MangaTable.description], row[MangaTable.genre].toGenreList(), MangaStatus.valueOf(row[MangaTable.status]), - row[MangaTable.inLibrary], - row[MangaTable.inLibraryAt], + row.getOrNull(MangaUserTable.inLibrary) ?: false, + row.getOrNull(MangaUserTable.inLibraryAt) ?: 0, UpdateStrategy.valueOf(row[MangaTable.updateStrategy]), row[MangaTable.realUrl], row[MangaTable.lastFetchedAt], diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt index fa58aac5f5..2074890346 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt @@ -68,6 +68,7 @@ interface Settings : Node { val basicAuthEnabled: Boolean? val basicAuthUsername: String? val basicAuthPassword: String? + val multiUser: Boolean? // misc val debugLogsEnabled: Boolean? @@ -140,6 +141,7 @@ data class PartialSettingsType( override val basicAuthEnabled: Boolean?, override val basicAuthUsername: String?, override val basicAuthPassword: String?, + override val multiUser: Boolean?, // misc override val debugLogsEnabled: Boolean?, @GraphQLDeprecated("Removed - does not do anything") @@ -207,6 +209,7 @@ class SettingsType( override val basicAuthEnabled: Boolean, override val basicAuthUsername: String, override val basicAuthPassword: String, + override val multiUser: Boolean?, // misc override val debugLogsEnabled: Boolean, @GraphQLDeprecated("Removed - does not do anything") @@ -269,6 +272,7 @@ class SettingsType( config.basicAuthEnabled.value, config.basicAuthUsername.value, config.basicAuthPassword.value, + config.multiUser.value, // misc config.debugLogsEnabled.value, false, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt index 5dbdde0996..3cfc157e05 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt @@ -22,8 +22,8 @@ class TrackerType( val authUrl: String?, val supportsTrackDeletion: Boolean?, ) : Node { - constructor(tracker: Tracker) : this( - tracker.isLoggedIn, + constructor(tracker: Tracker, userId: Int) : this( + tracker.isLoggedIn(userId), tracker, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/BackupController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/BackupController.kt index 1713e597d9..a7e42a702f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/BackupController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/BackupController.kt @@ -6,7 +6,10 @@ import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.JavalinSetup.getAttribute +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.withOperation @@ -28,9 +31,10 @@ object BackupController { } }, behaviorOf = { ctx -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { - ProtoBackupImport.restoreLegacy(ctx.bodyInputStream()) + ProtoBackupImport.restoreLegacy(userId, ctx.bodyInputStream()) }.thenApply { ctx.json(it) } @@ -56,9 +60,10 @@ object BackupController { }, behaviorOf = { ctx -> // TODO: rewrite this with ctx.uploadedFiles(), don't call the multipart field "backup.proto.gz" + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { - ProtoBackupImport.restoreLegacy(ctx.uploadedFile("backup.proto.gz")!!.content()) + ProtoBackupImport.restoreLegacy(userId, ctx.uploadedFile("backup.proto.gz")!!.content()) }.thenApply { ctx.json(it) } @@ -80,10 +85,12 @@ object BackupController { } }, behaviorOf = { ctx -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.contentType("application/octet-stream") ctx.future { future { ProtoBackupExport.createBackup( + userId, BackupFlags( includeManga = true, includeCategories = true, @@ -110,12 +117,14 @@ object BackupController { } }, behaviorOf = { ctx -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.contentType("application/octet-stream") ctx.header("Content-Disposition", """attachment; filename="${Backup.getFilename()}"""") ctx.future { future { ProtoBackupExport.createBackup( + userId, BackupFlags( includeManga = true, includeCategories = true, @@ -142,9 +151,10 @@ object BackupController { } }, behaviorOf = { ctx -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { - ProtoBackupValidator.validate(ctx.bodyInputStream()) + ProtoBackupValidator.validate(userId, ctx.bodyInputStream()) }.thenApply { ctx.json(it) } @@ -173,9 +183,10 @@ object BackupController { } }, behaviorOf = { ctx -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { - ProtoBackupValidator.validate(ctx.uploadedFile("backup.proto.gz")!!.content()) + ProtoBackupValidator.validate(userId, ctx.uploadedFile("backup.proto.gz")!!.content()) }.thenApply { ctx.json(it) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/CategoryController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/CategoryController.kt index 0ea20c1f91..a917bbb451 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/CategoryController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/CategoryController.kt @@ -12,6 +12,9 @@ import suwayomi.tachidesk.manga.impl.Category import suwayomi.tachidesk.manga.impl.CategoryManga import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass +import suwayomi.tachidesk.server.JavalinSetup.Attribute +import suwayomi.tachidesk.server.JavalinSetup.getAttribute +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.formParam import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.pathParam @@ -28,7 +31,8 @@ object CategoryController { } }, behaviorOf = { ctx -> - ctx.json(Category.getCategoryList()) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + ctx.json(Category.getCategoryList(userId)) }, withResults = { json>(HttpStatus.OK) @@ -46,7 +50,8 @@ object CategoryController { } }, behaviorOf = { ctx, name -> - if (Category.createCategory(name) != -1) { + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + if (Category.createCategory(userId, name) != -1) { ctx.status(200) } else { ctx.status(HttpStatus.BAD_REQUEST) @@ -73,7 +78,8 @@ object CategoryController { } }, behaviorOf = { ctx, categoryId, name, isDefault, includeInUpdate, includeInDownload -> - Category.updateCategory(categoryId, name, isDefault, includeInUpdate, includeInDownload) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + Category.updateCategory(userId, categoryId, name, isDefault, includeInUpdate, includeInDownload) ctx.status(200) }, withResults = { @@ -92,7 +98,8 @@ object CategoryController { } }, behaviorOf = { ctx, categoryId -> - Category.removeCategory(categoryId) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + Category.removeCategory(userId, categoryId) ctx.status(200) }, withResults = { @@ -111,7 +118,8 @@ object CategoryController { } }, behaviorOf = { ctx, categoryId -> - ctx.json(CategoryManga.getCategoryMangaList(categoryId)) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + ctx.json(CategoryManga.getCategoryMangaList(userId, categoryId)) }, withResults = { json>(HttpStatus.OK) @@ -130,7 +138,8 @@ object CategoryController { } }, behaviorOf = { ctx, from, to -> - Category.reorderCategory(from, to) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + Category.reorderCategory(userId, from, to) ctx.status(200) }, withResults = { @@ -151,7 +160,8 @@ object CategoryController { } }, behaviorOf = { ctx, categoryId, key, value -> - Category.modifyMeta(categoryId, key, value) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + Category.modifyMeta(userId, categoryId, key, value) ctx.status(200) }, withResults = { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/DownloadController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/DownloadController.kt index 56cc6e044e..eb1646112b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/DownloadController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/DownloadController.kt @@ -12,7 +12,10 @@ import io.javalin.websocket.WsConfig import kotlinx.serialization.json.Json import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.JavalinSetup.getAttribute +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.pathParam import suwayomi.tachidesk.server.util.withOperation @@ -44,7 +47,8 @@ object DownloadController { description("Start the downloader") } }, - behaviorOf = { + behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.start() }, withResults = { @@ -62,6 +66,7 @@ object DownloadController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { DownloadManager.stop() } .thenApply { ctx.status(HttpStatus.OK) } @@ -82,6 +87,7 @@ object DownloadController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { DownloadManager.clear() } .thenApply { ctx.status(HttpStatus.OK) } @@ -104,6 +110,7 @@ object DownloadController { } }, behaviorOf = { ctx, chapterIndex, mangaId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { DownloadManager.enqueueWithChapterIndex(mangaId, chapterIndex) @@ -126,6 +133,7 @@ object DownloadController { body() }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val inputs = json.decodeFromString(ctx.body()) ctx.future { future { @@ -149,6 +157,7 @@ object DownloadController { body() }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val input = json.decodeFromString(ctx.body()) ctx.future { future { @@ -173,6 +182,7 @@ object DownloadController { } }, behaviorOf = { ctx, chapterIndex, mangaId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.dequeue(chapterIndex, mangaId) ctx.status(200) @@ -194,7 +204,8 @@ object DownloadController { description("Reorder chapter in download queue") } }, - behaviorOf = { _, chapterIndex, mangaId, to -> + behaviorOf = { ctx, chapterIndex, mangaId, to -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.reorder(chapterIndex, mangaId, to) }, withResults = { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/ExtensionController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/ExtensionController.kt index ebea9427bd..006b27adb2 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/ExtensionController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/ExtensionController.kt @@ -12,7 +12,10 @@ import io.javalin.http.HttpStatus import suwayomi.tachidesk.manga.impl.extension.Extension import suwayomi.tachidesk.manga.impl.extension.ExtensionsList import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.JavalinSetup.getAttribute +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.pathParam import suwayomi.tachidesk.server.util.withOperation @@ -31,6 +34,7 @@ object ExtensionController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { ExtensionsList.getExtensionList() @@ -55,6 +59,7 @@ object ExtensionController { } }, behaviorOf = { ctx, pkgName -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { Extension.installExtension(pkgName) @@ -84,6 +89,7 @@ object ExtensionController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val uploadedFile = ctx.uploadedFile("file")!! logger.debug { "Uploaded extension file name: " + uploadedFile.filename() } @@ -116,6 +122,7 @@ object ExtensionController { } }, behaviorOf = { ctx, pkgName -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { Extension.updateExtension(pkgName) @@ -143,6 +150,7 @@ object ExtensionController { } }, behaviorOf = { ctx, pkgName -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() Extension.uninstallExtension(pkgName) ctx.status(200) }, @@ -165,6 +173,7 @@ object ExtensionController { } }, behaviorOf = { ctx, apkName -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { Extension.getExtensionIcon(apkName) } .thenApply { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt index 1eb6364c86..38d0e8df39 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt @@ -18,7 +18,10 @@ import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyByIndex import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.JavalinSetup.getAttribute +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.formParam import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.pathParam @@ -41,9 +44,10 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, onlineFetch -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { - Manga.getManga(mangaId, onlineFetch) + Manga.getManga(userId, mangaId, onlineFetch) }.thenApply { ctx.json(it) } } }, @@ -65,9 +69,10 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, onlineFetch -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { - Manga.getMangaFull(mangaId, onlineFetch) + Manga.getMangaFull(userId, mangaId, onlineFetch) }.thenApply { ctx.json(it) } } }, @@ -88,6 +93,7 @@ object MangaController { } }, behaviorOf = { ctx, mangaId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { Manga.getMangaThumbnail(mangaId) } .thenApply { @@ -115,8 +121,9 @@ object MangaController { } }, behaviorOf = { ctx, mangaId -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { - future { Library.addMangaToLibrary(mangaId) } + future { Library.addMangaToLibrary(userId, mangaId) } .thenApply { ctx.status(HttpStatus.OK) } } }, @@ -137,8 +144,9 @@ object MangaController { } }, behaviorOf = { ctx, mangaId -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { - future { Library.removeMangaFromLibrary(mangaId) } + future { Library.removeMangaFromLibrary(userId, mangaId) } .thenApply { ctx.status(HttpStatus.OK) } } }, @@ -159,7 +167,8 @@ object MangaController { } }, behaviorOf = { ctx, mangaId -> - ctx.json(CategoryManga.getMangaCategories(mangaId)) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + ctx.json(CategoryManga.getMangaCategories(userId, mangaId)) }, withResults = { json>(HttpStatus.OK) @@ -178,7 +187,8 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, categoryId -> - CategoryManga.addMangaToCategory(mangaId, categoryId) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + CategoryManga.addMangaToCategory(userId, mangaId, categoryId) ctx.status(200) }, withResults = { @@ -198,7 +208,8 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, categoryId -> - CategoryManga.removeMangaFromCategory(mangaId, categoryId) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + CategoryManga.removeMangaFromCategory(userId, mangaId, categoryId) ctx.status(200) }, withResults = { @@ -219,7 +230,8 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, key, value -> - Manga.modifyMangaMeta(mangaId, key, value) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + Manga.modifyMangaMeta(userId, mangaId, key, value) ctx.status(200) }, withResults = { @@ -244,8 +256,9 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, onlineFetch -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { - future { Chapter.getChapterList(mangaId, onlineFetch) } + future { Chapter.getChapterList(userId, mangaId, onlineFetch) } .thenApply { ctx.json(it) } } }, @@ -267,8 +280,9 @@ object MangaController { body() }, behaviorOf = { ctx, mangaId -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() val input = json.decodeFromString(ctx.body()) - Chapter.modifyChapters(input, mangaId) + Chapter.modifyChapters(userId, input, mangaId) }, withResults = { httpCode(HttpStatus.OK) @@ -286,8 +300,10 @@ object MangaController { body() }, behaviorOf = { ctx -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() val input = json.decodeFromString(ctx.body()) Chapter.modifyChapters( + userId, Chapter.MangaChapterBatchEditInput( input.chapterIds, null, @@ -312,8 +328,9 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, chapterIndex -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { - future { getChapterDownloadReadyByIndex(chapterIndex, mangaId) } + future { getChapterDownloadReadyByIndex(userId, chapterIndex, mangaId) } .thenApply { ctx.json(it) } } }, @@ -339,7 +356,8 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead -> - Chapter.modifyChapter(mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + Chapter.modifyChapter(userId, mangaId, chapterIndex, read, bookmarked, markPrevRead, lastPageRead) ctx.status(200) }, @@ -360,6 +378,7 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, chapterIndex -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() Chapter.deleteChapter(mangaId, chapterIndex) ctx.status(200) @@ -384,7 +403,8 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, chapterIndex, key, value -> - Chapter.modifyChapterMeta(mangaId, chapterIndex, key, value) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + Chapter.modifyChapterMeta(userId, mangaId, chapterIndex, key, value) ctx.status(200) }, @@ -409,6 +429,7 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, chapterIndex, index -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { Page.getPageImage(mangaId, chapterIndex, index) } .thenApply { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/SourceController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/SourceController.kt index d67be69cde..907f63fa55 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/SourceController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/SourceController.kt @@ -17,7 +17,10 @@ import suwayomi.tachidesk.manga.impl.Source import suwayomi.tachidesk.manga.impl.Source.SourcePreferenceChange import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.JavalinSetup.getAttribute +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.pathParam import suwayomi.tachidesk.server.util.queryParam @@ -35,6 +38,7 @@ object SourceController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.json(Source.getSourceList()) }, withResults = { @@ -53,6 +57,7 @@ object SourceController { } }, behaviorOf = { ctx, sourceId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.json(Source.getSource(sourceId)!!) }, withResults = { @@ -73,9 +78,10 @@ object SourceController { } }, behaviorOf = { ctx, sourceId, pageNum -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { - MangaList.getMangaList(sourceId, pageNum, popular = true) + MangaList.getMangaList(userId, sourceId, pageNum, popular = true) }.thenApply { ctx.json(it) } } }, @@ -96,9 +102,10 @@ object SourceController { } }, behaviorOf = { ctx, sourceId, pageNum -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { - MangaList.getMangaList(sourceId, pageNum, popular = false) + MangaList.getMangaList(userId, sourceId, pageNum, popular = false) }.thenApply { ctx.json(it) } } }, @@ -118,6 +125,7 @@ object SourceController { } }, behaviorOf = { ctx, sourceId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.json(Source.getSourcePreferences(sourceId)) }, withResults = { @@ -137,6 +145,7 @@ object SourceController { body() }, behaviorOf = { ctx, sourceId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val preferenceChange = ctx.bodyAsClass(SourcePreferenceChange::class.java) ctx.json(Source.setSourcePreference(sourceId, preferenceChange.position, preferenceChange.value)) }, @@ -157,6 +166,7 @@ object SourceController { } }, behaviorOf = { ctx, sourceId, reset -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.json(Search.getFilterList(sourceId, reset)) }, withResults = { @@ -179,6 +189,7 @@ object SourceController { body>() }, behaviorOf = { ctx, sourceId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val filterChange = try { json.decodeFromString>(ctx.body()) @@ -206,8 +217,9 @@ object SourceController { } }, behaviorOf = { ctx, sourceId, searchTerm, pageNum -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { - future { Search.sourceSearch(sourceId, searchTerm, pageNum) } + future { Search.sourceSearch(userId, sourceId, searchTerm, pageNum) } .thenApply { ctx.json(it) } } }, @@ -229,9 +241,10 @@ object SourceController { body() }, behaviorOf = { ctx, sourceId, pageNum -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() val filter = json.decodeFromString(ctx.body()) ctx.future { - future { Search.sourceFilter(sourceId, pageNum, filter) } + future { Search.sourceFilter(userId, sourceId, pageNum, filter) } .thenApply { ctx.json(it) } } }, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt index e606bdd4dd..9f7904f8e0 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt @@ -12,7 +12,10 @@ import io.javalin.http.HttpStatus import kotlinx.serialization.json.Json import suwayomi.tachidesk.manga.impl.track.Track import suwayomi.tachidesk.manga.model.dataclass.TrackerDataClass +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.JavalinSetup.getAttribute +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.pathParam import suwayomi.tachidesk.server.util.queryParam @@ -33,7 +36,8 @@ object TrackController { } }, behaviorOf = { ctx -> - ctx.json(Track.getTrackerList()) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + ctx.json(Track.getTrackerList(userId)) }, withResults = { json>(HttpStatus.OK) @@ -52,8 +56,9 @@ object TrackController { behaviorOf = { ctx -> val input = json.decodeFromString(ctx.body()) logger.debug { "tracker login $input" } + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { - future { Track.login(input) } + future { Track.login(userId, input) } .thenApply { ctx.status(HttpStatus.OK) } } }, @@ -75,8 +80,9 @@ object TrackController { behaviorOf = { ctx -> val input = json.decodeFromString(ctx.body()) logger.debug { "tracker logout $input" } + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { - future { Track.logout(input) } + future { Track.logout(userId, input) } .thenApply { ctx.status(HttpStatus.OK) } } }, @@ -98,8 +104,9 @@ object TrackController { behaviorOf = { ctx -> val input = json.decodeFromString(ctx.body()) logger.debug { "tracker search $input" } + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { - future { Track.search(input) } + future { Track.search(userId, input) } .thenApply { ctx.json(it) } } }, @@ -121,8 +128,9 @@ object TrackController { } }, behaviorOf = { ctx, mangaId, trackerId, remoteId -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { - future { Track.bind(mangaId, trackerId, remoteId.toLong()) } + future { Track.bind(userId, mangaId, trackerId, remoteId.toLong()) } .thenApply { ctx.status(HttpStatus.OK) } } }, @@ -143,8 +151,9 @@ object TrackController { behaviorOf = { ctx -> val input = json.decodeFromString(ctx.body()) logger.debug { "tracker update $input" } + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { - future { Track.update(input) } + future { Track.update(userId, input) } .thenApply { ctx.status(HttpStatus.OK) } } }, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/UpdateController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/UpdateController.kt index d9c93fccdb..e033932c2b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/UpdateController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/UpdateController.kt @@ -10,7 +10,10 @@ import suwayomi.tachidesk.manga.impl.update.UpdateStatus import suwayomi.tachidesk.manga.impl.update.UpdaterSocket import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.PaginatedList +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.JavalinSetup.getAttribute +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.formParam import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.pathParam @@ -39,9 +42,10 @@ object UpdateController { } }, behaviorOf = { ctx, pageNum -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future { future { - Chapter.getRecentChapters(pageNum) + Chapter.getRecentChapters(userId, pageNum) }.thenApply { ctx.json(it) } } }, @@ -66,16 +70,17 @@ object UpdateController { } }, behaviorOf = { ctx, categoryId -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() val updater = Injekt.get() if (categoryId == null) { logger.info { "Adding Library to Update Queue" } updater.addCategoriesToUpdateQueue( - Category.getCategoryList(), + Category.getCategoryList(userId), clear = true, forceAll = false, ) } else { - val category = Category.getCategoryById(categoryId) + val category = Category.getCategoryById(userId, categoryId) if (category != null) { updater.addCategoriesToUpdateQueue( listOf(category), @@ -115,6 +120,7 @@ object UpdateController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val updater = Injekt.get() ctx.json(updater.statusDeprecated.value) }, @@ -132,6 +138,7 @@ object UpdateController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val updater = Injekt.get() logger.info { "Resetting Updater" } ctx.future { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Category.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Category.kt index 6dc65da57d..486edbeea6 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Category.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Category.kt @@ -14,6 +14,7 @@ import org.jetbrains.exposed.sql.andWhere import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insertAndGetId +import org.jetbrains.exposed.sql.leftJoin import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update @@ -22,26 +23,32 @@ import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryMetaTable import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable +import suwayomi.tachidesk.manga.model.table.getWithUserData import suwayomi.tachidesk.manga.model.table.toDataClass object Category { /** * The new category will be placed at the end of the list */ - fun createCategory(name: String): Int { + fun createCategory( + userId: Int, + name: String, + ): Int { // creating a category named Default is illegal if (name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true)) return -1 return transaction { - if (CategoryTable.selectAll().where { CategoryTable.name eq name }.firstOrNull() == null) { + if (CategoryTable.selectAll().where { CategoryTable.name eq name and (CategoryTable.user eq userId) }.firstOrNull() == null) { val newCategoryId = CategoryTable .insertAndGetId { it[CategoryTable.name] = name it[CategoryTable.order] = Int.MAX_VALUE + it[CategoryTable.user] = userId }.value - normalizeCategories() + normalizeCategories(userId) newCategoryId } else { @@ -51,6 +58,7 @@ object Category { } fun updateCategory( + userId: Int, categoryId: Int, name: String?, isDefault: Boolean?, @@ -58,7 +66,7 @@ object Category { includeInDownload: Int?, ) { transaction { - CategoryTable.update({ CategoryTable.id eq categoryId }) { + CategoryTable.update({ CategoryTable.id eq categoryId and (CategoryTable.user eq userId) }) { if ( categoryId != DEFAULT_CATEGORY_ID && name != null && @@ -77,6 +85,7 @@ object Category { * Move the category from order number `from` to `to` */ fun reorderCategory( + userId: Int, from: Int, to: Int, ) { @@ -86,32 +95,36 @@ object Category { CategoryTable .selectAll() .where { - CategoryTable.id neq DEFAULT_CATEGORY_ID + CategoryTable.id neq DEFAULT_CATEGORY_ID and (CategoryTable.user eq userId) }.orderBy(CategoryTable.order to SortOrder.ASC) .toMutableList() categories.add(to - 1, categories.removeAt(from - 1)) categories.forEachIndexed { index, cat -> - CategoryTable.update({ CategoryTable.id eq cat[CategoryTable.id].value }) { + CategoryTable.update({ CategoryTable.id eq cat[CategoryTable.id].value and (CategoryTable.user eq userId) }) { it[CategoryTable.order] = index + 1 } } - normalizeCategories() + normalizeCategories(userId) } } - fun removeCategory(categoryId: Int) { + fun removeCategory( + userId: Int, + categoryId: Int, + ) { if (categoryId == DEFAULT_CATEGORY_ID) return transaction { - CategoryTable.deleteWhere { CategoryTable.id eq categoryId } - normalizeCategories() + CategoryTable.deleteWhere { CategoryTable.id eq categoryId and (CategoryTable.user eq userId) } + normalizeCategories(userId) } } /** make sure category order numbers starts from 1 and is consecutive */ - fun normalizeCategories() { + fun normalizeCategories(userId: Int) { transaction { CategoryTable .selectAll() + .where { (CategoryTable.user eq userId) } .orderBy(CategoryTable.order to SortOrder.ASC) .sortedWith(compareBy({ it[CategoryTable.id].value != 0 }, { it[CategoryTable.order] })) .forEachIndexed { index, cat -> @@ -122,12 +135,13 @@ object Category { } } - private fun needsDefaultCategory() = + private fun needsDefaultCategory(userId: Int) = transaction { MangaTable + .getWithUserData(userId) .leftJoin(CategoryMangaTable) .selectAll() - .where { MangaTable.inLibrary eq true } + .where { MangaUserTable.inLibrary eq true and (CategoryMangaTable.user eq userId) } .andWhere { CategoryMangaTable.manga.isNull() } .empty() .not() @@ -136,13 +150,14 @@ object Category { const val DEFAULT_CATEGORY_ID = 0 const val DEFAULT_CATEGORY_NAME = "Default" - fun getCategoryList(): List = + fun getCategoryList(userId: Int): List = transaction { CategoryTable .selectAll() + .where { CategoryTable.user eq userId } .orderBy(CategoryTable.order to SortOrder.ASC) .let { - if (needsDefaultCategory()) { + if (needsDefaultCategory(userId)) { it } else { it.andWhere { CategoryTable.id neq DEFAULT_CATEGORY_ID } @@ -152,39 +167,50 @@ object Category { } } - fun getCategoryById(categoryId: Int): CategoryDataClass? = + fun getCategoryById( + userId: Int, + categoryId: Int, + ): CategoryDataClass? = transaction { - CategoryTable.selectAll().where { CategoryTable.id eq categoryId }.firstOrNull()?.let { + CategoryTable.selectAll().where { CategoryTable.id eq categoryId and (CategoryTable.user eq userId) }.firstOrNull()?.let { CategoryTable.toDataClass(it) } } - fun getCategorySize(categoryId: Int): Int = + fun getCategorySize( + userId: Int, + categoryId: Int, + ): Int = transaction { if (categoryId == DEFAULT_CATEGORY_ID) { MangaTable + .getWithUserData(userId) .leftJoin(CategoryMangaTable) .selectAll() - .where { MangaTable.inLibrary eq true } + .where { MangaUserTable.inLibrary eq true and (CategoryMangaTable.user eq userId) } .andWhere { CategoryMangaTable.manga.isNull() } } else { CategoryMangaTable - .leftJoin(MangaTable) + .leftJoin(MangaTable.getWithUserData(userId)) .selectAll() - .where { CategoryMangaTable.category eq categoryId } - .andWhere { MangaTable.inLibrary eq true } + .where { CategoryMangaTable.category eq categoryId and (CategoryMangaTable.user eq userId) } + .andWhere { MangaUserTable.inLibrary eq true } }.count().toInt() } - fun getCategoryMetaMap(categoryId: Int): Map = + fun getCategoryMetaMap( + userId: Int, + categoryId: Int, + ): Map = transaction { CategoryMetaTable .selectAll() - .where { CategoryMetaTable.ref eq categoryId } + .where { CategoryMetaTable.ref eq categoryId and (CategoryMetaTable.user eq userId) } .associate { it[CategoryMetaTable.key] to it[CategoryMetaTable.value] } } fun modifyMeta( + userId: Int, categoryId: Int, key: String, value: String, @@ -192,7 +218,11 @@ object Category { transaction { val meta = transaction { - CategoryMetaTable.selectAll().where { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) } + CategoryMetaTable.selectAll().where { + (CategoryMetaTable.ref eq categoryId) and + (CategoryMetaTable.user eq userId) and + (CategoryMetaTable.key eq key) + } }.firstOrNull() if (meta == null) { @@ -200,9 +230,16 @@ object Category { it[CategoryMetaTable.key] = key it[CategoryMetaTable.value] = value it[CategoryMetaTable.ref] = categoryId + it[CategoryMetaTable.user] = userId } } else { - CategoryMetaTable.update({ (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }) { + CategoryMetaTable.update( + { + (CategoryMetaTable.ref eq categoryId) and + (CategoryMetaTable.user eq userId) and + (CategoryMetaTable.key eq key) + }, + ) { it[CategoryMetaTable.value] = value } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/CategoryManga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/CategoryManga.kt index cbe6684368..e978782e1a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/CategoryManga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/CategoryManga.kt @@ -10,6 +10,7 @@ package suwayomi.tachidesk.manga.impl import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.SqlExpressionBuilder.isNull import org.jetbrains.exposed.sql.alias import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.count @@ -17,6 +18,7 @@ import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.leftJoin import org.jetbrains.exposed.sql.max +import org.jetbrains.exposed.sql.or import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.wrapAsExpression @@ -27,11 +29,15 @@ import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ChapterUserTable import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable +import suwayomi.tachidesk.manga.model.table.getWithUserData import suwayomi.tachidesk.manga.model.table.toDataClass object CategoryManga { fun addMangaToCategory( + userId: Int, mangaId: Int, categoryId: Int, ) { @@ -41,7 +47,9 @@ object CategoryManga { CategoryMangaTable .selectAll() .where { - (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) + (CategoryMangaTable.category eq categoryId) and + (CategoryMangaTable.manga eq mangaId) and + (CategoryMangaTable.user eq userId) }.isEmpty() transaction { @@ -49,32 +57,47 @@ object CategoryManga { CategoryMangaTable.insert { it[CategoryMangaTable.category] = categoryId it[CategoryMangaTable.manga] = mangaId + it[CategoryMangaTable.user] = userId } } } } fun removeMangaFromCategory( + userId: Int, mangaId: Int, categoryId: Int, ) { if (categoryId == DEFAULT_CATEGORY_ID) return transaction { - CategoryMangaTable.deleteWhere { (CategoryMangaTable.category eq categoryId) and (CategoryMangaTable.manga eq mangaId) } + CategoryMangaTable.deleteWhere { + (CategoryMangaTable.category eq categoryId) and + (CategoryMangaTable.manga eq mangaId) and + (CategoryMangaTable.user eq userId) + } } } /** * list of mangas that belong to a category */ - fun getCategoryMangaList(categoryId: Int): List { + fun getCategoryMangaList( + userId: Int, + categoryId: Int, + ): List { // Select the required columns from the MangaTable and add the aggregate functions to compute unread, download, and chapter counts val unreadCount = wrapAsExpression( ChapterTable + .getWithUserData(userId) .select( ChapterTable.id.count(), - ).where { ((ChapterTable.isRead eq false) and (ChapterTable.manga eq MangaTable.id)) }, + ).where { + ( + (ChapterUserTable.isRead eq false or (ChapterUserTable.isRead.isNull())) and + (ChapterTable.manga eq MangaTable.id) + ) + }, ) val downloadedCount = wrapAsExpression( @@ -85,12 +108,12 @@ object CategoryManga { ) val chapterCount = ChapterTable.id.count().alias("chapter_count") - val lastReadAt = ChapterTable.lastReadAt.max().alias("last_read_at") - val selectedColumns = MangaTable.columns + unreadCount + downloadedCount + chapterCount + lastReadAt + val lastReadAt = ChapterUserTable.lastReadAt.max().alias("last_read_at") + val selectedColumns = MangaTable.getWithUserData(userId).columns + unreadCount + downloadedCount + chapterCount + lastReadAt val transform: (ResultRow) -> MangaDataClass = { // Map the data from the result row to the MangaDataClass - val dataClass = MangaTable.toDataClass(it) + val dataClass = MangaTable.toDataClass(userId, it) dataClass.lastReadAt = it[lastReadAt] dataClass.unreadCount = it[unreadCount] dataClass.downloadCount = it[downloadedCount] @@ -103,16 +126,22 @@ object CategoryManga { val query = if (categoryId == DEFAULT_CATEGORY_ID) { MangaTable - .leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga }) + .getWithUserData(userId) + .leftJoin(ChapterTable.getWithUserData(userId), { MangaTable.id }, { ChapterTable.manga }) .leftJoin(CategoryMangaTable) .select(columns = selectedColumns) - .where { (MangaTable.inLibrary eq true) and CategoryMangaTable.category.isNull() } + .where { + (MangaUserTable.inLibrary eq true) and + (CategoryMangaTable.user eq userId) and + CategoryMangaTable.category.isNull() + } } else { MangaTable + .getWithUserData(userId) .innerJoin(CategoryMangaTable) - .leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga }) + .leftJoin(ChapterTable.getWithUserData(userId), { MangaTable.id }, { ChapterTable.manga }) .select(columns = selectedColumns) - .where { (MangaTable.inLibrary eq true) and (CategoryMangaTable.category eq categoryId) } + .where { (MangaUserTable.inLibrary eq true) and (CategoryMangaTable.category eq categoryId) } } // Join with the ChapterTable to fetch the last read chapter for each manga @@ -123,27 +152,36 @@ object CategoryManga { /** * list of categories that a manga belongs to */ - fun getMangaCategories(mangaId: Int): List = + fun getMangaCategories( + userId: Int, + mangaId: Int, + ): List = transaction { CategoryMangaTable .innerJoin(CategoryTable) .selectAll() .where { - CategoryMangaTable.manga eq mangaId + CategoryMangaTable.manga eq mangaId and (CategoryTable.user eq userId) and (CategoryMangaTable.user eq userId) }.orderBy(CategoryTable.order to SortOrder.ASC) .map { CategoryTable.toDataClass(it) } } - fun getMangasCategories(mangaIDs: List): Map> = + fun getMangasCategories( + userId: Int, + mangaIDs: List, + ): Map> = buildMap { transaction { CategoryMangaTable .innerJoin(CategoryTable) .selectAll() - .where { CategoryMangaTable.manga inList mangaIDs } - .groupBy { it[CategoryMangaTable.manga] } + .where { + (CategoryTable.user eq userId) and + (CategoryMangaTable.user eq userId) and + (CategoryMangaTable.manga inList mangaIDs) + }.groupBy { it[CategoryMangaTable.manga] } .forEach { val mangaId = it.key.value val categories = it.value diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt index c69949b040..17c2633f58 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt @@ -33,6 +33,7 @@ import suwayomi.tachidesk.manga.impl.Manga.getManga import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput import suwayomi.tachidesk.manga.impl.track.Track +import suwayomi.tachidesk.manga.impl.util.lang.isNotEmpty import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass @@ -40,8 +41,11 @@ import suwayomi.tachidesk.manga.model.dataclass.PaginatedList import suwayomi.tachidesk.manga.model.dataclass.paginatedFrom import suwayomi.tachidesk.manga.model.table.ChapterMetaTable import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ChapterUserTable import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable import suwayomi.tachidesk.manga.model.table.PageTable +import suwayomi.tachidesk.manga.model.table.getWithUserData import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.server.serverConfig import java.time.Instant @@ -62,22 +66,24 @@ object Chapter { /** get chapter list when showing a manga */ suspend fun getChapterList( + userId: Int, mangaId: Int, onlineFetch: Boolean = false, ): List = if (onlineFetch) { - getSourceChapters(mangaId) + getSourceChapters(userId, mangaId) } else { transaction { ChapterTable + .getWithUserData(userId) .selectAll() .where { ChapterTable.manga eq mangaId } .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) .map { - ChapterTable.toDataClass(it) + ChapterTable.toDataClass(userId, it) } }.ifEmpty { - getSourceChapters(mangaId) + getSourceChapters(userId, mangaId) } } @@ -90,19 +96,23 @@ object Chapter { .toInt() } - private suspend fun getSourceChapters(mangaId: Int): List { - val chapterList = fetchChapterList(mangaId) + private suspend fun getSourceChapters( + userId: Int, + mangaId: Int, + ): List { + val chapterList = fetchChapterList(userId, mangaId) val dbChapterMap = transaction { ChapterTable + .getWithUserData(userId) .selectAll() .where { ChapterTable.manga eq mangaId } .associateBy({ it[ChapterTable.url] }, { it }) } val chapterIds = chapterList.map { dbChapterMap.getValue(it.url)[ChapterTable.id] } - val chapterMetas = getChaptersMetaMaps(chapterIds) + val chapterMetas = getChaptersMetaMaps(userId, chapterIds) return chapterList.mapIndexed { index, it -> @@ -116,10 +126,10 @@ object Chapter { chapterNumber = it.chapter_number, scanlator = it.scanlator, mangaId = mangaId, - read = dbChapter[ChapterTable.isRead], - bookmarked = dbChapter[ChapterTable.isBookmarked], - lastPageRead = dbChapter[ChapterTable.lastPageRead], - lastReadAt = dbChapter[ChapterTable.lastReadAt], + read = dbChapter.getOrNull(ChapterUserTable.isRead) ?: false, + bookmarked = dbChapter.getOrNull(ChapterUserTable.isBookmarked) ?: false, + lastPageRead = dbChapter.getOrNull(ChapterUserTable.lastPageRead) ?: 0, + lastReadAt = dbChapter.getOrNull(ChapterUserTable.lastReadAt) ?: 0, index = chapterList.size - index, fetchedAt = dbChapter[ChapterTable.fetchedAt], realUrl = dbChapter[ChapterTable.realUrl], @@ -137,11 +147,15 @@ object Chapter { .expireAfterAccess(10.minutes) .build() - suspend fun fetchChapterList(mangaId: Int): List { + // todo user accounts + suspend fun fetchChapterList( + userId: Int, + mangaId: Int, + ): List { val mutex = map.get(mangaId) { Mutex() } val chapterList = mutex.withLock { - val manga = getManga(mangaId) + val manga = getManga(userId, mangaId) val source = getCatalogueSourceOrStub(manga.sourceId.toLong()) val sManga = @@ -151,7 +165,7 @@ object Chapter { description = manga.description } - val currentLatestChapterNumber = Manga.getLatestChapter(mangaId)?.chapterNumber ?: 0f + val currentLatestChapterNumber = Manga.getLatestChapter(userId, mangaId)?.chapterNumber ?: 0f val numberOfCurrentChapters = getCountOfMangaChapters(mangaId) val chapters = source.getChapterList(sManga) @@ -184,7 +198,7 @@ object Chapter { ChapterTable .selectAll() .where { ChapterTable.manga eq mangaId } - .map { ChapterTable.toDataClass(it) } + .map { ChapterTable.toDataClass(userId, it) } .toList() } @@ -280,14 +294,14 @@ object Chapter { this[ChapterTable.fetchedAt] = chapter.fetchedAt this[ChapterTable.manga] = chapter.mangaId this[ChapterTable.realUrl] = chapter.realUrl - this[ChapterTable.isRead] = false - this[ChapterTable.isBookmarked] = false + // todo this[ChapterTable.isRead] = false + // todo this[ChapterTable.isBookmarked] = false this[ChapterTable.isDownloaded] = false // is recognized chapter number if (chapter.chapterNumber >= 0f && chapter.chapterNumber in deletedChapterNumbers) { - this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers - this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers + // todo this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers + // todo this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers // only preserve download status for chapters of the same scanlator, otherwise, // the downloaded files won't be found anyway @@ -302,7 +316,7 @@ object Chapter { this[ChapterTable.fetchedAt] = it } } - }.forEach { insertedChapters.add(ChapterTable.toDataClass(it)) } + }.forEach { insertedChapters.add(ChapterTable.toDataClass(userId, it)) } } if (chaptersToUpdate.isNotEmpty()) { @@ -326,7 +340,13 @@ object Chapter { } if (manga.inLibrary) { - downloadNewChapters(mangaId, currentLatestChapterNumber, numberOfCurrentChapters, insertedChapters) + downloadNewChapters( + userId = userId, + mangaId = mangaId, + prevLatestChapterNumber = currentLatestChapterNumber, + prevNumberOfChapters = numberOfCurrentChapters, + newChapters = insertedChapters, + ) } uniqueChapters @@ -335,7 +355,9 @@ object Chapter { return chapterList } + // todo user accounts private fun downloadNewChapters( + userId: Int, mangaId: Int, prevLatestChapterNumber: Float, prevNumberOfChapters: Int, @@ -369,11 +391,11 @@ object Chapter { return } - if (!Manga.isInIncludedDownloadCategory(log, mangaId)) { + if (!Manga.isInIncludedDownloadCategory(userId, log, mangaId)) { return } - val unreadChapters = Manga.getUnreadChapters(mangaId).subtract(newChapters.toSet()) + val unreadChapters = Manga.getUnreadChapters(userId, mangaId).subtract(newChapters.toSet()) val skipDueToUnreadChapters = serverConfig.excludeEntryWithUnreadChapters.value && unreadChapters.isNotEmpty() if (skipDueToUnreadChapters) { @@ -425,6 +447,7 @@ object Chapter { } fun modifyChapter( + userId: Int, mangaId: Int, chapterIndex: Int, isRead: Boolean?, @@ -434,29 +457,77 @@ object Chapter { ) { transaction { if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) { - ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }) { update -> - isRead?.also { - update[ChapterTable.isRead] = it - } - isBookmarked?.also { - update[ChapterTable.isBookmarked] = it + val chapter = + ChapterTable + .selectAll() + .where { + (ChapterTable.manga eq mangaId) and + (ChapterTable.sourceOrder eq chapterIndex) + }.first() + val userDataExists = + ChapterUserTable + .selectAll() + .where { + ChapterUserTable.user eq userId and (ChapterUserTable.chapter eq chapter[ChapterTable.id].value) + }.isNotEmpty() + if (userDataExists) { + ChapterUserTable.update( + { (ChapterUserTable.user eq userId) and (ChapterUserTable.chapter eq chapter[ChapterTable.id].value) }, + ) { update -> + isRead?.also { + update[ChapterUserTable.isRead] = it + } + isBookmarked?.also { + update[ChapterUserTable.isBookmarked] = it + } + lastPageRead?.also { + update[ChapterUserTable.lastPageRead] = it + update[ChapterUserTable.lastReadAt] = Instant.now().epochSecond + } } - lastPageRead?.also { - update[ChapterTable.lastPageRead] = it - update[ChapterTable.lastReadAt] = Instant.now().epochSecond + } else { + ChapterUserTable.insert { insert -> + insert[ChapterUserTable.user] = userId + insert[ChapterUserTable.chapter] = chapter[ChapterTable.id].value + isRead?.also { + insert[ChapterUserTable.isRead] = it + } + isBookmarked?.also { + insert[ChapterUserTable.isBookmarked] = it + } + lastPageRead?.also { + insert[ChapterUserTable.lastPageRead] = it + insert[ChapterUserTable.lastReadAt] = Instant.now().epochSecond + } } } } markPrevRead?.let { - ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder less chapterIndex) }) { - it[ChapterTable.isRead] = markPrevRead + val chapters = + ChapterTable + .selectAll() + .where { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder less chapterIndex) } + .map { it[ChapterTable.id].value } + val existingUserData = + ChapterUserTable.selectAll().where { + ChapterUserTable.user eq userId and (ChapterUserTable.chapter inList chapters) + } + ChapterUserTable.update({ ChapterUserTable.id inList existingUserData.map { it[ChapterUserTable.id].value } }) { + it[ChapterUserTable.isRead] = markPrevRead + } + ChapterUserTable.batchInsert( + chapters - existingUserData.map { it[ChapterUserTable.chapter].value }.toSet(), + ) { + this[ChapterUserTable.user] = userId + this[ChapterUserTable.chapter] = it + this[ChapterUserTable.isRead] = markPrevRead } } } if (isRead == true || markPrevRead == true) { - Track.asyncTrackChapter(setOf(mangaId)) + Track.asyncTrackChapter(userId, setOf(mangaId)) } } @@ -482,6 +553,7 @@ object Chapter { ) fun modifyChapters( + userId: Int, input: MangaChapterBatchEditInput, mangaId: Int? = null, ) { @@ -525,16 +597,37 @@ object Chapter { transaction { val now = Instant.now().epochSecond - ChapterTable.update({ condition }) { update -> + val chapters = ChapterTable.selectAll().where { condition }.map { it[ChapterTable.id].value } + val existingUserData = + ChapterUserTable.selectAll().where { + ChapterUserTable.user eq userId and (ChapterUserTable.chapter inList chapters) + } + ChapterUserTable.update({ ChapterUserTable.id inList existingUserData.map { it[ChapterUserTable.id].value } }) { update -> + isRead?.also { + update[ChapterUserTable.isRead] = it + } + isBookmarked?.also { + update[ChapterUserTable.isBookmarked] = it + } + lastPageRead?.also { + update[ChapterUserTable.lastPageRead] = it + update[ChapterUserTable.lastReadAt] = now + } + } + ChapterUserTable.batchInsert( + chapters - existingUserData.map { it[ChapterUserTable.chapter].value }.toSet(), + ) { + this[ChapterUserTable.user] = userId + this[ChapterUserTable.chapter] = it isRead?.also { - update[ChapterTable.isRead] = it + this[ChapterUserTable.isRead] = it } isBookmarked?.also { - update[ChapterTable.isBookmarked] = it + this[ChapterUserTable.isBookmarked] = it } lastPageRead?.also { - update[ChapterTable.lastPageRead] = it - update[ChapterTable.lastReadAt] = now + this[ChapterUserTable.lastPageRead] = it + this[ChapterUserTable.lastReadAt] = now } } } @@ -548,29 +641,36 @@ object Chapter { .map { it[ChapterTable.manga].value } .toSet() } - Track.asyncTrackChapter(mangaIds) + Track.asyncTrackChapter(userId, mangaIds) } } - fun getChaptersMetaMaps(chapterIds: List>): Map, Map> = + fun getChaptersMetaMaps( + userId: Int, + chapterIds: List>, + ): Map, Map> = transaction { ChapterMetaTable .selectAll() - .where { ChapterMetaTable.ref inList chapterIds } + .where { ChapterMetaTable.user eq userId and (ChapterMetaTable.ref inList chapterIds) } .groupBy { it[ChapterMetaTable.ref] } .mapValues { it.value.associate { it[ChapterMetaTable.key] to it[ChapterMetaTable.value] } } .withDefault { emptyMap() } } - fun getChapterMetaMap(chapter: EntityID): Map = + fun getChapterMetaMap( + userId: Int, + chapter: EntityID, + ): Map = transaction { ChapterMetaTable .selectAll() - .where { ChapterMetaTable.ref eq chapter } + .where { ChapterMetaTable.user eq userId and (ChapterMetaTable.ref eq chapter) } .associate { it[ChapterMetaTable.key] to it[ChapterMetaTable.value] } } fun modifyChapterMeta( + userId: Int, mangaId: Int, chapterIndex: Int, key: String, @@ -580,14 +680,17 @@ object Chapter { val chapterId = ChapterTable .selectAll() - .where { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) } - .first()[ChapterTable.id] + .where { + (ChapterTable.manga eq mangaId) and + (ChapterTable.sourceOrder eq chapterIndex) + }.first()[ChapterTable.id] .value - modifyChapterMeta(chapterId, key, value) + modifyChapterMeta(userId, chapterId, key, value) } } fun modifyChapterMeta( + userId: Int, chapterId: Int, key: String, value: String, @@ -596,7 +699,7 @@ object Chapter { val meta = ChapterMetaTable .selectAll() - .where { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } + .where { ChapterMetaTable.user eq userId and (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } .firstOrNull() if (meta == null) { @@ -604,9 +707,16 @@ object Chapter { it[ChapterMetaTable.key] = key it[ChapterMetaTable.value] = value it[ChapterMetaTable.ref] = chapterId + it[ChapterMetaTable.user] = userId } } else { - ChapterMetaTable.update({ (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }) { + ChapterMetaTable.update( + { + ChapterMetaTable.user eq userId and + (ChapterMetaTable.ref eq chapterId) and + (ChapterMetaTable.key eq key) + }, + ) { it[ChapterMetaTable.value] = value } } @@ -678,17 +788,20 @@ object Chapter { } } - fun getRecentChapters(pageNum: Int): PaginatedList = + fun getRecentChapters( + userId: Int, + pageNum: Int, + ): PaginatedList = paginatedFrom(pageNum) { transaction { - (ChapterTable innerJoin MangaTable) + (ChapterTable.getWithUserData(userId) innerJoin MangaTable.getWithUserData(userId)) .selectAll() - .where { (MangaTable.inLibrary eq true) and (ChapterTable.fetchedAt greater MangaTable.inLibraryAt) } + .where { (MangaUserTable.inLibrary eq true) and (ChapterTable.fetchedAt greater MangaUserTable.inLibraryAt) } .orderBy(ChapterTable.fetchedAt to SortOrder.DESC) .map { MangaChapterDataClass( - MangaTable.toDataClass(it), - ChapterTable.toDataClass(it), + MangaTable.toDataClass(userId, it), + ChapterTable.toDataClass(userId, it), ) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Library.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Library.kt index 9d5f60aa28..6d330c43e9 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Library.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Library.kt @@ -17,30 +17,50 @@ import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update import suwayomi.tachidesk.manga.impl.Manga.getManga +import suwayomi.tachidesk.manga.impl.util.lang.isEmpty +import suwayomi.tachidesk.manga.impl.util.lang.isNotEmpty import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryTable -import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable import java.time.Instant object Library { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - suspend fun addMangaToLibrary(mangaId: Int) { - val manga = getManga(mangaId) + suspend fun addMangaToLibrary( + userId: Int, + mangaId: Int, + ) { + val manga = getManga(userId, mangaId) if (!manga.inLibrary) { transaction { val defaultCategories = CategoryTable .selectAll() .where { - (CategoryTable.isDefault eq true) and + MangaUserTable.user eq userId and + (CategoryTable.isDefault eq true) and (CategoryTable.id neq Category.DEFAULT_CATEGORY_ID) }.toList() - val existingCategories = CategoryMangaTable.selectAll().where { CategoryMangaTable.manga eq mangaId }.toList() + val existingCategories = + CategoryMangaTable + .selectAll() + .where { + MangaUserTable.user eq userId and (CategoryMangaTable.manga eq mangaId) + }.toList() - MangaTable.update({ MangaTable.id eq manga.id }) { - it[inLibrary] = true - it[inLibraryAt] = Instant.now().epochSecond + if (MangaUserTable.selectAll().where { MangaUserTable.user eq userId and (MangaUserTable.manga eq mangaId) }.isEmpty()) { + MangaUserTable.insert { + it[MangaUserTable.manga] = mangaId + it[MangaUserTable.user] = userId + it[inLibrary] = true + it[inLibraryAt] = Instant.now().epochSecond + } + } else { + MangaUserTable.update({ MangaUserTable.user eq userId and (MangaUserTable.manga eq mangaId) }) { + it[inLibrary] = true + it[inLibraryAt] = Instant.now().epochSecond + } } if (existingCategories.isEmpty()) { @@ -48,35 +68,44 @@ object Library { CategoryMangaTable.insert { it[CategoryMangaTable.category] = category[CategoryTable.id].value it[CategoryMangaTable.manga] = mangaId + it[CategoryMangaTable.user] = userId } } } }.apply { - handleMangaThumbnail(mangaId, true) + handleMangaThumbnail(mangaId) } } } - suspend fun removeMangaFromLibrary(mangaId: Int) { - val manga = getManga(mangaId) + suspend fun removeMangaFromLibrary( + userId: Int, + mangaId: Int, + ) { + val manga = getManga(userId, mangaId) if (manga.inLibrary) { transaction { - MangaTable.update({ MangaTable.id eq manga.id }) { + MangaUserTable.update({ MangaUserTable.user eq userId and (MangaUserTable.manga eq mangaId) }) { it[inLibrary] = false } }.apply { - handleMangaThumbnail(mangaId, false) + handleMangaThumbnail(mangaId) } } } - fun handleMangaThumbnail( - mangaId: Int, - inLibrary: Boolean, - ) { + fun handleMangaThumbnail(mangaId: Int) { scope.launch { + val mangaInLibrary = + transaction { + MangaUserTable + .selectAll() + .where { + MangaUserTable.manga eq mangaId and (MangaUserTable.inLibrary eq true) + }.isNotEmpty() + } try { - if (inLibrary) { + if (mangaInLibrary) { ThumbnailDownloadHelper.download(mangaId) } else { ThumbnailDownloadHelper.delete(mangaId) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt index 5fddac09a5..5b0b7fe35c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt @@ -22,8 +22,10 @@ import okhttp3.CacheControl import okhttp3.Response import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update @@ -31,6 +33,7 @@ import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl import suwayomi.tachidesk.manga.impl.Source.getSource import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.MissingThumbnailException import suwayomi.tachidesk.manga.impl.track.Track +import suwayomi.tachidesk.manga.impl.util.lang.isNotEmpty import suwayomi.tachidesk.manga.impl.util.network.await import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub @@ -44,9 +47,12 @@ import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.toGenreList import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ChapterUserTable import suwayomi.tachidesk.manga.model.table.MangaMetaTable import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable +import suwayomi.tachidesk.manga.model.table.getWithUserData import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.server.ApplicationDirs import uy.kohesive.injekt.injectLazy @@ -69,17 +75,32 @@ object Manga { } suspend fun getManga( + userId: Int, mangaId: Int, onlineFetch: Boolean = false, ): MangaDataClass { - var mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() } + var mangaEntry = + transaction { + MangaTable + .getWithUserData(userId) + .selectAll() + .where { MangaTable.id eq mangaId } + .first() + } return if (!onlineFetch && mangaEntry[MangaTable.initialized]) { - getMangaDataClass(mangaId, mangaEntry) + getMangaDataClass(userId, mangaId, mangaEntry) } else { // initialize manga - val sManga = fetchManga(mangaId) ?: return getMangaDataClass(mangaId, mangaEntry) - - mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() } + val sManga = fetchManga(mangaId) ?: return getMangaDataClass(userId, mangaId, mangaEntry) + + mangaEntry = + transaction { + MangaTable + .getWithUserData(userId) + .selectAll() + .where { MangaTable.id eq mangaId } + .first() + } MangaDataClass( id = mangaId, @@ -94,16 +115,16 @@ object Manga { description = sManga.description, genre = sManga.genre.toGenreList(), status = MangaStatus.valueOf(sManga.status).name, - inLibrary = mangaEntry[MangaTable.inLibrary], - inLibraryAt = mangaEntry[MangaTable.inLibraryAt], + inLibrary = mangaEntry.getOrNull(MangaUserTable.inLibrary) ?: false, + inLibraryAt = mangaEntry.getOrNull(MangaUserTable.inLibraryAt) ?: 0, source = getSource(mangaEntry[MangaTable.sourceReference]), - meta = getMangaMetaMap(mangaId), + meta = getMangaMetaMap(userId, mangaId), realUrl = mangaEntry[MangaTable.realUrl], lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt], chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt], updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]), freshData = true, - trackers = Track.getTrackRecordsByMangaId(mangaId), + trackers = Track.getTrackRecordsByMangaId(userId, mangaId), ) } } @@ -185,16 +206,18 @@ object Manga { } suspend fun getMangaFull( + userId: Int, mangaId: Int, onlineFetch: Boolean = false, ): MangaDataClass { - val mangaDaaClass = getManga(mangaId, onlineFetch) + val mangaDaaClass = getManga(userId, mangaId, onlineFetch) return transaction { val unreadCount = ChapterTable + .getWithUserData(userId) .selectAll() - .where { (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq false) } + .where { (ChapterTable.manga eq mangaId) and (ChapterUserTable.isRead eq false) } .count() val downloadCount = @@ -211,21 +234,23 @@ object Manga { val lastChapterRead = ChapterTable + .getWithUserData(userId) .selectAll() .where { (ChapterTable.manga eq mangaId) } .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) - .firstOrNull { it[ChapterTable.isRead] } + .firstOrNull { it.getOrNull(ChapterUserTable.isRead) == true } mangaDaaClass.unreadCount = unreadCount mangaDaaClass.downloadCount = downloadCount mangaDaaClass.chapterCount = chapterCount - mangaDaaClass.lastChapterRead = lastChapterRead?.let { ChapterTable.toDataClass(it) } + mangaDaaClass.lastChapterRead = lastChapterRead?.let { ChapterTable.toDataClass(userId, it) } mangaDaaClass } } private fun getMangaDataClass( + userId: Int, mangaId: Int, mangaEntry: ResultRow, ) = MangaDataClass( @@ -241,27 +266,31 @@ object Manga { description = mangaEntry[MangaTable.description], genre = mangaEntry[MangaTable.genre].toGenreList(), status = MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, - inLibrary = mangaEntry[MangaTable.inLibrary], - inLibraryAt = mangaEntry[MangaTable.inLibraryAt], + inLibrary = mangaEntry.getOrNull(MangaUserTable.inLibrary) ?: false, + inLibraryAt = mangaEntry.getOrNull(MangaUserTable.inLibraryAt) ?: 0, source = getSource(mangaEntry[MangaTable.sourceReference]), - meta = getMangaMetaMap(mangaId), + meta = getMangaMetaMap(userId, mangaId), realUrl = mangaEntry[MangaTable.realUrl], lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt], chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt], updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]), freshData = false, - trackers = Track.getTrackRecordsByMangaId(mangaId), + trackers = Track.getTrackRecordsByMangaId(userId, mangaId), ) - fun getMangaMetaMap(mangaId: Int): Map = + fun getMangaMetaMap( + userId: Int, + mangaId: Int, + ): Map = transaction { MangaMetaTable .selectAll() - .where { MangaMetaTable.ref eq mangaId } + .where { MangaMetaTable.user eq userId and (MangaMetaTable.ref eq mangaId) } .associate { it[MangaMetaTable.key] to it[MangaMetaTable.value] } } fun modifyMangaMeta( + userId: Int, mangaId: Int, key: String, value: String, @@ -270,7 +299,7 @@ object Manga { val meta = MangaMetaTable .selectAll() - .where { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) } + .where { MangaMetaTable.user eq userId and (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) } .firstOrNull() if (meta == null) { @@ -278,9 +307,16 @@ object Manga { it[MangaMetaTable.key] = key it[MangaMetaTable.value] = value it[MangaMetaTable.ref] = mangaId + it[MangaMetaTable.user] = userId } } else { - MangaMetaTable.update({ (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }) { + MangaMetaTable.update( + { + MangaMetaTable.user eq userId and + (MangaMetaTable.ref eq mangaId) and + (MangaMetaTable.key eq key) + }, + ) { it[MangaMetaTable.value] = value } } @@ -288,7 +324,7 @@ object Manga { } private suspend fun fetchThumbnailUrl(mangaId: Int): String? { - getManga(mangaId, true) + getManga(0, mangaId, true) return transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }[MangaTable.thumbnail_url] @@ -382,9 +418,24 @@ object Manga { } suspend fun getMangaThumbnail(mangaId: Int): Pair { - val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() } + val mangaInLibrary = + transaction { + MangaUserTable + .selectAll() + .where { + MangaUserTable.manga eq mangaId and (MangaUserTable.inLibrary eq true) + }.isNotEmpty() + } + val mangaSource = + transaction { + MangaTable + .select(MangaTable.sourceReference) + .where { MangaTable.id eq mangaId } + .firstOrNull() + ?.get(MangaTable.sourceReference) + } - if (mangaEntry[MangaTable.inLibrary] && mangaEntry[MangaTable.sourceReference] != LocalSource.ID) { + if (mangaInLibrary && mangaSource != LocalSource.ID) { return try { ThumbnailDownloadHelper.getImage(mangaId) } catch (_: MissingThumbnailException) { @@ -403,35 +454,48 @@ object Manga { clearCachedImage(applicationDirs.thumbnailDownloadsRoot, fileName) } - fun getLatestChapter(mangaId: Int): ChapterDataClass? = + fun getLatestChapter( + userId: Int, + mangaId: Int, + ): ChapterDataClass? = transaction { - ChapterTable.selectAll().where { ChapterTable.manga eq mangaId }.maxByOrNull { it[ChapterTable.sourceOrder] } - }?.let { ChapterTable.toDataClass(it) } - - fun getUnreadChapters(mangaId: Int): List = + ChapterTable + .getWithUserData( + userId, + ).selectAll() + .where { ChapterTable.manga eq mangaId } + .maxByOrNull { it[ChapterTable.sourceOrder] } + }?.let { ChapterTable.toDataClass(userId, it) } + + fun getUnreadChapters( + userId: Int, + mangaId: Int, + ): List = transaction { ChapterTable + .getWithUserData(userId) .selectAll() - .where { (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq false) } + .where { (ChapterTable.manga eq mangaId) and (ChapterUserTable.isRead eq false) } .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) - .map { ChapterTable.toDataClass(it) } + .map { ChapterTable.toDataClass(userId, it) } } fun isInIncludedDownloadCategory( + userId: Int, logContext: KLogger = logger, mangaId: Int, ): Boolean { val log = KotlinLogging.logger("${logContext.name}::isInExcludedDownloadCategory($mangaId)") // Verify the manga is configured to be downloaded based on it's categories. - var mangaCategories = CategoryManga.getMangaCategories(mangaId).toSet() + var mangaCategories = CategoryManga.getMangaCategories(userId, mangaId).toSet() // if the manga has no categories, then it's implicitly in the default category if (mangaCategories.isEmpty()) { - val defaultCategory = Category.getCategoryById(Category.DEFAULT_CATEGORY_ID)!! + val defaultCategory = Category.getCategoryById(userId, Category.DEFAULT_CATEGORY_ID)!! mangaCategories = setOf(defaultCategory) } - val downloadCategoriesMap = Category.getCategoryList().groupBy { it.includeInDownload } + val downloadCategoriesMap = Category.getCategoryList(userId).groupBy { it.includeInDownload } val unsetCategories = downloadCategoriesMap[IncludeOrExclude.UNSET].orEmpty() // We only download if it's in the include list, and not in the exclude list. // Use the unset categories as the included categories if the included categories is diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/MangaList.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/MangaList.kt index 6ef5ad1096..a6a7a1459e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/MangaList.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/MangaList.kt @@ -17,6 +17,8 @@ import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable +import suwayomi.tachidesk.manga.model.table.getWithUserData import suwayomi.tachidesk.manga.model.table.toDataClass import java.time.Instant @@ -24,6 +26,7 @@ object MangaList { fun proxyThumbnailUrl(mangaId: Int): String = "/api/v1/manga/$mangaId/thumbnail" suspend fun getMangaList( + userId: Int, sourceId: Long, pageNum: Int = 1, popular: Boolean, @@ -42,18 +45,22 @@ object MangaList { throw Exception("Source $source doesn't support latest") } } - return mangasPage.processEntries(sourceId) + return mangasPage.processEntries(userId, sourceId) } - fun MangasPage.insertOrUpdate(sourceId: Long): List = + fun MangasPage.insertOrUpdate( + userId: Int, + sourceId: Long, + ): List = transaction { val existingMangaUrlsToId = MangaTable + .leftJoin(MangaUserTable) .selectAll() .where { (MangaTable.sourceReference eq sourceId) and (MangaTable.url inList mangas.map { it.url }) - }.associateBy { it[MangaTable.url] } + }.groupBy { it[MangaTable.url] } val existingMangaUrls = existingMangaUrlsToId.map { it.key } val mangasToInsert = mangas.filter { !existingMangaUrls.contains(it.url) } @@ -82,13 +89,14 @@ object MangaList { mangas .mapNotNull { sManga -> existingMangaUrlsToId[sManga.url]?.let { sManga to it } - }.filterNot { (_, resultRow) -> - resultRow[MangaTable.inLibrary] + }.filterNot { (_, resultRows) -> + resultRows.any { it[MangaUserTable.inLibrary] } // todo } if (mangaToUpdate.isNotEmpty()) { BatchUpdateStatement(MangaTable).apply { - mangaToUpdate.forEach { (sManga, manga) -> + mangaToUpdate.forEach { (sManga, mangas) -> + val manga = mangas.first() addBatch(EntityID(manga[MangaTable.id].value, MangaTable)) this[MangaTable.title] = sManga.title this[MangaTable.artist] = sManga.artist ?: manga[MangaTable.artist] @@ -112,7 +120,7 @@ object MangaList { val mangaUrlsToId = existingMangaUrlsToId - .mapValues { it.value[MangaTable.id].value } + insertedMangaUrlsToId + .mapValues { it.value.first()[MangaTable.id].value } + insertedMangaUrlsToId mangas.map { manga -> mangaUrlsToId[manga.url] @@ -120,12 +128,20 @@ object MangaList { } } - fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass { + fun MangasPage.processEntries( + userId: Int, + sourceId: Long, + ): PagedMangaListDataClass { val mangasPage = this val mangaList = transaction { - val mangaIds = insertOrUpdate(sourceId) - return@transaction MangaTable.selectAll().where { MangaTable.id inList mangaIds }.map { MangaTable.toDataClass(it) } + val mangaIds = insertOrUpdate(userId, sourceId) + return@transaction MangaTable + .getWithUserData(userId) + .selectAll() + .where { + MangaTable.id inList mangaIds + }.map { MangaTable.toDataClass(userId, it) } } return PagedMangaListDataClass( mangaList, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Search.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Search.kt index 9ab51dbcb3..7d3463db85 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Search.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Search.kt @@ -20,16 +20,18 @@ import uy.kohesive.injekt.injectLazy object Search { suspend fun sourceSearch( + userId: Int, sourceId: Long, searchTerm: String, pageNum: Int, ): PagedMangaListDataClass { val source = getCatalogueSourceOrStub(sourceId) val searchManga = source.getSearchManga(pageNum, searchTerm, getFilterListOf(source)) - return searchManga.processEntries(sourceId) + return searchManga.processEntries(userId, sourceId) } suspend fun sourceFilter( + userId: Int, sourceId: Long, pageNum: Int, filter: FilterData, @@ -37,7 +39,7 @@ object Search { val source = getCatalogueSourceOrStub(sourceId) val filterList = if (filter.filter != null) buildFilterList(sourceId, filter.filter) else source.getFilterList() val searchManga = source.getSearchManga(pageNum, filter.searchTerm ?: "", filterList) - return searchManga.processEntries(sourceId) + return searchManga.processEntries(userId, sourceId) } private val filterListCache = mutableMapOf() diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt index 916b2a61b9..f2a3b85e88 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Source.kt @@ -152,6 +152,7 @@ object Source { } fun modifyMeta( + userId: Int, sourceId: Long, key: String, value: String, @@ -159,7 +160,10 @@ object Source { transaction { val meta = transaction { - SourceMetaTable.selectAll().where { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) } + SourceMetaTable.selectAll().where { + SourceMetaTable.user eq userId and (SourceMetaTable.ref eq sourceId) and + (SourceMetaTable.key eq key) + } }.firstOrNull() if (meta == null) { @@ -167,9 +171,16 @@ object Source { it[SourceMetaTable.key] = key it[SourceMetaTable.value] = value it[SourceMetaTable.ref] = sourceId + it[SourceMetaTable.user] = userId } } else { - SourceMetaTable.update({ (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) }) { + SourceMetaTable.update( + { + (SourceMetaTable.user eq userId) and + (SourceMetaTable.ref eq sourceId) and + (SourceMetaTable.key eq key) + }, + ) { it[SourceMetaTable.value] = value } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt index bc5d245b33..1f769699ac 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt @@ -37,7 +37,9 @@ import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable import suwayomi.tachidesk.manga.model.table.SourceTable +import suwayomi.tachidesk.manga.model.table.getWithUserData import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.server.ApplicationDirs import suwayomi.tachidesk.server.serverConfig @@ -116,6 +118,7 @@ object ProtoBackupExport : ProtoBackupBase() { logger.info { "Creating automated backup..." } createBackup( + 1, // todo figure out how to make a global backup with all user data BackupFlags( includeManga = true, includeCategories = true, @@ -171,16 +174,19 @@ object ProtoBackupExport : ProtoBackupBase() { } } - fun createBackup(flags: BackupFlags): InputStream { + fun createBackup( + userId: Int, + flags: BackupFlags, + ): InputStream { // Create root object - val databaseManga = transaction { MangaTable.selectAll().where { MangaTable.inLibrary eq true } } + val databaseManga = transaction { MangaTable.getWithUserData(userId).selectAll().where { MangaUserTable.inLibrary eq true } } val backup: Backup = transaction { Backup( - backupManga(databaseManga, flags), - backupCategories(), + backupManga(userId, databaseManga, flags), + backupCategories(userId), backupExtensionInfo(databaseManga), ) } @@ -197,6 +203,7 @@ object ProtoBackupExport : ProtoBackupBase() { } private fun backupManga( + userId: Int, databaseManga: Query, flags: BackupFlags, ): List = @@ -212,7 +219,7 @@ object ProtoBackupExport : ProtoBackupBase() { genre = mangaRow[MangaTable.genre]?.split(", ") ?: emptyList(), status = MangaStatus.valueOf(mangaRow[MangaTable.status]).value, thumbnailUrl = mangaRow[MangaTable.thumbnail_url], - dateAdded = TimeUnit.SECONDS.toMillis(mangaRow[MangaTable.inLibraryAt]), + dateAdded = TimeUnit.SECONDS.toMillis(mangaRow[MangaUserTable.inLibraryAt]), viewer = 0, // not supported in Tachidesk updateStrategy = UpdateStrategy.valueOf(mangaRow[MangaTable.updateStrategy]), ) @@ -223,11 +230,12 @@ object ProtoBackupExport : ProtoBackupBase() { val chapters = transaction { ChapterTable + .getWithUserData(userId) .selectAll() .where { ChapterTable.manga eq mangaId } .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) .map { - ChapterTable.toDataClass(it) + ChapterTable.toDataClass(userId, it) } } @@ -249,12 +257,12 @@ object ProtoBackupExport : ProtoBackupBase() { } if (flags.includeCategories) { - backupManga.categories = CategoryManga.getMangaCategories(mangaId).map { it.order } + backupManga.categories = CategoryManga.getMangaCategories(userId, mangaId).map { it.order } } if (flags.includeTracking) { val tracks = - Track.getTrackRecordsByMangaId(mangaRow[MangaTable.id].value).mapNotNull { + Track.getTrackRecordsByMangaId(userId, mangaRow[MangaTable.id].value).mapNotNull { if (it.record == null) { null } else { @@ -286,9 +294,10 @@ object ProtoBackupExport : ProtoBackupBase() { backupManga } - private fun backupCategories(): List = + private fun backupCategories(userId: Int): List = CategoryTable .selectAll() + .where { CategoryTable.user eq userId } .orderBy(CategoryTable.order to SortOrder.ASC) .map { CategoryTable.toDataClass(it) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt index 36b4b45baf..4602ddb6e7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.sync.withLock import okio.buffer import okio.gzip import okio.source +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.batchInsert import org.jetbrains.exposed.sql.insert @@ -47,7 +48,9 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrackRecordDataClass import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ChapterUserTable import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable import java.io.InputStream import java.util.Date import java.util.Timer @@ -118,7 +121,10 @@ object ProtoBackupImport : ProtoBackupBase() { } @OptIn(DelicateCoroutinesApi::class) - suspend fun restore(sourceStream: InputStream): String { + suspend fun restore( + userId: Int, + sourceStream: InputStream, + ): String { val restoreId = System.currentTimeMillis().toString() logger.info { "restore($restoreId): queued" } @@ -126,20 +132,21 @@ object ProtoBackupImport : ProtoBackupBase() { updateRestoreState(restoreId, BackupRestoreState.Idle) GlobalScope.launch { - restoreLegacy(sourceStream, restoreId) + restoreLegacy(userId, sourceStream, restoreId) } return restoreId } suspend fun restoreLegacy( + userId: Int, sourceStream: InputStream, restoreId: String = "legacy", ): ValidationResult = backupMutex.withLock { try { logger.info { "restore($restoreId): restoring..." } - performRestore(restoreId, sourceStream) + performRestore(userId, restoreId, sourceStream) } catch (e: Exception) { logger.error(e) { "restore($restoreId): failed due to" } @@ -157,6 +164,7 @@ object ProtoBackupImport : ProtoBackupBase() { } private fun performRestore( + userId: Int, id: String, sourceStream: InputStream, ): ValidationResult { @@ -168,14 +176,14 @@ object ProtoBackupImport : ProtoBackupBase() { .use { it.readByteArray() } val backup = parser.decodeFromByteArray(Backup.serializer(), backupString) - val validationResult = validate(backup) + val validationResult = validate(userId, backup) restoreAmount = backup.backupManga.size + 1 // +1 for categories updateRestoreState(id, BackupRestoreState.RestoringCategories(backup.backupManga.size)) // Restore categories if (backup.backupCategories.isNotEmpty()) { - restoreCategories(backup.backupCategories) + restoreCategories(userId, backup.backupCategories) } val categoryMapping = @@ -184,7 +192,7 @@ object ProtoBackupImport : ProtoBackupBase() { val dbCategory = CategoryTable .selectAll() - .where { CategoryTable.name eq it.name } + .where { CategoryTable.name eq it.name and (CategoryTable.user eq userId) } .firstOrNull() val categoryId = dbCategory?.let { categoryResultRow -> @@ -209,6 +217,7 @@ object ProtoBackupImport : ProtoBackupBase() { ) restoreManga( + userId, backupManga = manga, backupCategories = backup.backupCategories, categoryMapping = categoryMapping, @@ -234,18 +243,22 @@ object ProtoBackupImport : ProtoBackupBase() { return validationResult } - private fun restoreCategories(backupCategories: List) { - val dbCategories = Category.getCategoryList() + private fun restoreCategories( + userId: Int, + backupCategories: List, + ) { + val dbCategories = Category.getCategoryList(userId) // Iterate over them and create missing categories backupCategories.forEach { category -> if (dbCategories.none { it.name == category.name }) { - Category.createCategory(category.name) + Category.createCategory(userId, category.name) } } } private fun restoreManga( + userId: Int, backupManga: BackupManga, backupCategories: List, categoryMapping: Map, @@ -256,7 +269,7 @@ object ProtoBackupImport : ProtoBackupBase() { val history = backupManga.history try { - restoreMangaData(manga, chapters, categories, history, backupManga.tracking, backupCategories, categoryMapping) + restoreMangaData(userId, manga, chapters, categories, history, backupManga.tracking, backupCategories, categoryMapping) } catch (e: Exception) { val sourceName = sourceMapping[manga.source] ?: manga.source.toString() errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}") @@ -265,6 +278,7 @@ object ProtoBackupImport : ProtoBackupBase() { @Suppress("UNUSED_PARAMETER") // TODO: remove private fun restoreMangaData( + userId: Int, manga: Manga, chapters: List, categories: List, @@ -302,41 +316,50 @@ object ProtoBackupImport : ProtoBackupBase() { it[sourceReference] = manga.source it[initialized] = manga.description != null - - it[inLibrary] = manga.favorite - - it[inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added) }.value + MangaUserTable.insert { + it[MangaUserTable.manga] = mangaId + it[MangaUserTable.user] = userId + it[MangaUserTable.inLibrary] = manga.favorite + it[MangaUserTable.inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added) + } + // delete thumbnail in case cached data still exists clearThumbnail(mangaId) // insert chapter data val chaptersLength = chapters.size - ChapterTable.batchInsert(chapters) { chapter -> - this[ChapterTable.url] = chapter.url - this[ChapterTable.name] = chapter.name - if (chapter.date_upload == 0L) { - this[ChapterTable.date_upload] = chapter.date_fetch - } else { - this[ChapterTable.date_upload] = chapter.date_upload - } - this[ChapterTable.chapter_number] = chapter.chapter_number - this[ChapterTable.scanlator] = chapter.scanlator + val rows = + ChapterTable.batchInsert(chapters) { chapter -> + this[ChapterTable.url] = chapter.url + this[ChapterTable.name] = chapter.name + if (chapter.date_upload == 0L) { + this[ChapterTable.date_upload] = chapter.date_fetch + } else { + this[ChapterTable.date_upload] = chapter.date_upload + } + this[ChapterTable.chapter_number] = chapter.chapter_number + this[ChapterTable.scanlator] = chapter.scanlator - this[ChapterTable.sourceOrder] = chaptersLength - chapter.source_order - this[ChapterTable.manga] = mangaId + this[ChapterTable.sourceOrder] = chaptersLength - chapter.source_order + this[ChapterTable.manga] = mangaId - this[ChapterTable.isRead] = chapter.read - this[ChapterTable.lastPageRead] = chapter.last_page_read - this[ChapterTable.isBookmarked] = chapter.bookmark + this[ChapterTable.fetchedAt] = TimeUnit.MILLISECONDS.toSeconds(chapter.date_fetch) + } - this[ChapterTable.fetchedAt] = TimeUnit.MILLISECONDS.toSeconds(chapter.date_fetch) + ChapterUserTable.batchInsert(rows) { row -> + val chapter = chapters.first { it.url == row[ChapterTable.url] } + this[ChapterUserTable.chapter] = row[ChapterTable.id].value + this[ChapterUserTable.user] = userId + this[ChapterUserTable.isRead] = chapter.read + this[ChapterUserTable.lastPageRead] = chapter.last_page_read + this[ChapterUserTable.isBookmarked] = chapter.bookmark } // insert categories categories.forEach { backupCategoryOrder -> - CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!) + CategoryManga.addMangaToCategory(userId, mangaId, categoryMapping[backupCategoryOrder]!!) } mangaId @@ -356,10 +379,26 @@ object ProtoBackupImport : ProtoBackupBase() { it[updateStrategy] = manga.update_strategy.name it[initialized] = dbManga[initialized] || manga.description != null + } - it[inLibrary] = manga.favorite || dbManga[inLibrary] - - it[inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added) + val mangaUserData = + MangaUserTable + .selectAll() + .where { + MangaUserTable.user eq userId and (MangaUserTable.manga eq mangaId) + }.firstOrNull() + if (mangaUserData != null) { + MangaUserTable.update({ MangaUserTable.id eq mangaUserData[ChapterUserTable.id] }) { + it[MangaUserTable.inLibrary] = manga.favorite || mangaUserData[MangaUserTable.inLibrary] + it[MangaUserTable.inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added) + } + } else { + MangaUserTable.insert { + it[MangaUserTable.manga] = mangaId + it[MangaUserTable.user] = userId + it[MangaUserTable.inLibrary] = manga.favorite + it[MangaUserTable.inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added) + } } // merge chapter data @@ -370,36 +409,62 @@ object ProtoBackupImport : ProtoBackupBase() { val dbChapter = dbChapters.find { it[ChapterTable.url] == chapter.url } if (dbChapter == null) { - ChapterTable.insert { - it[url] = chapter.url - it[name] = chapter.name - if (chapter.date_upload == 0L) { - it[date_upload] = chapter.date_fetch - } else { - it[date_upload] = chapter.date_upload + val chapterId = + ChapterTable.insertAndGetId { + it[url] = chapter.url + it[name] = chapter.name + if (chapter.date_upload == 0L) { + it[date_upload] = chapter.date_fetch + } else { + it[date_upload] = chapter.date_upload + } + it[chapter_number] = chapter.chapter_number + it[scanlator] = chapter.scanlator + + it[sourceOrder] = chaptersLength - chapter.source_order + it[ChapterTable.manga] = mangaId } - it[chapter_number] = chapter.chapter_number - it[scanlator] = chapter.scanlator - - it[sourceOrder] = chaptersLength - chapter.source_order - it[ChapterTable.manga] = mangaId - it[isRead] = chapter.read - it[lastPageRead] = chapter.last_page_read - it[isBookmarked] = chapter.bookmark + ChapterUserTable.insert { + it[ChapterUserTable.chapter] = mangaId + it[ChapterUserTable.user] = userId + it[ChapterUserTable.isRead] = chapter.read + it[ChapterUserTable.lastPageRead] = chapter.last_page_read + it[ChapterUserTable.isBookmarked] = chapter.bookmark } } else { - ChapterTable.update({ (ChapterTable.url eq dbChapter[ChapterTable.url]) and (ChapterTable.manga eq mangaId) }) { - it[isRead] = chapter.read || dbChapter[isRead] - it[lastPageRead] = max(chapter.last_page_read, dbChapter[lastPageRead]) - it[isBookmarked] = chapter.bookmark || dbChapter[isBookmarked] + val chapterId = dbChapter[ChapterTable.id].value + val chapterUserData = + ChapterUserTable + .selectAll() + .where { + MangaUserTable.user eq userId and (ChapterUserTable.chapter eq chapterId) + }.firstOrNull() + if (chapterUserData != null) { + ChapterUserTable.update({ ChapterUserTable.id eq chapterUserData[ChapterUserTable.id] }) { + it[isRead] = chapter.read || chapterUserData[isRead] + it[lastPageRead] = + max( + chapter.last_page_read, + chapterUserData[lastPageRead], + ) + it[isBookmarked] = chapter.bookmark || chapterUserData[isBookmarked] + } + } else { + ChapterUserTable.insert { + it[ChapterUserTable.chapter] = chapterId + it[MangaUserTable.user] = userId + it[isRead] = chapter.read + it[lastPageRead] = chapter.last_page_read + it[isBookmarked] = chapter.bookmark + } } } } // merge categories categories.forEach { backupCategoryOrder -> - CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!) + CategoryManga.addMangaToCategory(userId, mangaId, categoryMapping[backupCategoryOrder]!!) } mangaId @@ -408,7 +473,7 @@ object ProtoBackupImport : ProtoBackupBase() { val dbTrackRecordsByTrackerId = Tracker - .getTrackRecordsByMangaId(mangaId) + .getTrackRecordsByMangaId(userId, mangaId) .mapNotNull { it.record?.toTrack() } .associateBy { it.sync_id } @@ -438,8 +503,8 @@ object ProtoBackupImport : ProtoBackupBase() { } }.partition { (it.id ?: -1) > 0 } - existingTracks.forEach(Tracker::updateTrackRecord) - newTracks.forEach(Tracker::insertTrackRecord) + existingTracks.forEach { Tracker.updateTrackRecord(userId, it) } + newTracks.forEach { Tracker.insertTrackRecord(userId, it) } // TODO: insert/merge history } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupValidator.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupValidator.kt index c2bc175366..b03361f7d3 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupValidator.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupValidator.kt @@ -27,7 +27,10 @@ object ProtoBackupValidator { val missingSourceIds: List>, ) - fun validate(backup: Backup): ValidationResult { + fun validate( + userId: Int, + backup: Backup, + ): ValidationResult { if (backup.backupManga.isEmpty()) { throw Exception("Backup does not contain any manga.") } @@ -48,7 +51,7 @@ object ProtoBackupValidator { val missingTrackers = trackers .mapNotNull { TrackerManager.getTracker(it) } - .filter { !it.isLoggedIn } + .filter { !it.isLoggedIn(userId) } .map { it.name } .sorted() @@ -62,7 +65,10 @@ object ProtoBackupValidator { ) } - fun validate(sourceStream: InputStream): ValidationResult { + fun validate( + userId: Int, + sourceStream: InputStream, + ): ValidationResult { val backupString = sourceStream .source() @@ -71,6 +77,6 @@ object ProtoBackupValidator { .use { it.readByteArray() } val backup = ProtoBackupImport.parser.decodeFromByteArray(Backup.serializer(), backupString) - return validate(backup) + return validate(userId, backup) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt index ea7e47e36b..f4c0cdabad 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/chapter/ChapterForDownload.kt @@ -23,28 +23,36 @@ import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ChapterUserTable import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.PageTable +import suwayomi.tachidesk.manga.model.table.getWithUserData import suwayomi.tachidesk.manga.model.table.toDataClass suspend fun getChapterDownloadReady( + userId: Int, chapterId: Int? = null, chapterIndex: Int? = null, mangaId: Int? = null, ): ChapterDataClass { - val chapter = ChapterForDownload(chapterId, chapterIndex, mangaId) + val chapter = ChapterForDownload(userId, chapterId, chapterIndex, mangaId) return chapter.asDownloadReady() } -suspend fun getChapterDownloadReadyById(chapterId: Int): ChapterDataClass = getChapterDownloadReady(chapterId = chapterId) +suspend fun getChapterDownloadReadyById( + userId: Int, + chapterId: Int, +): ChapterDataClass = getChapterDownloadReady(userId = userId, chapterId = chapterId) suspend fun getChapterDownloadReadyByIndex( + userId: Int, chapterIndex: Int, mangaId: Int, -): ChapterDataClass = getChapterDownloadReady(chapterIndex = chapterIndex, mangaId = mangaId) +): ChapterDataClass = getChapterDownloadReady(userId = userId, chapterIndex = chapterIndex, mangaId = mangaId) private class ChapterForDownload( + private val userId: Int, optChapterId: Int? = null, optChapterIndex: Int? = null, optMangaId: Int? = null, @@ -78,7 +86,7 @@ private class ChapterForDownload( return asDataClass() } - private fun asDataClass() = ChapterTable.toDataClass(chapterEntry) + private fun asDataClass() = ChapterTable.toDataClass(userId, chapterEntry) // no need for user id init { chapterEntry = freshChapterEntry(optChapterId, optChapterIndex, optMangaId) @@ -98,6 +106,7 @@ private class ChapterForDownload( optMangaId: Int? = null, ) = transaction { ChapterTable + .getWithUserData(userId) .selectAll() .where { if (optChapterId != null) { @@ -159,8 +168,17 @@ private class ChapterForDownload( ChapterTable.update({ ChapterTable.id eq chapterId }) { val pageCount = pageList.size it[ChapterTable.pageCount] = pageCount - it[ChapterTable.lastPageRead] = chapterEntry[ChapterTable.lastPageRead].coerceAtMost(pageCount - 1) } + val pageCount = pageList.size + ChapterUserTable + .selectAll() + .where { + ChapterUserTable.chapter eq chapterId and (ChapterUserTable.lastPageRead greaterEq pageCount) + }.forEach { row -> + ChapterUserTable.update({ ChapterUserTable.id eq row[ChapterUserTable.id] }) { + it[ChapterUserTable.lastPageRead] = pageCount - 1 + } + } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt index a9b00b848f..db7c3cc794 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt @@ -311,21 +311,23 @@ object DownloadManager { .toList() } + // todo User accounts val mangas = transaction { chapters .distinctBy { chapter -> chapter[MangaTable.id] } - .map { MangaTable.toDataClass(it) } + .map { MangaTable.toDataClass(0, it) } .associateBy { it.id } } + // todo User accounts val inputPairs = transaction { chapters.map { Pair( // this should be safe because mangas is created above from chapters mangas[it[ChapterTable.manga].value]!!, - ChapterTable.toDataClass(it), + ChapterTable.toDataClass(0, it), ) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/Downloader.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/Downloader.kt index fdb0ad24a5..d4aec6b766 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/Downloader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/Downloader.kt @@ -131,7 +131,7 @@ class Downloader( download.state = Downloading step(DownloadUpdate(PROGRESS, download), true) - download.chapter = getChapterDownloadReadyByIndex(download.chapterIndex, download.mangaId) + download.chapter = getChapterDownloadReadyByIndex(0, download.chapterIndex, download.mangaId) ChapterDownloadHelper.download(download.mangaId, download.chapter.id, download, scope) { downloadChapter, immediate -> step(downloadChapter?.let { DownloadUpdate(PROGRESS, downloadChapter) }, immediate) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt index 55d693b895..3a7806fc7f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt @@ -24,8 +24,10 @@ import suwayomi.tachidesk.manga.model.dataclass.MangaTrackerDataClass import suwayomi.tachidesk.manga.model.dataclass.TrackSearchDataClass import suwayomi.tachidesk.manga.model.dataclass.TrackerDataClass import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ChapterUserTable import suwayomi.tachidesk.manga.model.table.TrackRecordTable import suwayomi.tachidesk.manga.model.table.TrackSearchTable +import suwayomi.tachidesk.manga.model.table.getWithUserData import suwayomi.tachidesk.manga.model.table.insertAll import suwayomi.tachidesk.server.generated.BuildConfig import java.io.InputStream @@ -34,10 +36,10 @@ object Track { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val logger = KotlinLogging.logger {} - fun getTrackerList(): List { + fun getTrackerList(userId: Int): List { val trackers = TrackerManager.services return trackers.map { - val isLogin = it.isLoggedIn + val isLogin = it.isLoggedIn(userId) val authUrl = if (isLogin) null else it.authUrl() TrackerDataClass( id = it.id, @@ -49,18 +51,24 @@ object Track { } } - suspend fun login(input: LoginInput) { + suspend fun login( + userId: Int, + input: LoginInput, + ) { val tracker = TrackerManager.getTracker(input.trackerId)!! if (input.callbackUrl != null) { - tracker.authCallback(input.callbackUrl) + tracker.authCallback(userId, input.callbackUrl) } else { - tracker.login(input.username ?: "", input.password ?: "") + tracker.login(userId, input.username ?: "", input.password ?: "") } } - fun logout(input: LogoutInput) { + suspend fun logout( + userId: Int, + input: LogoutInput, + ) { val tracker = TrackerManager.getTracker(input.trackerId)!! - tracker.logout() + tracker.logout(userId) } fun proxyThumbnailUrl(trackerId: Int): String = "/api/v1/track/$trackerId/thumbnail" @@ -71,12 +79,15 @@ object Track { return logo to "image/png" } - fun getTrackRecordsByMangaId(mangaId: Int): List { + fun getTrackRecordsByMangaId( + userId: Int, + mangaId: Int, + ): List { val recordMap = transaction { TrackRecordTable .selectAll() - .where { TrackRecordTable.mangaId eq mangaId } + .where { TrackRecordTable.mangaId eq mangaId and (TrackRecordTable.user eq userId) } .map { it.toTrackRecordDataClass() } }.associateBy { it.trackerId } @@ -88,7 +99,7 @@ object Track { Track.create(it.id).also { t -> t.score = record.score.toFloat() } - record.scoreString = it.displayScore(track) + record.scoreString = it.displayScore(userId, track) } MangaTrackerDataClass( id = it.id, @@ -96,15 +107,18 @@ object Track { icon = proxyThumbnailUrl(it.id), statusList = it.getStatusList(), statusTextMap = it.getStatusList().associateWith { k -> it.getStatus(k).orEmpty() }, - scoreList = it.getScoreList(), + scoreList = it.getScoreList(userId), record = record, ) } } - suspend fun search(input: SearchInput): List { + suspend fun search( + userId: Int, + input: SearchInput, + ): List { val tracker = TrackerManager.getTracker(input.trackerId)!! - val list = tracker.search(input.title) + val list = tracker.search(userId, input.title) return list.insertAll().map { TrackSearchDataClass( id = it[TrackSearchTable.id].value, @@ -132,6 +146,7 @@ object Track { } suspend fun bind( + userId: Int, mangaId: Int, trackerId: Int, remoteId: Long, @@ -149,7 +164,8 @@ object Track { .selectAll() .where { (TrackRecordTable.trackerId eq trackerId) and - (TrackRecordTable.remoteId eq remoteId) + (TrackRecordTable.remoteId eq remoteId) and + (TrackRecordTable.user eq userId) }.first() .toTrack() .apply { @@ -158,12 +174,12 @@ object Track { } val tracker = TrackerManager.getTracker(trackerId)!! - val chapter = queryMaxReadChapter(mangaId) + val chapter = queryMaxReadChapter(userId, mangaId) val hasReadChapters = chapter != null val chapterNumber = chapter?.get(ChapterTable.chapter_number) - tracker.bind(track, hasReadChapters) - val recordId = upsertTrackRecord(track) + tracker.bind(userId, track, hasReadChapters) + val recordId = upsertTrackRecord(userId, track) var lastChapterRead: Double? = null var startDate: Long? = null @@ -174,15 +190,16 @@ object Track { val oldestChapter = transaction { ChapterTable + .getWithUserData(userId) .selectAll() .where { - (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq true) - }.orderBy(ChapterTable.lastReadAt to SortOrder.ASC) + (ChapterTable.manga eq mangaId) and (ChapterUserTable.isRead eq true) + }.orderBy(ChapterUserTable.lastReadAt to SortOrder.ASC) .limit(1) .firstOrNull() } if (oldestChapter != null) { - startDate = oldestChapter[ChapterTable.lastReadAt] * 1000 + startDate = oldestChapter[ChapterUserTable.lastReadAt] * 1000 } } if (lastChapterRead != null || startDate != null) { @@ -192,51 +209,58 @@ object Track { lastChapterRead = lastChapterRead, startDate = startDate, ) - update(trackUpdate) + update(userId, trackUpdate) } } - suspend fun refresh(recordId: Int) { + suspend fun refresh( + userId: Int, + recordId: Int, + ) { val recordDb = transaction { - TrackRecordTable.selectAll().where { TrackRecordTable.id eq recordId }.first() + TrackRecordTable.selectAll().where { TrackRecordTable.id eq recordId and (TrackRecordTable.user eq userId) }.first() } val tracker = TrackerManager.getTracker(recordDb[TrackRecordTable.trackerId])!! val track = recordDb.toTrack() - tracker.refresh(track) - upsertTrackRecord(track) + tracker.refresh(userId, track) + upsertTrackRecord(userId, track) } suspend fun unbind( + userId: Int, recordId: Int, deleteRemoteTrack: Boolean? = false, ) { val recordDb = transaction { - TrackRecordTable.selectAll().where { TrackRecordTable.id eq recordId }.first() + TrackRecordTable.selectAll().where { TrackRecordTable.id eq recordId and (TrackRecordTable.user eq userId) }.first() } val tracker = TrackerManager.getTracker(recordDb[TrackRecordTable.trackerId]) if (deleteRemoteTrack == true && tracker is DeletableTrackService) { - tracker.delete(recordDb.toTrack()) + tracker.delete(userId, recordDb.toTrack()) } transaction { - TrackRecordTable.deleteWhere { TrackRecordTable.id eq recordId } + TrackRecordTable.deleteWhere { TrackRecordTable.id eq recordId and (TrackRecordTable.user eq userId) } } } - suspend fun update(input: UpdateInput) { + suspend fun update( + userId: Int, + input: UpdateInput, + ) { if (input.unbind == true) { - unbind(input.recordId) + unbind(userId, input.recordId) return } val recordDb = transaction { - TrackRecordTable.selectAll().where { TrackRecordTable.id eq input.recordId }.first() + TrackRecordTable.selectAll().where { TrackRecordTable.id eq input.recordId and (TrackRecordTable.user eq userId) }.first() } val tracker = TrackerManager.getTracker(recordDb[TrackRecordTable.trackerId])!! @@ -263,7 +287,7 @@ object Track { } } if (input.scoreString != null) { - val score = tracker.indexToScore(tracker.getScoreList().indexOf(input.scoreString)) + val score = tracker.indexToScore(userId, tracker.getScoreList(userId).indexOf(input.scoreString)) // conversion issues between Float <-> Double so convert to string before double recordDb[TrackRecordTable.score] = score.toString().toDouble() } @@ -275,24 +299,30 @@ object Track { } val track = recordDb.toTrack() - tracker.update(track) + tracker.update(userId, track) - upsertTrackRecord(track) + upsertTrackRecord(userId, track) } - fun asyncTrackChapter(mangaIds: Set) { - if (!TrackerManager.hasLoggedTracker()) { + fun asyncTrackChapter( + userId: Int, + mangaIds: Set, + ) { + if (!TrackerManager.hasLoggedTracker(userId)) { return } scope.launch { mangaIds.forEach { - trackChapter(it) + trackChapter(userId, it) } } } - suspend fun trackChapter(mangaId: Int) { - val chapter = queryMaxReadChapter(mangaId) + suspend fun trackChapter( + userId: Int, + mangaId: Int, + ) { + val chapter = queryMaxReadChapter(userId, mangaId) val chapterNumber = chapter?.get(ChapterTable.chapter_number) logger.info { @@ -300,21 +330,26 @@ object Track { } if (chapterNumber != null && chapterNumber > 0) { - trackChapter(mangaId, chapterNumber.toDouble()) + trackChapter(userId, mangaId, chapterNumber.toDouble()) } } - private fun queryMaxReadChapter(mangaId: Int): ResultRow? = + private fun queryMaxReadChapter( + userId: Int, + mangaId: Int, + ): ResultRow? = transaction { ChapterTable + .getWithUserData(userId) .selectAll() - .where { (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq true) } + .where { (ChapterTable.manga eq mangaId) and (ChapterUserTable.isRead eq true) } .orderBy(ChapterTable.chapter_number to SortOrder.DESC) .limit(1) .firstOrNull() } private suspend fun trackChapter( + userId: Int, mangaId: Int, chapterNumber: Double, ) { @@ -322,13 +357,13 @@ object Track { transaction { TrackRecordTable .selectAll() - .where { TrackRecordTable.mangaId eq mangaId } + .where { TrackRecordTable.mangaId eq mangaId and (TrackRecordTable.user eq userId) } .toList() } records.forEach { try { - trackChapterForTracker(it, chapterNumber) + trackChapterForTracker(userId, it, chapterNumber) } catch (e: Exception) { KotlinLogging .logger("${logger.name}::trackChapter(mangaId= $mangaId, chapterNumber= $chapterNumber)") @@ -338,6 +373,7 @@ object Track { } private suspend fun trackChapterForTracker( + userId: Int, it: ResultRow, chapterNumber: Double, ) { @@ -357,13 +393,13 @@ object Track { return } - if (!tracker.isLoggedIn) { - upsertTrackRecord(track) + if (!tracker.isLoggedIn(userId)) { + upsertTrackRecord(userId, track) return } - tracker.refresh(track) - upsertTrackRecord(track) + tracker.refresh(userId, track) + upsertTrackRecord(userId, track) val lastChapterRead = track.last_chapter_read @@ -371,34 +407,42 @@ object Track { if (chapterNumber > lastChapterRead) { track.last_chapter_read = chapterNumber.toFloat() - tracker.update(track, true) - upsertTrackRecord(track) + tracker.update(userId, track, true) + upsertTrackRecord(userId, track) } } - fun upsertTrackRecord(track: Track): Int = + fun upsertTrackRecord( + userId: Int, + track: Track, + ): Int = transaction { val existingRecord = TrackRecordTable .selectAll() .where { (TrackRecordTable.mangaId eq track.manga_id) and - (TrackRecordTable.trackerId eq track.sync_id) + (TrackRecordTable.trackerId eq track.sync_id) and + (TrackRecordTable.user eq userId) }.singleOrNull() if (existingRecord != null) { - updateTrackRecord(track) + updateTrackRecord(userId, track) existingRecord[TrackRecordTable.id].value } else { - insertTrackRecord(track) + insertTrackRecord(userId, track) } } - fun updateTrackRecord(track: Track): Int = + fun updateTrackRecord( + userId: Int, + track: Track, + ): Int = transaction { TrackRecordTable.update( { - (TrackRecordTable.mangaId eq track.manga_id) and + (TrackRecordTable.user eq userId) and + (TrackRecordTable.mangaId eq track.manga_id) and (TrackRecordTable.trackerId eq track.sync_id) }, ) { @@ -415,7 +459,10 @@ object Track { } } - fun insertTrackRecord(track: Track): Int = + fun insertTrackRecord( + userId: Int, + track: Track, + ): Int = transaction { TrackRecordTable .insertAndGetId { @@ -431,6 +478,7 @@ object Track { it[remoteUrl] = track.tracking_url it[startDate] = track.started_reading_date it[finishDate] = track.finished_reading_date + it[user] = userId }.value } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTrackService.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTrackService.kt index bf9e6cd765..b1c3470bfe 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTrackService.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTrackService.kt @@ -6,5 +6,8 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.Track * For track services api that support deleting a manga entry for a user's list */ interface DeletableTrackService { - suspend fun delete(track: Track) + suspend fun delete( + userId: Int, + track: Track, + ) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt index 896f998a50..43d9db07fd 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt @@ -22,7 +22,7 @@ abstract class Tracker( abstract val supportsTrackDeletion: Boolean - override fun toString() = "$name ($id) (isLoggedIn= $isLoggedIn, isAuthExpired= ${getIfAuthExpired()})" + override fun toString() = "$name ($id)" abstract fun getLogo(): String @@ -36,60 +36,77 @@ abstract class Tracker( abstract fun getCompletionStatus(): Int - abstract fun getScoreList(): List + abstract fun getScoreList(userId: Int): List - open fun indexToScore(index: Int): Float = index.toFloat() + open fun indexToScore( + userId: Int, + index: Int, + ): Float = index.toFloat() - abstract fun displayScore(track: Track): String + abstract fun displayScore( + userId: Int, + track: Track, + ): String abstract suspend fun update( + userId: Int, track: Track, didReadChapter: Boolean = false, ): Track abstract suspend fun bind( + userId: Int, track: Track, hasReadChapters: Boolean = false, ): Track - abstract suspend fun search(query: String): List + abstract suspend fun search( + userId: Int, + query: String, + ): List - abstract suspend fun refresh(track: Track): Track + abstract suspend fun refresh( + userId: Int, + track: Track, + ): Track open fun authUrl(): String? = null - open suspend fun authCallback(url: String) {} + open suspend fun authCallback( + userId: Int, + url: String, + ) {} abstract suspend fun login( + userId: Int, username: String, password: String, ) - open fun logout() { - trackPreferences.setTrackCredentials(this, "", "") + open suspend fun logout(userId: Int) { + trackPreferences.setTrackCredentials(userId, this, "", "") } - open val isLoggedIn: Boolean - get() { - return getUsername().isNotEmpty() && - getPassword().isNotEmpty() - } + open fun isLoggedIn(userId: Int): Boolean = + getUsername(userId).isNotEmpty() && + getPassword(userId).isNotEmpty() - fun getUsername() = trackPreferences.getTrackUsername(this) ?: "" + fun getUsername(userId: Int) = trackPreferences.getTrackUsername(userId, this) ?: "" - fun getPassword() = trackPreferences.getTrackPassword(this) ?: "" + fun getPassword(userId: Int) = trackPreferences.getTrackPassword(userId, this) ?: "" fun saveCredentials( + userId: Int, username: String, password: String, ) { - trackPreferences.setTrackCredentials(this, username, password) + trackPreferences.setTrackCredentials(userId, this, username, password) } - fun getIfAuthExpired(): Boolean = trackPreferences.trackAuthExpired(this) + fun getIfAuthExpired(userId: Int): Boolean = trackPreferences.trackAuthExpired(userId, this) - fun setAuthExpired() { - trackPreferences.setTrackTokenExpired(this) + fun setAuthExpired(userId: Int) { + trackPreferences.setTrackTokenExpired(userId, this) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt index feab34a3cb..8ea179f072 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt @@ -30,5 +30,5 @@ object TrackerManager { fun getTracker(id: Int) = services.find { it.id == id } - fun hasLoggedTracker() = services.any { it.isLoggedIn } + fun hasLoggedTracker(userId: Int) = services.any { it.isLoggedIn(userId) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt index 1fc9f8730a..dad82d2565 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt @@ -12,17 +12,26 @@ object TrackerPreferences { Injekt.get().getSharedPreferences("tracker", Context.MODE_PRIVATE) private val logger = KotlinLogging.logger {} - fun getTrackUsername(sync: Tracker) = preferenceStore.getString(trackUsername(sync.id), "") + fun getTrackUsername( + userId: Int, + sync: Tracker, + ) = preferenceStore.getString(trackUsername(userId, sync.id), "") - fun getTrackPassword(sync: Tracker) = preferenceStore.getString(trackPassword(sync.id), "") + fun getTrackPassword( + userId: Int, + sync: Tracker, + ) = preferenceStore.getString(trackPassword(userId, sync.id), "") - fun trackAuthExpired(tracker: Tracker) = - preferenceStore.getBoolean( - trackTokenExpired(tracker.id), - false, - ) + fun trackAuthExpired( + userId: Int, + tracker: Tracker, + ) = preferenceStore.getBoolean( + trackTokenExpired(userId, tracker.id), + false, + ) fun setTrackCredentials( + userId: Int, sync: Tracker, username: String, password: String, @@ -30,15 +39,19 @@ object TrackerPreferences { logger.debug { "setTrackCredentials: id=${sync.id} username=$username" } preferenceStore .edit() - .putString(trackUsername(sync.id), username) - .putString(trackPassword(sync.id), password) - .putBoolean(trackTokenExpired(sync.id), false) + .putString(trackUsername(userId, sync.id), username) + .putString(trackPassword(userId, sync.id), password) + .putBoolean(trackTokenExpired(userId, sync.id), false) .apply() } - fun getTrackToken(sync: Tracker) = preferenceStore.getString(trackToken(sync.id), "") + fun getTrackToken( + userId: Int, + sync: Tracker, + ) = preferenceStore.getString(trackToken(userId, sync.id), "") fun setTrackToken( + userId: Int, sync: Tracker, token: String?, ) { @@ -46,44 +59,66 @@ object TrackerPreferences { if (token == null) { preferenceStore .edit() - .remove(trackToken(sync.id)) - .putBoolean(trackTokenExpired(sync.id), false) + .remove(trackToken(userId, sync.id)) + .putBoolean(trackTokenExpired(userId, sync.id), false) .apply() } else { preferenceStore .edit() - .putString(trackToken(sync.id), token) - .putBoolean(trackTokenExpired(sync.id), false) + .putString(trackToken(userId, sync.id), token) + .putBoolean(trackTokenExpired(userId, sync.id), false) .apply() } } - fun setTrackTokenExpired(sync: Tracker) { + fun setTrackTokenExpired( + userId: Int, + sync: Tracker, + ) { preferenceStore .edit() - .putBoolean(trackTokenExpired(sync.id), true) + .putBoolean(trackTokenExpired(userId, sync.id), true) .apply() } - fun getScoreType(sync: Tracker) = preferenceStore.getString(scoreType(sync.id), Anilist.POINT_10) + fun getScoreType( + userId: Int, + sync: Tracker, + ) = preferenceStore.getString(scoreType(userId, sync.id), Anilist.POINT_10) fun setScoreType( + userId: Int, sync: Tracker, scoreType: String, ) = preferenceStore .edit() - .putString(scoreType(sync.id), scoreType) + .putString(scoreType(userId, sync.id), scoreType) .apply() - fun autoUpdateTrack() = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true) + fun autoUpdateTrack(userId: Int) = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true) - fun trackUsername(trackerId: Int) = "pref_mangasync_username_$trackerId" + fun trackUsername( + userId: Int, + trackerId: Int, + ) = "pref_mangasync_username_${userId}_$trackerId" - private fun trackPassword(trackerId: Int) = "pref_mangasync_password_$trackerId" + private fun trackPassword( + userId: Int, + trackerId: Int, + ) = "pref_mangasync_password_${userId}_$trackerId" - private fun trackToken(trackerId: Int) = "track_token_$trackerId" + private fun trackToken( + userId: Int, + trackerId: Int, + ) = "track_token_${userId}_$trackerId" - private fun trackTokenExpired(trackerId: Int) = "track_token_expired_$trackerId" + private fun trackTokenExpired( + userId: Int, + trackerId: Int, + ) = "track_token_expired_${userId}_$trackerId" - private fun scoreType(trackerId: Int) = "score_type_$trackerId" + private fun scoreType( + userId: Int, + trackerId: Int, + ) = "score_type_${userId}_$trackerId" } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt index c219d17a59..57fd2de85c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt @@ -2,6 +2,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.anilist import android.annotation.StringRes import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.reactivecircus.cache4k.Cache import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService @@ -11,6 +12,7 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch import uy.kohesive.injekt.injectLazy import java.io.IOException +import kotlin.time.Duration.Companion.hours class Anilist( id: Int, @@ -35,9 +37,26 @@ class Anilist( private val json: Json by injectLazy() - private val interceptor by lazy { AnilistInterceptor(this) } + private val interceptors = + Cache + .Builder() + .expireAfterAccess(1.hours) + .build() + private val apis = + Cache + .Builder() + .expireAfterAccess(1.hours) + .build() + + suspend fun interceptor(userId: Int): AnilistInterceptor = + interceptors.get(userId) { + AnilistInterceptor(userId, this) + } - private val api by lazy { AnilistApi(client, interceptor) } + suspend fun api(userId: Int): AnilistApi = + apis.get(userId) { + AnilistApi(client, interceptor(userId)) + } override val supportsReadingDates: Boolean = true @@ -65,8 +84,8 @@ class Anilist( override fun getCompletionStatus(): Int = COMPLETED - override fun getScoreList(): List = - when (trackPreferences.getScoreType(this)) { + override fun getScoreList(userId: Int): List = + when (trackPreferences.getScoreType(userId, this)) { // 10 point POINT_10 -> IntRange(0, 10).map(Int::toString) // 100 point @@ -80,8 +99,11 @@ class Anilist( else -> throw Exception("Unknown score type") } - override fun indexToScore(index: Int): Float = - when (trackPreferences.getScoreType(this)) { + override fun indexToScore( + userId: Int, + index: Int, + ): Float = + when (trackPreferences.getScoreType(userId, this)) { // 10 point POINT_10 -> index * 10f // 100 point @@ -103,9 +125,12 @@ class Anilist( else -> throw Exception("Unknown score type") } - override fun displayScore(track: Track): String { + override fun displayScore( + userId: Int, + track: Track, + ): String { val score = track.score - return when (val type = trackPreferences.getScoreType(this)) { + return when (val type = trackPreferences.getScoreType(userId, this)) { POINT_5 -> when (score) { 0f -> "0 ★" @@ -122,16 +147,20 @@ class Anilist( } } - private suspend fun add(track: Track): Track = api.addLibManga(track) + private suspend fun add( + userId: Int, + track: Track, + ): Track = api(userId).addLibManga(track) override suspend fun update( + userId: Int, track: Track, didReadChapter: Boolean, ): Track { // If user was using API v1 fetch library_id if (track.library_id == null || track.library_id!! == 0L) { val libManga = - api.findLibManga(track, getUsername().toInt()) + api(userId).findLibManga(track, getUsername(userId).toInt()) ?: throw Exception("$track not found on user library") track.library_id = libManga.library_id } @@ -150,23 +179,27 @@ class Anilist( } } - return api.updateLibManga(track) + return api(userId).updateLibManga(track) } - override suspend fun delete(track: Track) { + override suspend fun delete( + userId: Int, + track: Track, + ) { if (track.library_id == null || track.library_id!! == 0L) { - val libManga = api.findLibManga(track, getUsername().toInt()) ?: return + val libManga = api(userId).findLibManga(track, getUsername(userId).toInt()) ?: return track.library_id = libManga.library_id } - api.deleteLibManga(track) + api(userId).deleteLibManga(track) } override suspend fun bind( + userId: Int, track: Track, hasReadChapters: Boolean, ): Track { - val remoteTrack = api.findLibManga(track, getUsername().toInt()) + val remoteTrack = api(userId).findLibManga(track, getUsername(userId).toInt()) return if (remoteTrack != null) { track.copyPersonalFrom(remoteTrack) track.library_id = remoteTrack.library_id @@ -176,19 +209,25 @@ class Anilist( track.status = if (isRereading.not() && hasReadChapters) READING else track.status } - update(track) + update(userId, track) } else { // Set default fields if it's not found in the list track.status = if (hasReadChapters) READING else PLAN_TO_READ track.score = 0F - add(track) + add(userId, track) } } - override suspend fun search(query: String): List = api.search(query) + override suspend fun search( + userId: Int, + query: String, + ): List = api(userId).search(query) - override suspend fun refresh(track: Track): Track { - val remoteTrack = api.getLibManga(track, getUsername().toInt()) + override suspend fun refresh( + userId: Int, + track: Track, + ): Track { + val remoteTrack = api(userId).getLibManga(track, getUsername(userId).toInt()) track.copyPersonalFrom(remoteTrack) track.title = remoteTrack.title track.total_chapters = remoteTrack.total_chapters @@ -197,44 +236,54 @@ class Anilist( override fun authUrl(): String = AnilistApi.authUrl().toString() - override suspend fun authCallback(url: String) { + override suspend fun authCallback( + userId: Int, + url: String, + ) { val token = url.extractToken("access_token") ?: throw IOException("cannot find token") - login(token) + login(userId, token) } override suspend fun login( + userId: Int, username: String, password: String, - ) = login(password) + ) = login(userId, password) - private suspend fun login(token: String) { + private suspend fun login( + userId: Int, + token: String, + ) { try { logger.debug { "login $token" } - val oauth = api.createOAuth(token) - interceptor.setAuth(oauth) - val (username, scoreType) = api.getCurrentUser() - trackPreferences.setScoreType(this, scoreType) - saveCredentials(username.toString(), oauth.access_token) + val oauth = api(userId).createOAuth(token) + interceptor(userId).setAuth(oauth) + val (username, scoreType) = api(userId).getCurrentUser() + trackPreferences.setScoreType(userId, this, scoreType) + saveCredentials(userId, username.toString(), oauth.access_token) } catch (e: Throwable) { logger.error(e) { "oauth err" } - logout() + logout(userId) throw e } } - override fun logout() { - super.logout() - trackPreferences.setTrackToken(this, null) - interceptor.setAuth(null) + override suspend fun logout(userId: Int) { + super.logout(userId) + trackPreferences.setTrackToken(userId, this, null) + interceptor(userId).setAuth(null) } - fun saveOAuth(oAuth: OAuth?) { - trackPreferences.setTrackToken(this, json.encodeToString(oAuth)) + fun saveOAuth( + userId: Int, + oAuth: OAuth?, + ) { + trackPreferences.setTrackToken(userId, this, json.encodeToString(oAuth)) } - fun loadOAuth(): OAuth? = + fun loadOAuth(userId: Int): OAuth? = try { - json.decodeFromString(trackPreferences.getTrackToken(this)!!) + json.decodeFromString(trackPreferences.getTrackToken(userId, this)!!) } catch (e: Exception) { logger.error(e) { "loadOAuth err" } null diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt index 3964cb9fa6..247ff81439 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt @@ -6,6 +6,7 @@ import suwayomi.tachidesk.manga.impl.track.tracker.TokenExpired import java.io.IOException class AnilistInterceptor( + private val userId: Int, private val anilist: Anilist, ) : Interceptor { /** @@ -20,18 +21,18 @@ class AnilistInterceptor( } init { - oauth = anilist.loadOAuth() + oauth = anilist.loadOAuth(userId) } override fun intercept(chain: Interceptor.Chain): Response { - if (anilist.getIfAuthExpired()) { + if (anilist.getIfAuthExpired(userId)) { throw TokenExpired() } val originalRequest = chain.request() // Refresh access token if null or expired. if (oauth?.isExpired() == true) { - anilist.setAuthExpired() + anilist.setAuthExpired(userId) throw TokenExpired() } @@ -56,6 +57,6 @@ class AnilistInterceptor( */ fun setAuth(oauth: OAuth?) { this.oauth = oauth - anilist.saveOAuth(oauth) + anilist.saveOAuth(userId, oauth) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdates.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdates.kt index 639aab84b8..d82c7c33b2 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdates.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdates.kt @@ -1,5 +1,6 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates +import io.github.reactivecircus.cache4k.Cache import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService import suwayomi.tachidesk.manga.impl.track.tracker.Tracker import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.ListItem @@ -8,6 +9,7 @@ import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.copyTo import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.toTrackSearch import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import kotlin.time.Duration.Companion.hours class MangaUpdates( id: Int, @@ -36,9 +38,26 @@ class MangaUpdates( override val supportsTrackDeletion: Boolean = true - private val interceptor by lazy { MangaUpdatesInterceptor(this) } + private val interceptors = + Cache + .Builder() + .expireAfterAccess(1.hours) + .build() + private val apis = + Cache + .Builder() + .expireAfterAccess(1.hours) + .build() + + suspend fun interceptor(userId: Int): MangaUpdatesInterceptor = + interceptors.get(userId) { + MangaUpdatesInterceptor(userId, this) + } - private val api by lazy { MangaUpdatesApi(interceptor, client) } + suspend fun api(userId: Int): MangaUpdatesApi = + apis.get(userId) { + MangaUpdatesApi(interceptor(userId), client) + } override fun getLogo(): String = "/static/tracker/manga_updates.png" @@ -60,49 +79,66 @@ class MangaUpdates( override fun getCompletionStatus(): Int = COMPLETE_LIST - override fun getScoreList(): List = SCORE_LIST + override fun getScoreList(userId: Int): List = SCORE_LIST - override fun indexToScore(index: Int): Float = if (index == 0) 0f else SCORE_LIST[index].toFloat() + override fun indexToScore( + userId: Int, + index: Int, + ): Float = if (index == 0) 0f else SCORE_LIST[index].toFloat() - override fun displayScore(track: Track): String = track.score.toString() + override fun displayScore( + userId: Int, + track: Track, + ): String = track.score.toString() override suspend fun update( + userId: Int, track: Track, didReadChapter: Boolean, ): Track { if (track.status != COMPLETE_LIST && didReadChapter) { track.status = READING_LIST } - api.updateSeriesListItem(track) + api(userId).updateSeriesListItem(track) return track } - override suspend fun delete(track: Track) { - api.deleteSeriesFromList(track) + override suspend fun delete( + userId: Int, + track: Track, + ) { + api(userId).deleteSeriesFromList(track) } override suspend fun bind( + userId: Int, track: Track, hasReadChapters: Boolean, ): Track = try { - val (series, rating) = api.getSeriesListItem(track) + val (series, rating) = api(userId).getSeriesListItem(track) track.copyFrom(series, rating) } catch (e: Exception) { track.score = 0f - api.addSeriesToList(track, hasReadChapters) + api(userId).addSeriesToList(track, hasReadChapters) track } - override suspend fun search(query: String): List = - api + override suspend fun search( + userId: Int, + query: String, + ): List = + api(userId) .search(query) .map { it.toTrackSearch(id) } - override suspend fun refresh(track: Track): Track { - val (series, rating) = api.getSeriesListItem(track) + override suspend fun refresh( + userId: Int, + track: Track, + ): Track { + val (series, rating) = api(userId).getSeriesListItem(track) return track.copyFrom(series, rating) } @@ -116,13 +152,14 @@ class MangaUpdates( } override suspend fun login( + userId: Int, username: String, password: String, ) { - val authenticated = api.authenticate(username, password) ?: throw Throwable("Unable to login") - saveCredentials(authenticated.uid.toString(), authenticated.sessionToken) - interceptor.newAuth(authenticated.sessionToken) + val authenticated = api(userId).authenticate(username, password) ?: throw Throwable("Unable to login") + saveCredentials(userId, authenticated.uid.toString(), authenticated.sessionToken) + interceptor(userId).newAuth(authenticated.sessionToken) } - fun restoreSession(): String? = trackPreferences.getTrackPassword(this) + fun restoreSession(userId: Int): String? = trackPreferences.getTrackPassword(userId, this) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesInterceptor.kt index ba9f5e2033..cec0b4b50e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesInterceptor.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesInterceptor.kt @@ -6,9 +6,10 @@ import suwayomi.tachidesk.server.generated.BuildConfig import java.io.IOException class MangaUpdatesInterceptor( + userId: Int, mangaUpdates: MangaUpdates, ) : Interceptor { - private var token: String? = mangaUpdates.restoreSession() + private var token: String? = mangaUpdates.restoreSession(userId) override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt index 3ae96b927c..c8fb6aeece 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt @@ -2,6 +2,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist import android.annotation.StringRes import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.reactivecircus.cache4k.Cache import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService @@ -11,6 +12,7 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch import uy.kohesive.injekt.injectLazy import java.io.IOException +import kotlin.time.Duration.Companion.hours class MyAnimeList( id: Int, @@ -32,8 +34,26 @@ class MyAnimeList( private val json: Json by injectLazy() - private val interceptor by lazy { MyAnimeListInterceptor(this) } - private val api by lazy { MyAnimeListApi(client, interceptor) } + private val interceptors = + Cache + .Builder() + .expireAfterAccess(1.hours) + .build() + private val apis = + Cache + .Builder() + .expireAfterAccess(1.hours) + .build() + + suspend fun interceptor(userId: Int): MyAnimeListInterceptor = + interceptors.get(userId) { + MyAnimeListInterceptor(userId, this) + } + + suspend fun api(userId: Int): MyAnimeListApi = + apis.get(userId) { + MyAnimeListApi(client, interceptor(userId)) + } override val supportsReadingDates: Boolean = true @@ -61,13 +81,20 @@ class MyAnimeList( override fun getCompletionStatus(): Int = COMPLETED - override fun getScoreList(): List = IntRange(0, 10).map(Int::toString) + override fun getScoreList(userId: Int): List = IntRange(0, 10).map(Int::toString) - override fun displayScore(track: Track): String = track.score.toInt().toString() + override fun displayScore( + userId: Int, + track: Track, + ): String = track.score.toInt().toString() - private suspend fun add(track: Track): Track = api.updateItem(track) + private suspend fun add( + userId: Int, + track: Track, + ): Track = api(userId).updateItem(track) override suspend fun update( + userId: Int, track: Track, didReadChapter: Boolean, ): Track { @@ -85,18 +112,22 @@ class MyAnimeList( } } - return api.updateItem(track) + return api(userId).updateItem(track) } - override suspend fun delete(track: Track) { - api.deleteItem(track) + override suspend fun delete( + userId: Int, + track: Track, + ) { + api(userId).deleteItem(track) } override suspend fun bind( + userId: Int, track: Track, hasReadChapters: Boolean, ): Track { - val remoteTrack = api.findListItem(track) + val remoteTrack = api(userId).findListItem(track) return if (remoteTrack != null) { track.copyPersonalFrom(remoteTrack) track.media_id = remoteTrack.media_id @@ -106,72 +137,90 @@ class MyAnimeList( track.status = if (isRereading.not() && hasReadChapters) READING else track.status } - update(track) + update(userId, track) } else { // Set default fields if it's not found in the list track.status = if (hasReadChapters) READING else PLAN_TO_READ track.score = 0F - add(track) + add(userId, track) } } - override suspend fun search(query: String): List { + override suspend fun search( + userId: Int, + query: String, + ): List { if (query.startsWith(SEARCH_ID_PREFIX)) { query.substringAfter(SEARCH_ID_PREFIX).toIntOrNull()?.let { id -> - return listOf(api.getMangaDetails(id)) + return listOf(api(userId).getMangaDetails(id)) } } if (query.startsWith(SEARCH_LIST_PREFIX)) { query.substringAfter(SEARCH_LIST_PREFIX).let { title -> - return api.findListItems(title) + return api(userId).findListItems(title) } } - return api.search(query) + return api(userId).search(query) } - override suspend fun refresh(track: Track): Track = api.findListItem(track) ?: add(track) + override suspend fun refresh( + userId: Int, + track: Track, + ): Track = + api(userId).findListItem(track) + ?: add(userId, track) override fun authUrl(): String = MyAnimeListApi.authUrl().toString() - override suspend fun authCallback(url: String) { + override suspend fun authCallback( + userId: Int, + url: String, + ) { val code = url.extractToken("code") ?: throw IOException("cannot find token") - login(code) + login(userId, code) } override suspend fun login( + userId: Int, username: String, password: String, - ) = login(password) + ) = login(userId, password) - suspend fun login(authCode: String) { + suspend fun login( + userId: Int, + authCode: String, + ) { try { logger.debug { "login $authCode" } - val oauth = api.getAccessToken(authCode) - interceptor.setAuth(oauth) - val username = api.getCurrentUser() - saveCredentials(username, oauth.access_token) + val oauth = api(userId).getAccessToken(authCode) + interceptor(userId).setAuth(oauth) + val username = api(userId).getCurrentUser() + saveCredentials(userId, username, oauth.access_token) } catch (e: Throwable) { logger.error(e) { "oauth err" } - logout() + logout(userId) throw e } } - override fun logout() { - super.logout() - trackPreferences.setTrackToken(this, null) - interceptor.setAuth(null) + override suspend fun logout(userId: Int) { + super.logout(userId) + trackPreferences.setTrackToken(userId, this, null) + interceptor(userId).setAuth(null) } - fun saveOAuth(oAuth: OAuth?) { - trackPreferences.setTrackToken(this, json.encodeToString(oAuth)) + fun saveOAuth( + userId: Int, + oAuth: OAuth?, + ) { + trackPreferences.setTrackToken(userId, this, json.encodeToString(oAuth)) } - fun loadOAuth(): OAuth? = + fun loadOAuth(userId: Int): OAuth? = try { - json.decodeFromString(trackPreferences.getTrackToken(this)!!) + json.decodeFromString(trackPreferences.getTrackToken(userId, this)!!) } catch (e: Exception) { logger.error(e) { "loadOAuth err" } null diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt index 80ca992960..a406e413fa 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt @@ -11,14 +11,15 @@ import uy.kohesive.injekt.injectLazy import java.io.IOException class MyAnimeListInterceptor( + private val userId: Int, private val myanimelist: MyAnimeList, ) : Interceptor { private val json: Json by injectLazy() - private var oauth: OAuth? = myanimelist.loadOAuth() + private var oauth: OAuth? = myanimelist.loadOAuth(userId) override fun intercept(chain: Interceptor.Chain): Response { - if (myanimelist.getIfAuthExpired()) { + if (myanimelist.getIfAuthExpired(userId)) { throw TokenExpired() } val originalRequest = chain.request() @@ -48,12 +49,12 @@ class MyAnimeListInterceptor( */ fun setAuth(oauth: OAuth?) { this.oauth = oauth - myanimelist.saveOAuth(oauth) + myanimelist.saveOAuth(userId, oauth) } private fun refreshToken(chain: Interceptor.Chain): OAuth = synchronized(this) { - if (myanimelist.getIfAuthExpired()) throw TokenExpired() + if (myanimelist.getIfAuthExpired(userId)) throw TokenExpired() oauth?.takeUnless { it.isExpired() }?.let { return@synchronized it } val response = @@ -64,7 +65,7 @@ class MyAnimeListInterceptor( } if (response.code == 401) { - myanimelist.setAuthExpired() + myanimelist.setAuthExpired(userId) throw TokenExpired() } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/update/Updater.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/update/Updater.kt index af220b5b37..3783c0439d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/update/Updater.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/update/Updater.kt @@ -122,7 +122,8 @@ class Updater : IUpdater { lastAutomatedUpdate, )})" } - addCategoriesToUpdateQueue(Category.getCategoryList(), clear = true, forceAll = false) + // todo User accounts + addCategoriesToUpdateQueue(Category.getCategoryList(1), clear = true, forceAll = false) } catch (e: Exception) { logger.error(e) { "autoUpdateTask: failed due to" } } @@ -234,9 +235,9 @@ class Updater : IUpdater { try { logger.info { "Updating ${job.manga}" } if (serverConfig.updateMangas.value || !job.manga.initialized) { - Manga.getManga(job.manga.id, true) + Manga.getManga(0, job.manga.id, true) } - Chapter.getChapterList(job.manga.id, true) + Chapter.getChapterList(0, job.manga.id, true) job.copy(status = JobStatus.COMPLETE) } catch (e: Exception) { logger.error(e) { "Error while updating ${job.manga}" } @@ -287,9 +288,9 @@ class Updater : IUpdater { val categoriesToUpdateMangas = categoriesToUpdate - .flatMap { CategoryManga.getCategoryMangaList(it.id) } + .flatMap { CategoryManga.getCategoryMangaList(1, it.id) } // todo User accounts .distinctBy { it.id } - val mangasToCategoriesMap = CategoryManga.getMangasCategories(categoriesToUpdateMangas.map { it.id }) + val mangasToCategoriesMap = CategoryManga.getMangasCategories(1, categoriesToUpdateMangas.map { it.id }) // todo User accounts val mangasToUpdate = categoriesToUpdateMangas .asSequence() diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryMangaTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryMangaTable.kt index 5cf445ab7f..909980ff16 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryMangaTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryMangaTable.kt @@ -9,8 +9,10 @@ package suwayomi.tachidesk.manga.model.table import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.sql.ReferenceOption +import suwayomi.tachidesk.global.model.table.UserTable object CategoryMangaTable : IntIdTable() { val category = reference("category", CategoryTable, ReferenceOption.CASCADE) val manga = reference("manga", MangaTable, ReferenceOption.CASCADE) + val user = reference("user", UserTable, ReferenceOption.CASCADE) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryMetaTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryMetaTable.kt index ba45aaf85a..a69c42a6e1 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryMetaTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryMetaTable.kt @@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.model.table import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.sql.ReferenceOption +import suwayomi.tachidesk.global.model.table.UserTable import suwayomi.tachidesk.manga.model.table.CategoryMetaTable.ref /** @@ -18,4 +19,5 @@ object CategoryMetaTable : IntIdTable() { val key = varchar("key", 256) val value = varchar("value", 4096) val ref = reference("category_ref", CategoryTable, ReferenceOption.CASCADE) + val user = reference("user", UserTable, ReferenceOption.CASCADE) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryTable.kt index 8bca536843..7da1494919 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/CategoryTable.kt @@ -8,7 +8,9 @@ package suwayomi.tachidesk.manga.model.table * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.ReferenceOption import org.jetbrains.exposed.sql.ResultRow +import suwayomi.tachidesk.global.model.table.UserTable import suwayomi.tachidesk.manga.impl.Category import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude @@ -19,6 +21,7 @@ object CategoryTable : IntIdTable() { val isDefault = bool("is_default").default(false) val includeInUpdate = integer("include_in_update").default(IncludeOrExclude.UNSET.value) val includeInDownload = integer("include_in_download").default(IncludeOrExclude.UNSET.value) + val user = reference("user", UserTable, ReferenceOption.CASCADE) } fun CategoryTable.toDataClass(categoryEntry: ResultRow) = @@ -27,8 +30,8 @@ fun CategoryTable.toDataClass(categoryEntry: ResultRow) = categoryEntry[order], categoryEntry[name], categoryEntry[isDefault], - Category.getCategorySize(categoryEntry[id].value), + Category.getCategorySize(categoryEntry[user].value, categoryEntry[id].value), IncludeOrExclude.fromValue(categoryEntry[includeInUpdate]), IncludeOrExclude.fromValue(categoryEntry[includeInDownload]), - Category.getCategoryMetaMap(categoryEntry[id].value), + Category.getCategoryMetaMap(categoryEntry[user].value, categoryEntry[id].value), ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterMetaTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterMetaTable.kt index 6584679fef..559941c1cf 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterMetaTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterMetaTable.kt @@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.model.table import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.sql.ReferenceOption +import suwayomi.tachidesk.global.model.table.UserTable import suwayomi.tachidesk.manga.model.table.ChapterMetaTable.ref /** @@ -29,4 +30,5 @@ object ChapterMetaTable : IntIdTable() { val key = varchar("key", 256) val value = varchar("value", 4096) val ref = reference("chapter_ref", ChapterTable, ReferenceOption.CASCADE) + val user = reference("user", UserTable, ReferenceOption.CASCADE) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt index 03d443ef08..545eafa823 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt @@ -23,10 +23,6 @@ object ChapterTable : IntIdTable() { val chapter_number = float("chapter_number").default(-1f) val scanlator = varchar("scanlator", 128).nullable() - val isRead = bool("read").default(false) - val isBookmarked = bool("bookmark").default(false) - val lastPageRead = integer("last_page_read").default(0) - val lastReadAt = long("last_read_at").default(0) val fetchedAt = long("fetched_at").default(0) val sourceOrder = integer("source_order") @@ -41,31 +37,33 @@ object ChapterTable : IntIdTable() { val manga = reference("manga", MangaTable, ReferenceOption.CASCADE) } -fun ChapterTable.toDataClass(chapterEntry: ResultRow) = - ChapterDataClass( - id = chapterEntry[id].value, - url = chapterEntry[url], - name = chapterEntry[name], - uploadDate = chapterEntry[date_upload], - chapterNumber = chapterEntry[chapter_number], - scanlator = chapterEntry[scanlator], - mangaId = chapterEntry[manga].value, - read = chapterEntry[isRead], - bookmarked = chapterEntry[isBookmarked], - lastPageRead = chapterEntry[lastPageRead], - lastReadAt = chapterEntry[lastReadAt], - index = chapterEntry[sourceOrder], - fetchedAt = chapterEntry[fetchedAt], - realUrl = chapterEntry[realUrl], - downloaded = chapterEntry[isDownloaded], - pageCount = chapterEntry[pageCount], - chapterCount = - transaction { - ChapterTable - .selectAll() - .where { manga eq chapterEntry[manga].value } - .count() - .toInt() - }, - meta = getChapterMetaMap(chapterEntry[id]), - ) +fun ChapterTable.toDataClass( + userId: Int, + chapterEntry: ResultRow, +) = ChapterDataClass( + id = chapterEntry[id].value, + url = chapterEntry[url], + name = chapterEntry[name], + uploadDate = chapterEntry[date_upload], + chapterNumber = chapterEntry[chapter_number], + scanlator = chapterEntry[scanlator], + mangaId = chapterEntry[manga].value, + read = chapterEntry.getOrNull(ChapterUserTable.isRead) ?: false, + bookmarked = chapterEntry.getOrNull(ChapterUserTable.isBookmarked) ?: false, + lastPageRead = chapterEntry.getOrNull(ChapterUserTable.lastPageRead) ?: 0, + lastReadAt = chapterEntry.getOrNull(ChapterUserTable.lastReadAt) ?: 0, + index = chapterEntry[sourceOrder], + fetchedAt = chapterEntry[fetchedAt], + realUrl = chapterEntry[realUrl], + downloaded = chapterEntry[isDownloaded], + pageCount = chapterEntry[pageCount], + chapterCount = + transaction { + ChapterTable + .selectAll() + .where { manga eq chapterEntry[manga].value } + .count() + .toInt() + }, + meta = getChapterMetaMap(userId, chapterEntry[id]), +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterUserTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterUserTable.kt new file mode 100644 index 0000000000..a7c69cfa67 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterUserTable.kt @@ -0,0 +1,32 @@ +package suwayomi.tachidesk.manga.model.table + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.leftJoin +import suwayomi.tachidesk.global.model.table.UserTable + +object ChapterUserTable : IntIdTable() { + val chapter = reference("chapter", ChapterTable, ReferenceOption.CASCADE) + val user = reference("user", UserTable, ReferenceOption.CASCADE) + + val isRead = bool("read").default(false) + val isBookmarked = bool("bookmark").default(false) + val lastPageRead = integer("last_page_read").default(0) + val lastReadAt = long("last_read_at").default(0) +} + +fun ChapterTable.getWithUserData(userId: Int) = + leftJoin( + ChapterUserTable, + onColumn = { ChapterTable.id }, + otherColumn = { ChapterUserTable.chapter }, + additionalConstraint = { ChapterUserTable.user eq userId }, + ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaMetaTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaMetaTable.kt index b4a7098a88..92c6ed6f80 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaMetaTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaMetaTable.kt @@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.model.table import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.sql.ReferenceOption +import suwayomi.tachidesk.global.model.table.UserTable import suwayomi.tachidesk.manga.model.table.MangaMetaTable.ref /** @@ -29,4 +30,5 @@ object MangaMetaTable : IntIdTable() { val key = varchar("key", 256) val value = varchar("value", 4096) val ref = reference("manga_ref", MangaTable, ReferenceOption.CASCADE) + val user = reference("user", UserTable, ReferenceOption.CASCADE) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt index b3bd8ec8dd..959f9fbd0a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt @@ -31,9 +31,6 @@ object MangaTable : IntIdTable() { val thumbnail_url = varchar("thumbnail_url", 2048).nullable() val thumbnailUrlLastFetched = long("thumbnail_url_last_fetched").default(0) - val inLibrary = bool("in_library").default(false) - val inLibraryAt = long("in_library_at").default(0) - // the [source] field name is used by some ancestor of IntIdTable val sourceReference = long("source") @@ -46,28 +43,30 @@ object MangaTable : IntIdTable() { val updateStrategy = varchar("update_strategy", 256).default(UpdateStrategy.ALWAYS_UPDATE.name) } -fun MangaTable.toDataClass(mangaEntry: ResultRow) = - MangaDataClass( - id = mangaEntry[this.id].value, - sourceId = mangaEntry[sourceReference].toString(), - url = mangaEntry[url], - title = mangaEntry[title], - thumbnailUrl = proxyThumbnailUrl(mangaEntry[this.id].value), - thumbnailUrlLastFetched = mangaEntry[thumbnailUrlLastFetched], - initialized = mangaEntry[initialized], - artist = mangaEntry[artist], - author = mangaEntry[author], - description = mangaEntry[description], - genre = mangaEntry[genre].toGenreList(), - status = Companion.valueOf(mangaEntry[status]).name, - inLibrary = mangaEntry[inLibrary], - inLibraryAt = mangaEntry[inLibraryAt], - meta = getMangaMetaMap(mangaEntry[id].value), - realUrl = mangaEntry[realUrl], - lastFetchedAt = mangaEntry[lastFetchedAt], - chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt], - updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]), - ) +fun MangaTable.toDataClass( + userId: Int, + mangaEntry: ResultRow, +) = MangaDataClass( + id = mangaEntry[this.id].value, + sourceId = mangaEntry[sourceReference].toString(), + url = mangaEntry[url], + title = mangaEntry[title], + thumbnailUrl = proxyThumbnailUrl(mangaEntry[this.id].value), + thumbnailUrlLastFetched = mangaEntry[thumbnailUrlLastFetched], + initialized = mangaEntry[initialized], + artist = mangaEntry[artist], + author = mangaEntry[author], + description = mangaEntry[description], + genre = mangaEntry[genre].toGenreList(), + status = Companion.valueOf(mangaEntry[status]).name, + inLibrary = mangaEntry.getOrNull(MangaUserTable.inLibrary) ?: false, + inLibraryAt = mangaEntry.getOrNull(MangaUserTable.inLibraryAt) ?: 0, + meta = getMangaMetaMap(userId, mangaEntry[id].value), + realUrl = mangaEntry[realUrl], + lastFetchedAt = mangaEntry[lastFetchedAt], + chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt], + updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]), +) enum class MangaStatus( val value: Int, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaUserTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaUserTable.kt new file mode 100644 index 0000000000..11706b6454 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaUserTable.kt @@ -0,0 +1,29 @@ +package suwayomi.tachidesk.manga.model.table + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.leftJoin +import suwayomi.tachidesk.global.model.table.UserTable + +object MangaUserTable : IntIdTable() { + val manga = reference("manga", MangaTable, ReferenceOption.CASCADE) + val user = reference("user", UserTable, ReferenceOption.CASCADE) + val inLibrary = bool("in_library").default(false) + val inLibraryAt = long("in_library_at").default(0) +} + +fun MangaTable.getWithUserData(userId: Int) = + leftJoin( + MangaUserTable, + onColumn = { MangaTable.id }, + otherColumn = { MangaUserTable.manga }, + additionalConstraint = { MangaUserTable.user eq userId }, + ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceMetaTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceMetaTable.kt index 3e6bc8a83f..49a22b6a4a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceMetaTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/SourceMetaTable.kt @@ -8,6 +8,8 @@ package suwayomi.tachidesk.manga.model.table * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.ReferenceOption +import suwayomi.tachidesk.global.model.table.UserTable import suwayomi.tachidesk.manga.model.table.ChapterMetaTable.ref /** @@ -17,4 +19,5 @@ object SourceMetaTable : IntIdTable() { val key = varchar("key", 256) val value = varchar("value", 4096) val ref = long("source_ref") + val user = reference("user", UserTable, ReferenceOption.CASCADE) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt index 44903df745..7f0203dbec 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt @@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.model.table import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.sql.ReferenceOption +import suwayomi.tachidesk.global.model.table.UserTable object TrackRecordTable : IntIdTable() { val mangaId = reference("manga_id", MangaTable, ReferenceOption.CASCADE) @@ -23,4 +24,5 @@ object TrackRecordTable : IntIdTable() { val remoteUrl = varchar("remote_url", 512) val startDate = long("start_date") val finishDate = long("finish_date") + val user = reference("user", UserTable, ReferenceOption.CASCADE) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt index 1e4f7a113a..0188689247 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt @@ -10,8 +10,12 @@ package suwayomi.tachidesk.server import io.github.oshai.kotlinlogging.KotlinLogging import io.javalin.Javalin import io.javalin.apibuilder.ApiBuilder.path +import io.javalin.http.Context +import io.javalin.http.Header +import io.javalin.http.HttpStatus import io.javalin.http.UnauthorizedResponse import io.javalin.http.staticfiles.Location +import io.javalin.websocket.WsContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -20,8 +24,13 @@ import kotlinx.coroutines.future.future import kotlinx.coroutines.runBlocking import org.eclipse.jetty.server.ServerConnector import suwayomi.tachidesk.global.GlobalAPI +import suwayomi.tachidesk.global.impl.util.Jwt import suwayomi.tachidesk.graphql.GraphQL import suwayomi.tachidesk.manga.MangaAPI +import suwayomi.tachidesk.server.JavalinSetup.setAttribute +import suwayomi.tachidesk.server.user.ForbiddenException +import suwayomi.tachidesk.server.user.UnauthorizedException +import suwayomi.tachidesk.server.user.UserType import suwayomi.tachidesk.server.util.Browser import suwayomi.tachidesk.server.util.WebInterfaceManager import uy.kohesive.injekt.injectLazy @@ -43,7 +52,11 @@ object JavalinSetup { Javalin.create { config -> if (serverConfig.webUIEnabled.value) { val serveWebUI = { - config.spaRoot.addFile("/", applicationDirs.webUIRoot + "/index.html", Location.EXTERNAL) + config.spaRoot.addFile( + "/", + applicationDirs.webUIRoot + "/index.html", + Location.EXTERNAL, + ) } WebInterfaceManager.setServeWebUI(serveWebUI) @@ -115,7 +128,24 @@ object JavalinSetup { password == serverConfig.basicAuthPassword.value } - if (serverConfig.basicAuthEnabled.value && !credentialsValid()) { + val user = + if (serverConfig.multiUser.value) { + val authentication = ctx.header(Header.AUTHORIZATION) + if (authentication.isNullOrBlank()) { + UserType.Visitor + } else { + Jwt.verifyJwt(authentication.substringAfter("Bearer ")) + } + } else { + UserType.Admin(1) + } + ctx.setAttribute(Attribute.TachideskUser, user) + + if ( + !serverConfig.multiUser.value && + serverConfig.basicAuthEnabled.value && + !credentialsValid() + ) { ctx.header("WWW-Authenticate", "Basic") throw UnauthorizedResponse() } @@ -129,6 +159,28 @@ object JavalinSetup { } } + app.wsBefore { + it.onConnect { ctx -> + val user = + if (serverConfig.multiUser.value) { + val authentication = ctx.header(Header.AUTHORIZATION) + if (authentication.isNullOrBlank()) { + val token = ctx.queryParam("token") + if (token.isNullOrBlank()) { + UserType.Visitor + } else { + Jwt.verifyJwt(token) + } + } else { + Jwt.verifyJwt(authentication.substringAfter("Bearer ")) + } + } else { + UserType.Admin(1) + } + ctx.setAttribute(Attribute.TachideskUser, user) + } + } + // when JVM is prompted to shutdown, stop javalin gracefully Runtime.getRuntime().addShutdownHook( thread(start = false) { @@ -156,6 +208,18 @@ object JavalinSetup { ctx.result(e.message ?: "Bad Request") } + app.exception(UnauthorizedException::class.java) { e, ctx -> + logger.error(e) { "UnauthorizedException while handling the request" } + ctx.status(HttpStatus.UNAUTHORIZED) + ctx.result(e.message ?: "Unauthorized") + } + + app.exception(ForbiddenException::class.java) { e, ctx -> + logger.error(e) { "ForbiddenException while handling the request" } + ctx.status(HttpStatus.FORBIDDEN) + ctx.result(e.message ?: "Forbidden") + } + app.start() } @@ -174,4 +238,28 @@ object JavalinSetup { // ) // } // } + + sealed class Attribute( + val name: String, + ) { + data object TachideskUser : Attribute("user") + } + + private fun Context.setAttribute( + attribute: Attribute, + value: T, + ) { + attribute(attribute.name, value) + } + + private fun WsContext.setAttribute( + attribute: Attribute, + value: T, + ) { + attribute(attribute.name, value) + } + + fun Context.getAttribute(attribute: Attribute): T = attribute(attribute.name)!! + + fun WsContext.getAttribute(attribute: Attribute): T = attribute(attribute.name)!! } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt index e8ae3b44df..29ceed228c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt @@ -130,6 +130,7 @@ class ServerConfig( val basicAuthEnabled: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) val basicAuthUsername: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) val basicAuthPassword: MutableStateFlow by OverrideConfigValue(StringConfigAdapter) + val multiUser: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) // misc val debugLogsEnabled: MutableStateFlow by OverrideConfigValue(BooleanConfigAdapter) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt index e7991029bb..eb739806ca 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt @@ -147,7 +147,7 @@ fun applicationSetup() { .replace(Regex("(\"basicAuth(?:Username|Password)\"\\s:\\s)(?!\"\")\".*\""), "$1\"******\"") } - logger.debug("Data Root directory is set to: ${applicationDirs.dataRoot}") + logger.debug { "Data Root directory is set to: ${applicationDirs.dataRoot}" } // Migrate Directories from old versions File("$ApplicationRootDir/manga-thumbnails").renameTo(applicationDirs.tempThumbnailCacheRoot) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0043_AddUsers.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0043_AddUsers.kt new file mode 100644 index 0000000000..e0fe601f96 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0043_AddUsers.kt @@ -0,0 +1,153 @@ +package suwayomi.tachidesk.server.database.migration + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import de.neonew.exposed.migrations.helpers.SQLMigration +import org.intellij.lang.annotations.Language +import suwayomi.tachidesk.global.impl.util.Bcrypt + +@Suppress("ClassName", "unused") +class M0043_AddUsers : SQLMigration() { + class UserSql { + private val password = Bcrypt.encryptPassword("password") + + @Language("SQL") + val sql = + """ + CREATE TABLE USER + ( + ID INT AUTO_INCREMENT PRIMARY KEY, + USERNAME VARCHAR(64) NOT NULL, + PASSWORD VARCHAR(90) NOT NULL + ); + + INSERT INTO USER(USERNAME, PASSWORD) + SELECT 'admin','$password'; + + CREATE TABLE USERROLES + ( + USER INT NOT NULL, + ROLE VARCHAR(24) NOT NULL, + CONSTRAINT FK_USERROLES_USER_ID + FOREIGN KEY (USER) REFERENCES USER (ID) ON DELETE CASCADE + ); + + INSERT INTO USERROLES(USER, ROLE) + SELECT 1, 'ADMIN'; + + CREATE TABLE USERPERMISSIONS + ( + USER INT NOT NULL, + PERMISSION VARCHAR(128) NOT NULL, + CONSTRAINT FK_USERPERMISSIONS_USER_ID + FOREIGN KEY (USER) REFERENCES USER (ID) ON DELETE CASCADE + ); + + -- Step 1: Add USER column to tables CATEGORY, MANGAMETA, CHAPTERMETA, CATEGORYMANGA, GLOBALMETA, and CATEGORYMETA + ALTER TABLE CATEGORY ADD COLUMN USER INT NOT NULL DEFAULT 1; + ALTER TABLE TRACKRECORD ADD COLUMN USER INT NOT NULL DEFAULT 1; + ALTER TABLE MANGAMETA ADD COLUMN USER INT NOT NULL DEFAULT 1; + ALTER TABLE CHAPTERMETA ADD COLUMN USER INT NOT NULL DEFAULT 1; + ALTER TABLE CATEGORYMANGA ADD COLUMN USER INT NOT NULL DEFAULT 1; + ALTER TABLE GLOBALMETA ADD COLUMN USER INT NOT NULL DEFAULT 1; + ALTER TABLE CATEGORYMETA ADD COLUMN USER INT NOT NULL DEFAULT 1; + ALTER TABLE SOURCEMETA ADD COLUMN USER INT NOT NULL DEFAULT 1; + + -- Add foreign key constraints to reference USER table + ALTER TABLE CATEGORY ADD CONSTRAINT FK_CATEGORY_USER FOREIGN KEY (USER) REFERENCES USER(ID) ON DELETE CASCADE; + ALTER TABLE MANGAMETA ADD CONSTRAINT FK_MANGAMETA_USER FOREIGN KEY (USER) REFERENCES USER(ID) ON DELETE CASCADE; + ALTER TABLE CHAPTERMETA ADD CONSTRAINT FK_CHAPTERMETA_USER FOREIGN KEY (USER) REFERENCES USER(ID) ON DELETE CASCADE; + ALTER TABLE CATEGORYMANGA ADD CONSTRAINT FK_CATEGORYMANGA_USER FOREIGN KEY (USER) REFERENCES USER(ID) ON DELETE CASCADE; + ALTER TABLE GLOBALMETA ADD CONSTRAINT FK_GLOBALMETA_USER FOREIGN KEY (USER) REFERENCES USER(ID) ON DELETE CASCADE; + ALTER TABLE CATEGORYMETA ADD CONSTRAINT FK_CATEGORYMETA_USER FOREIGN KEY (USER) REFERENCES USER(ID) ON DELETE CASCADE; + ALTER TABLE SOURCEMETA ADD CONSTRAINT FK_CATEGORYMETA_USER FOREIGN KEY (USER) REFERENCES USER(ID) ON DELETE CASCADE; + + ALTER TABLE CATEGORY + ALTER COLUMN USER DROP DEFAULT; + + ALTER TABLE TRACKRECORD + ALTER COLUMN USER DROP DEFAULT; + + ALTER TABLE MANGAMETA + ALTER COLUMN USER DROP DEFAULT; + + ALTER TABLE CHAPTERMETA + ALTER COLUMN USER DROP DEFAULT; + + ALTER TABLE CATEGORYMANGA + ALTER COLUMN USER DROP DEFAULT; + + ALTER TABLE GLOBALMETA + ALTER COLUMN USER DROP DEFAULT; + + ALTER TABLE CATEGORYMETA + ALTER COLUMN USER DROP DEFAULT; + + ALTER TABLE SOURCEMETA + ALTER COLUMN USER DROP DEFAULT; + + -- Step 2: Create the CHAPTERUSER table + CREATE TABLE CHAPTERUSER + ( + ID INT AUTO_INCREMENT PRIMARY KEY, + LAST_READ_AT BIGINT DEFAULT 0 NOT NULL, + LAST_PAGE_READ INT DEFAULT 0 NOT NULL, + BOOKMARK BOOLEAN DEFAULT FALSE NOT NULL, + READ BOOLEAN DEFAULT FALSE NOT NULL, + CHAPTER INT NOT NULL, + USER INT NOT NULL, + CONSTRAINT FK_CHAPTERUSER_CHAPTER_ID + FOREIGN KEY (CHAPTER) REFERENCES CHAPTER (ID) ON DELETE CASCADE, + CONSTRAINT FK_CHAPTERUSER_USER_ID + FOREIGN KEY (USER) REFERENCES USER (ID) ON DELETE CASCADE + ); + + -- Step 3: Create the MANGAUSER table + CREATE TABLE MANGAUSER + ( + ID INT AUTO_INCREMENT PRIMARY KEY, + IN_LIBRARY BOOLEAN DEFAULT FALSE NOT NULL, + IN_LIBRARY_AT BIGINT DEFAULT 0 NOT NULL, + MANGA INT NOT NULL, + USER INT NOT NULL, + CONSTRAINT FK_MANGAUSER_MANGA_ID + FOREIGN KEY (MANGA) REFERENCES MANGA (ID) ON DELETE CASCADE, + CONSTRAINT FK_MANGAUSER_USER_ID + FOREIGN KEY (USER) REFERENCES USER (ID) ON DELETE CASCADE + ); + + -- Step 4: Backfill the CHAPTERUSER and MANGAUSER tables with existing data + INSERT INTO CHAPTERUSER (LAST_READ_AT, LAST_PAGE_READ, BOOKMARK, READ, CHAPTER, USER) + SELECT LAST_READ_AT, LAST_PAGE_READ, BOOKMARK, READ, ID AS CHAPTER, 1 AS USER + FROM CHAPTER; + + INSERT INTO MANGAUSER (IN_LIBRARY, IN_LIBRARY_AT, MANGA, USER) + SELECT IN_LIBRARY, IN_LIBRARY_AT, ID AS MANGA, 1 AS USER + FROM MANGA; + + -- Step 5: Remove extracted columns from CHAPTER and MANGA tables + ALTER TABLE CHAPTER + DROP COLUMN LAST_READ_AT; + ALTER TABLE CHAPTER + DROP COLUMN LAST_PAGE_READ; + ALTER TABLE CHAPTER + DROP COLUMN BOOKMARK; + ALTER TABLE CHAPTER + DROP COLUMN READ; + + ALTER TABLE MANGA + DROP COLUMN IN_LIBRARY; + ALTER TABLE MANGA + DROP COLUMN IN_LIBRARY_AT; + """.trimIndent() + } + + override val sql by lazy { + UserSql().sql + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/user/Permissions.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/user/Permissions.kt new file mode 100644 index 0000000000..a6204a728c --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/user/Permissions.kt @@ -0,0 +1,10 @@ +package suwayomi.tachidesk.server.user + +enum class Permissions { + INSTALL_EXTENSIONS, + INSTALL_UNTRUSTED_EXTENSIONS, + UNINSTALL_EXTENSIONS, + DOWNLOAD_CHAPTERS, + DELETE_DOWNLOADS, + CREATE_USER, +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/user/UserType.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/user/UserType.kt new file mode 100644 index 0000000000..593acd45ad --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/user/UserType.kt @@ -0,0 +1,38 @@ +package suwayomi.tachidesk.server.user + +sealed class UserType { + class Admin( + val id: Int, + ) : UserType() + + class User( + val id: Int, + val permissions: List, + ) : UserType() + + data object Visitor : UserType() +} + +fun UserType.requireUser(): Int = + when (this) { + is UserType.Admin -> id + is UserType.User -> id + UserType.Visitor -> throw UnauthorizedException() + } + +fun UserType.requirePermissions(vararg permissions: Permissions) { + when (this) { + is UserType.Admin -> Unit + is UserType.User -> { + val userPermissions = this.permissions + if (!permissions.all { it in userPermissions }) { + throw ForbiddenException() + } + } + UserType.Visitor -> throw UnauthorizedException() + } +} + +class UnauthorizedException : IllegalStateException("Unauthorized") + +class ForbiddenException : IllegalStateException("Forbidden") diff --git a/server/src/main/resources/server-reference.conf b/server/src/main/resources/server-reference.conf index 2f6cb2ae46..b7e24ac74d 100644 --- a/server/src/main/resources/server-reference.conf +++ b/server/src/main/resources/server-reference.conf @@ -42,11 +42,14 @@ server.excludeCompleted = true server.globalUpdateInterval = 12 # time in hours - 0 to disable it - (doesn't have to be full hours e.g. 12.5) - range: 6 <= n < ∞ - default: 12 hours - interval in which the global update will be automatically triggered server.updateMangas = false # if the mangas should be updated along with the chapter list during a library/category update -# Authentication +# authentication server.basicAuthEnabled = false server.basicAuthUsername = "" server.basicAuthPassword = "" +# user +server.multiUser = false # Will ignore basic auth if enabled + # misc server.debugLogsEnabled = false server.systemTrayEnabled = true diff --git a/server/src/test/kotlin/suwayomi/tachidesk/manga/controller/CategoryControllerTest.kt b/server/src/test/kotlin/suwayomi/tachidesk/manga/controller/CategoryControllerTest.kt index 589e52d961..3a95b626ea 100644 --- a/server/src/test/kotlin/suwayomi/tachidesk/manga/controller/CategoryControllerTest.kt +++ b/server/src/test/kotlin/suwayomi/tachidesk/manga/controller/CategoryControllerTest.kt @@ -18,15 +18,15 @@ import suwayomi.tachidesk.test.clearTables class CategoryControllerTest : ApplicationTest() { @Test fun categoryReorder() { - Category.createCategory("foo") - Category.createCategory("bar") - val cats = Category.getCategoryList() + Category.createCategory(1, "foo") + Category.createCategory(1, "bar") + val cats = Category.getCategoryList(1) val foo = cats.asSequence().filter { it.name == "foo" }.first() val bar = cats.asSequence().filter { it.name == "bar" }.first() assertEquals(1, foo.order) assertEquals(2, bar.order) - Category.reorderCategory(1, 2) - val catsReordered = Category.getCategoryList() + Category.reorderCategory(1, 1, 2) + val catsReordered = Category.getCategoryList(1) val fooReordered = catsReordered.asSequence().filter { it.name == "foo" }.first() val barReordered = catsReordered.asSequence().filter { it.name == "bar" }.first() assertEquals(2, fooReordered.order) diff --git a/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/CategoryMangaTest.kt b/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/CategoryMangaTest.kt index 731356d99e..f3c093b068 100644 --- a/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/CategoryMangaTest.kt +++ b/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/CategoryMangaTest.kt @@ -25,43 +25,43 @@ import suwayomi.tachidesk.test.createLibraryManga class CategoryMangaTest : ApplicationTest() { @Test fun getCategoryMangaList() { - val emptyCats = CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID).size + val emptyCats = CategoryManga.getCategoryMangaList(1, DEFAULT_CATEGORY_ID).size assertEquals(0, emptyCats, "Default category should be empty at start") val mangaId = createLibraryManga("Psyren") createChapters(mangaId, 10, true) - assertEquals(1, CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID).size, "Default category should have one member") + assertEquals(1, CategoryManga.getCategoryMangaList(1, DEFAULT_CATEGORY_ID).size, "Default category should have one member") assertEquals( 0, - CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount, + CategoryManga.getCategoryMangaList(1, DEFAULT_CATEGORY_ID)[0].unreadCount, "Manga should not have any unread chapters", ) createChapters(mangaId, 10, false) assertEquals( 10, - CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount, + CategoryManga.getCategoryMangaList(1, DEFAULT_CATEGORY_ID)[0].unreadCount, "Manga should have unread chapters", ) - val categoryId = Category.createCategory("Old") + val categoryId = Category.createCategory(1, "Old") assertEquals( 0, - CategoryManga.getCategoryMangaList(categoryId).size, + CategoryManga.getCategoryMangaList(1, categoryId).size, "Newly created category shouldn't have any Mangas", ) - CategoryManga.addMangaToCategory(mangaId, categoryId) + CategoryManga.addMangaToCategory(1, mangaId, categoryId) assertEquals( 1, - CategoryManga.getCategoryMangaList(categoryId).size, + CategoryManga.getCategoryMangaList(1, categoryId).size, "Manga should been moved", ) assertEquals( 10, - CategoryManga.getCategoryMangaList(categoryId)[0].unreadCount, + CategoryManga.getCategoryMangaList(1, categoryId)[0].unreadCount, "Manga should keep it's unread count in moved category", ) assertEquals( 0, - CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID).size, + CategoryManga.getCategoryMangaList(1, DEFAULT_CATEGORY_ID).size, "Manga shouldn't be member of default category after moving", ) } diff --git a/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/MangaTest.kt b/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/MangaTest.kt index a7a7fb453b..2d6dc35eb3 100644 --- a/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/MangaTest.kt +++ b/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/MangaTest.kt @@ -22,34 +22,34 @@ class MangaTest : ApplicationTest() { @Test fun getMangaMeta() { val metaManga = createLibraryManga("META_TEST") - val emptyMeta = Manga.getMangaMetaMap(metaManga).size + val emptyMeta = Manga.getMangaMetaMap(1, metaManga).size assertEquals(0, emptyMeta, "Default Manga meta should be empty at start") - Manga.modifyMangaMeta(metaManga, "test", "value") - assertEquals(1, Manga.getMangaMetaMap(metaManga).size, "Manga meta should have one member") - assertEquals("value", Manga.getMangaMetaMap(metaManga)["test"], "Manga meta use the value 'value' for key 'test'") + Manga.modifyMangaMeta(1, metaManga, "test", "value") + assertEquals(1, Manga.getMangaMetaMap(1, metaManga).size, "Manga meta should have one member") + assertEquals("value", Manga.getMangaMetaMap(1, metaManga)["test"], "Manga meta use the value 'value' for key 'test'") - Manga.modifyMangaMeta(metaManga, "test", "newValue") + Manga.modifyMangaMeta(1, metaManga, "test", "newValue") assertEquals( 1, - Manga.getMangaMetaMap(metaManga).size, + Manga.getMangaMetaMap(1, metaManga).size, "Manga meta should still only have one pair", ) assertEquals( "newValue", - Manga.getMangaMetaMap(metaManga)["test"], + Manga.getMangaMetaMap(1, metaManga)["test"], "Manga meta with key 'test' should use the value `newValue`", ) - Manga.modifyMangaMeta(metaManga, "test2", "value2") + Manga.modifyMangaMeta(1, metaManga, "test2", "value2") assertEquals( 2, - Manga.getMangaMetaMap(metaManga).size, + Manga.getMangaMetaMap(1, metaManga).size, "Manga Meta should have an additional pair", ) assertEquals( "value2", - Manga.getMangaMetaMap(metaManga)["test2"], + Manga.getMangaMetaMap(1, metaManga)["test2"], "Manga Meta for key 'test2' should be 'value2'", ) } diff --git a/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/SearchTest.kt b/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/SearchTest.kt index 31f00aac77..377c0065ad 100644 --- a/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/SearchTest.kt +++ b/server/src/test/kotlin/suwayomi/tachidesk/manga/impl/SearchTest.kt @@ -63,7 +63,8 @@ class SearchTest : ApplicationTest() { fun searchWorks() { val searchResults = runBlocking { - sourceSearch(sourceId, "all the mangas", 1) + // todo user accounts + sourceSearch(1, sourceId, "all the mangas", 1) } assertEquals(mangasCount, searchResults.mangaList.size, "should return all the mangas") @@ -219,12 +220,12 @@ class FilterListTest : ApplicationTest() { setFilter( source.id, - FilterChange(0, "change!"), + listOf(FilterChange(0, "change!")), ) setFilter( source.id, - FilterChange(1, "change!"), + listOf(FilterChange(1, "change!")), ) val filterList = getFilterList(source.id, false) @@ -246,7 +247,7 @@ class FilterListTest : ApplicationTest() { setFilter( source.id, - FilterChange(2, "1"), + listOf(FilterChange(2, "1")), ) val filterList = getFilterList(source.id, false) @@ -263,7 +264,7 @@ class FilterListTest : ApplicationTest() { setFilter( source.id, - FilterChange(3, "I'm a changed man!"), + listOf(FilterChange(3, "I'm a changed man!")), ) val filterList = getFilterList(source.id, false) @@ -280,7 +281,7 @@ class FilterListTest : ApplicationTest() { setFilter( source.id, - FilterChange(4, "true"), + listOf(FilterChange(4, "true")), ) val filterList = getFilterList(source.id, false) @@ -297,7 +298,7 @@ class FilterListTest : ApplicationTest() { setFilter( source.id, - FilterChange(5, "1"), + listOf(FilterChange(5, "1")), ) val filterList = getFilterList(source.id, false) @@ -314,7 +315,7 @@ class FilterListTest : ApplicationTest() { setFilter( source.id, - FilterChange(6, """{"position":0,"state":"true"}"""), + listOf(FilterChange(6, """{"position":0,"state":"true"}""")), ) val filterList = getFilterList(source.id, false) @@ -331,7 +332,7 @@ class FilterListTest : ApplicationTest() { setFilter( source.id, - FilterChange(7, """{"index":1,"ascending":"true"}"""), + listOf(FilterChange(7, """{"index":1,"ascending":"true"}""")), ) val filterList = getFilterList(source.id, false) diff --git a/server/src/test/kotlin/suwayomi/tachidesk/test/TestUtils.kt b/server/src/test/kotlin/suwayomi/tachidesk/test/TestUtils.kt index 8bd8e16370..570bdcb17d 100644 --- a/server/src/test/kotlin/suwayomi/tachidesk/test/TestUtils.kt +++ b/server/src/test/kotlin/suwayomi/tachidesk/test/TestUtils.kt @@ -13,11 +13,16 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.jetbrains.exposed.dao.id.IdTable import org.jetbrains.exposed.sql.batchInsert import org.jetbrains.exposed.sql.deleteAll +import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insertAndGetId +import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction import org.slf4j.Logger +import suwayomi.tachidesk.manga.model.table.ChapterMetaTable import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.ChapterUserTable import suwayomi.tachidesk.manga.model.table.MangaTable +import suwayomi.tachidesk.manga.model.table.MangaUserTable fun setLoggingEnabled(enabled: Boolean = true) { val logger = (KotlinLogging.logger(Logger.ROOT_LOGGER_NAME).underlyingLogger as ch.qos.logback.classic.Logger) @@ -33,13 +38,20 @@ const val BASE_PATH = "build/tmp/TestDesk" fun createLibraryManga(_title: String): Int = transaction { - MangaTable - .insertAndGetId { - it[title] = _title - it[url] = _title - it[sourceReference] = 1 - it[inLibrary] = true - }.value + val mangaId = + MangaTable + .insertAndGetId { + it[title] = _title + it[url] = _title + it[sourceReference] = 1 + }.value + + MangaUserTable.insert { + it[MangaUserTable.manga] = mangaId + it[MangaUserTable.user] = 1 + it[MangaUserTable.inLibrary] = true + } + mangaId } fun createSMangas(count: Int): List = @@ -62,9 +74,15 @@ fun createChapters( this[ChapterTable.url] = "$it" this[ChapterTable.name] = "$it" this[ChapterTable.sourceOrder] = it - this[ChapterTable.isRead] = read this[ChapterTable.manga] = mangaId } + + val chapters = ChapterTable.select { ChapterTable.manga eq mangaId }.map { it[ChapterTable.id].value } + ChapterMetaTable.batchInsert(chapters) { + this[ChapterUserTable.chapter] = mangaId + this[ChapterUserTable.user] = 1 + this[ChapterUserTable.isRead] = read + } } }