diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index eb9ecaa91..e44097e5f 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -34,7 +34,7 @@ jobs: path: master fetch-depth: 0 - - name: Set up JDK 1.8 + - name: Set up JDK uses: actions/setup-java@v1 with: java-version: 1.8 diff --git a/.github/workflows/build_push.yml b/.github/workflows/build_push.yml index 8b3ba09d7..b98d42fa9 100644 --- a/.github/workflows/build_push.yml +++ b/.github/workflows/build_push.yml @@ -34,7 +34,7 @@ jobs: path: master fetch-depth: 0 - - name: Set up JDK 1.8 + - name: Set up JDK uses: actions/setup-java@v1 with: java-version: 1.8 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1b221ebeb..9b8765295 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,7 +34,7 @@ jobs: path: master fetch-depth: 0 - - name: Set up JDK 1.8 + - name: Set up JDK uses: actions/setup-java@v1 with: java-version: 1.8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8085822d0..f635758a2 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" + [plugins] # Kotlin kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin"} diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 868c141d6..a75051e64 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -78,6 +78,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 3916a557b..91e5c3746 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.HttpCode 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 dfd37d435..799abbafa 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() }, ) 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 e4c368f8d..b181dddc2 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/global/impl/GlobalMeta.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/GlobalMeta.kt @@ -1,8 +1,8 @@ package suwayomi.tachidesk.global.impl +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 import suwayomi.tachidesk.global.model.table.GlobalMetaTable @@ -16,31 +16,33 @@ import suwayomi.tachidesk.global.model.table.GlobalMetaTable object GlobalMeta { fun modifyMeta( + userId: Int, key: String, value: String, ) { transaction { val meta = transaction { - GlobalMetaTable.select { GlobalMetaTable.key eq key } + GlobalMetaTable.select { 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 { return transaction { - GlobalMetaTable.selectAll() + GlobalMetaTable.select { 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 000000000..2ce8e8033 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/util/Bcrypt.kt @@ -0,0 +1,19 @@ +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 { + return hasher.hashToString(12, password.toCharArray()) + } + + fun verify( + hash: String, + password: String, + ): Boolean { + return 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 000000000..8cbd7d7bc --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/util/Jwt.kt @@ -0,0 +1,128 @@ +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.select +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.select { UserRolesTable.user eq userId }.toList() + .map { it[UserRolesTable.role] } + } + val permissions = + transaction { + UserPermissionsTable.select { 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 { + return 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 4872f5c15..ea94e990c 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 000000000..1b0eb07b9 --- /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 000000000..551b386f6 --- /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 000000000..2c86faa5c --- /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 190fe6343..c39400ad2 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/CategoryDataLoader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/CategoryDataLoader.kt @@ -12,25 +12,30 @@ 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.select 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" override fun getDataLoader(): DataLoader = - DataLoaderFactory.newDataLoader { ids -> + DataLoaderFactory.newDataLoader { ids, env -> future { + val userId = env.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val categories = - CategoryTable.select { CategoryTable.id inList ids } + CategoryTable.select { CategoryTable.id inList ids and (CategoryTable.user eq userId) } .map { CategoryType(it) } .associateBy { it.id } ids.map { categories[it] } @@ -43,12 +48,18 @@ class CategoryForIdsDataLoader : KotlinDataLoader, CategoryNodeList> { override val dataLoaderName = "CategoryForIdsDataLoader" override fun getDataLoader(): DataLoader, CategoryNodeList> = - DataLoaderFactory.newDataLoader { categoryIds -> + DataLoaderFactory.newDataLoader { categoryIds, env -> future { + val userId = env.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val ids = categoryIds.flatten().distinct() - val categories = CategoryTable.select { CategoryTable.id inList ids }.map { CategoryType(it) } + val categories = + CategoryTable.select { CategoryTable.id inList ids and (CategoryTable.user eq userId) }.map { + CategoryType( + it, + ) + } categoryIds.map { categoryIds -> categories.filter { it.id in categoryIds }.toNodeList() } @@ -61,13 +72,18 @@ class CategoriesForMangaDataLoader : KotlinDataLoader { override val dataLoaderName = "CategoriesForMangaDataLoader" override fun getDataLoader(): DataLoader = - DataLoaderFactory.newDataLoader { ids -> + DataLoaderFactory.newDataLoader { ids, env -> future { + val userId = env.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val itemsByRef = CategoryMangaTable.innerJoin(CategoryTable) - .select { CategoryMangaTable.manga inList ids } + .select { + 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 } } 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 bca01095d..4daa91207 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ChapterDataLoader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/ChapterDataLoader.kt @@ -17,22 +17,29 @@ import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.count import org.jetbrains.exposed.sql.select 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" override fun getDataLoader(): DataLoader = - DataLoaderFactory.newDataLoader { ids -> + DataLoaderFactory.newDataLoader { ids, env -> future { + val userId = env.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val chapters = - ChapterTable.select { ChapterTable.id inList ids } + ChapterTable.getWithUserData(userId) + .select { ChapterTable.id inList ids } .map { ChapterType(it) } .associateBy { it.id } ids.map { chapters[it] } @@ -45,12 +52,14 @@ class ChaptersForMangaDataLoader : KotlinDataLoader { override val dataLoaderName = "ChaptersForMangaDataLoader" override fun getDataLoader(): DataLoader = - DataLoaderFactory.newDataLoader { ids -> + DataLoaderFactory.newDataLoader { ids, env -> future { + val userId = env.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val chaptersByMangaId = - ChapterTable.select { ChapterTable.manga inList ids } + ChapterTable.getWithUserData(userId) + .select { ChapterTable.manga inList ids } .map { ChapterType(it) } .groupBy { it.mangaId } ids.map { (chaptersByMangaId[it] ?: emptyList()).toNodeList() } @@ -83,16 +92,17 @@ class UnreadChapterCountForMangaDataLoader : KotlinDataLoader { override val dataLoaderName = "UnreadChapterCountForMangaDataLoader" override fun getDataLoader(): DataLoader = - DataLoaderFactory.newDataLoader { ids -> + DataLoaderFactory.newDataLoader { ids, env -> future { + val userId = env.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val unreadChapterCountByMangaId = - ChapterTable - .slice(ChapterTable.manga, ChapterTable.isRead.count()) - .select { (ChapterTable.manga inList ids) and (ChapterTable.isRead eq false) } + ChapterTable.getWithUserData(userId) + .slice(ChapterTable.manga, ChapterUserTable.isRead.count()) + .select { (ChapterTable.manga inList ids) and (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 } } } @@ -103,13 +113,14 @@ class LastReadChapterForMangaDataLoader : KotlinDataLoader { override val dataLoaderName = "LastReadChapterForMangaDataLoader" override fun getDataLoader(): DataLoader = - DataLoaderFactory.newDataLoader { ids -> + DataLoaderFactory.newDataLoader { ids, env -> future { + val userId = env.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val lastReadChaptersByMangaId = - ChapterTable - .select { (ChapterTable.manga inList ids) and (ChapterTable.isRead eq true) } + ChapterTable.getWithUserData(userId) + .select { (ChapterTable.manga inList ids) and (ChapterUserTable.isRead eq true) } .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) .groupBy { it[ChapterTable.manga].value } ids.map { id -> lastReadChaptersByMangaId[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 fe4cb199a..23ffb256f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MangaDataLoader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MangaDataLoader.kt @@ -12,26 +12,34 @@ 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.andWhere import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction +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" override fun getDataLoader(): DataLoader = - DataLoaderFactory.newDataLoader { ids -> + DataLoaderFactory.newDataLoader { ids, env -> future { + val userId = env.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val manga = - MangaTable.select { MangaTable.id inList ids } + MangaTable.getWithUserData(userId) + .select { MangaTable.id inList ids } .map { MangaType(it) } .associateBy { it.id } ids.map { manga[it] } @@ -44,15 +52,16 @@ class MangaForCategoryDataLoader : KotlinDataLoader { override val dataLoaderName = "MangaForCategoryDataLoader" override fun getDataLoader(): DataLoader = - DataLoaderFactory.newDataLoader { ids -> + DataLoaderFactory.newDataLoader { ids, env -> future { + val userId = env.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val itemsByRef = if (ids.contains(0)) { - MangaTable + MangaTable.getWithUserData(userId) .leftJoin(CategoryMangaTable) - .select { MangaTable.inLibrary eq true } + .select { MangaUserTable.inLibrary eq true and (CategoryMangaTable.user eq userId) } .andWhere { CategoryMangaTable.manga.isNull() } .map { MangaType(it) } .let { @@ -62,7 +71,7 @@ class MangaForCategoryDataLoader : KotlinDataLoader { emptyMap() } + CategoryMangaTable.innerJoin(MangaTable) - .select { CategoryMangaTable.category inList ids } + .select { 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 } } @@ -77,12 +86,14 @@ class MangaForSourceDataLoader : KotlinDataLoader { override val dataLoaderName = "MangaForSourceDataLoader" override fun getDataLoader(): DataLoader = - DataLoaderFactory.newDataLoader { ids -> + DataLoaderFactory.newDataLoader { ids, env -> future { + val userId = env.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val mangaBySourceId = - MangaTable.select { MangaTable.sourceReference inList ids } + MangaTable.getWithUserData(userId) + .select { MangaTable.sourceReference inList ids } .map { MangaType(it) } .groupBy { it.sourceId } ids.map { (mangaBySourceId[it] ?: emptyList()).toNodeList() } @@ -95,13 +106,15 @@ class MangaForIdsDataLoader : KotlinDataLoader, MangaNodeList> { override val dataLoaderName = "MangaForIdsDataLoader" override fun getDataLoader(): DataLoader, MangaNodeList> = - DataLoaderFactory.newDataLoader { mangaIds -> + DataLoaderFactory.newDataLoader { mangaIds, env -> future { + val userId = env.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val ids = mangaIds.flatten().distinct() val manga = - MangaTable.select { MangaTable.id inList ids } + MangaTable.getWithUserData(userId) + .select { MangaTable.id inList ids } .map { MangaType(it) } mangaIds.map { mangaIds -> manga.filter { it.id in mangaIds }.toNodeList() 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 d0b8c594b..cbbeec5d6 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MetaDataLoader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/MetaDataLoader.kt @@ -5,9 +5,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.select 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 @@ -15,18 +17,21 @@ import suwayomi.tachidesk.graphql.types.MangaMetaType import suwayomi.tachidesk.manga.model.table.CategoryMetaTable import suwayomi.tachidesk.manga.model.table.ChapterMetaTable import suwayomi.tachidesk.manga.model.table.MangaMetaTable +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" override fun getDataLoader(): DataLoader = - DataLoaderFactory.newDataLoader { ids -> + DataLoaderFactory.newDataLoader { ids, env -> future { + val userId = env.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val metasByRefId = - GlobalMetaTable.select { GlobalMetaTable.key inList ids } + GlobalMetaTable.select { GlobalMetaTable.key inList ids and (GlobalMetaTable.user eq userId) } .map { GlobalMetaType(it) } .associateBy { it.key } ids.map { metasByRefId[it] } @@ -39,12 +44,13 @@ class ChapterMetaDataLoader : KotlinDataLoader> { override val dataLoaderName = "ChapterMetaDataLoader" override fun getDataLoader(): DataLoader> = - DataLoaderFactory.newDataLoader> { ids -> + DataLoaderFactory.newDataLoader> { ids, env -> future { + val userId = env.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val metasByRefId = - ChapterMetaTable.select { ChapterMetaTable.ref inList ids } + ChapterMetaTable.select { ChapterMetaTable.ref inList ids and (ChapterMetaTable.user eq userId) } .map { ChapterMetaType(it) } .groupBy { it.chapterId } ids.map { metasByRefId[it].orEmpty() } @@ -57,12 +63,13 @@ class MangaMetaDataLoader : KotlinDataLoader> { override val dataLoaderName = "MangaMetaDataLoader" override fun getDataLoader(): DataLoader> = - DataLoaderFactory.newDataLoader> { ids -> + DataLoaderFactory.newDataLoader> { ids, env -> future { + val userId = env.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val metasByRefId = - MangaMetaTable.select { MangaMetaTable.ref inList ids } + MangaMetaTable.select { MangaMetaTable.ref inList ids and (MangaMetaTable.user eq userId) } .map { MangaMetaType(it) } .groupBy { it.mangaId } ids.map { metasByRefId[it].orEmpty() } @@ -75,12 +82,13 @@ class CategoryMetaDataLoader : KotlinDataLoader> { override val dataLoaderName = "CategoryMetaDataLoader" override fun getDataLoader(): DataLoader> = - DataLoaderFactory.newDataLoader> { ids -> + DataLoaderFactory.newDataLoader> { ids, env -> future { + val userId = env.getAttribute(Attribute.TachideskUser).requireUser() transaction { addLogger(Slf4jSqlDebugLogger) val metasByRefId = - CategoryMetaTable.select { CategoryMetaTable.ref inList ids } + CategoryMetaTable.select { CategoryMetaTable.ref inList ids and (CategoryMetaTable.user eq userId) } .map { CategoryMetaType(it) } .groupBy { it.categoryId } ids.map { metasByRefId[it].orEmpty() } 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 054c975df..ca7575df8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/BackupMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/BackupMutation.kt @@ -1,5 +1,6 @@ package suwayomi.tachidesk.graphql.mutations +import graphql.schema.DataFetchingEnvironment import io.javalin.http.UploadedFile import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope @@ -7,13 +8,16 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import suwayomi.tachidesk.graphql.server.TemporaryFileStorage +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.BackupRestoreState 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.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 @@ -29,12 +33,16 @@ class BackupMutation { ) @OptIn(DelicateCoroutinesApi::class) - fun restoreBackup(input: RestoreBackupInput): CompletableFuture { + fun restoreBackup( + dataFetchingEnvironment: DataFetchingEnvironment, + input: RestoreBackupInput, + ): CompletableFuture { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, backup) = input return future { GlobalScope.launch { - ProtoBackupImport.performRestore(backup.content) + ProtoBackupImport.performRestore(userId, backup.content) } val status = @@ -59,11 +67,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 = ProtoBackupExport.getBackupFilename() 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 311e32242..7b94eb5e5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/CategoryMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/CategoryMutation.kt @@ -1,5 +1,6 @@ package suwayomi.tachidesk.graphql.mutations +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 @@ -9,9 +10,9 @@ import org.jetbrains.exposed.sql.batchInsert import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insertAndGetId 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 +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.CategoryMetaType import suwayomi.tachidesk.graphql.types.CategoryType import suwayomi.tachidesk.graphql.types.MangaType @@ -23,6 +24,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( @@ -35,10 +39,14 @@ class CategoryMutation { val meta: CategoryMetaType, ) - fun setCategoryMeta(input: SetCategoryMetaInput): SetCategoryMetaPayload { + fun setCategoryMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetCategoryMetaInput, + ): SetCategoryMetaPayload { + 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) return SetCategoryMetaPayload(clientMutationId, meta) } @@ -55,16 +63,27 @@ class CategoryMutation { val category: CategoryType, ) - fun deleteCategoryMeta(input: DeleteCategoryMetaInput): DeleteCategoryMetaPayload { + fun deleteCategoryMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteCategoryMetaInput, + ): DeleteCategoryMetaPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, categoryId, key) = input val (meta, category) = transaction { val meta = - CategoryMetaTable.select { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) } - .firstOrNull() - - CategoryMetaTable.deleteWhere { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) } + CategoryMetaTable.select { + 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 { @@ -110,26 +129,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 } @@ -138,10 +158,14 @@ class CategoryMutation { } } - fun updateCategory(input: UpdateCategoryInput): UpdateCategoryPayload { + fun updateCategory( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateCategoryInput, + ): UpdateCategoryPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, id, patch) = input - updateCategories(listOf(id), patch) + updateCategories(userId, listOf(id), patch) val category = transaction { @@ -154,10 +178,14 @@ class CategoryMutation { ) } - fun updateCategories(input: UpdateCategoriesInput): UpdateCategoriesPayload { + fun updateCategories( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateCategoriesInput, + ): UpdateCategoriesPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, ids, patch) = input - updateCategories(ids, patch) + updateCategories(userId, ids, patch) val categories = transaction { @@ -181,7 +209,11 @@ class CategoryMutation { val position: Int, ) - fun updateCategoryOrder(input: UpdateCategoryOrderInput): UpdateCategoryOrderPayload { + fun updateCategoryOrder( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateCategoryOrderInput, + ): UpdateCategoryOrderPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, categoryId, position) = input require(position > 0) { "'order' must not be <= 0" @@ -190,31 +222,31 @@ class CategoryMutation { transaction { val currentOrder = CategoryTable - .select { CategoryTable.id eq categoryId } + .select { 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.select { CategoryTable.user eq userId }.orderBy(CategoryTable.order).map { CategoryType(it) } } return UpdateCategoryOrderPayload( @@ -236,10 +268,14 @@ class CategoryMutation { val category: CategoryType, ) - fun createCategory(input: CreateCategoryInput): CreateCategoryPayload { + fun createCategory( + dataFetchingEnvironment: DataFetchingEnvironment, + input: CreateCategoryInput, + ): CreateCategoryPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, name, order, default, includeInUpdate) = input transaction { - require(CategoryTable.select { CategoryTable.name eq input.name }.isEmpty()) { + require(CategoryTable.select { CategoryTable.name eq input.name and (CategoryTable.user eq userId) }.isEmpty()) { "'name' must be unique" } } @@ -255,13 +291,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) { @@ -272,9 +309,9 @@ class CategoryMutation { } } - Category.normalizeCategories() + Category.normalizeCategories(userId) - CategoryType(CategoryTable.select { CategoryTable.id eq id }.first()) + CategoryType(CategoryTable.select { CategoryTable.id eq id and (CategoryTable.user eq userId) }.first()) } return CreateCategoryPayload(clientMutationId, category) @@ -291,7 +328,11 @@ class CategoryMutation { val mangas: List, ) - fun deleteCategory(input: DeleteCategoryInput): DeleteCategoryPayload { + fun deleteCategory( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteCategoryInput, + ): DeleteCategoryPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, categoryId) = input if (categoryId == 0) { // Don't delete default category return DeleteCategoryPayload( @@ -304,19 +345,19 @@ class CategoryMutation { val (category, mangas) = transaction { val category = - CategoryTable.select { CategoryTable.id eq categoryId } + CategoryTable.select { CategoryTable.id eq categoryId and (CategoryTable.user eq userId) } .firstOrNull() val mangas = transaction { - MangaTable.innerJoin(CategoryMangaTable) - .select { CategoryMangaTable.category eq categoryId } + MangaTable.getWithUserData(userId).innerJoin(CategoryMangaTable) + .select { 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) @@ -357,15 +398,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()) { @@ -375,7 +419,9 @@ class CategoryMutation { patch.addToCategories.forEach { categoryId -> val existingMapping = CategoryMangaTable.select { - (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) { @@ -388,19 +434,24 @@ class CategoryMutation { CategoryMangaTable.batchInsert(newCategories) { (manga, category) -> this[CategoryMangaTable.manga] = manga this[CategoryMangaTable.category] = category + this[CategoryMangaTable.user] = userId } } } } - fun updateMangaCategories(input: UpdateMangaCategoriesInput): UpdateMangaCategoriesPayload { + fun updateMangaCategories( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateMangaCategoriesInput, + ): UpdateMangaCategoriesPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, id, patch) = input - updateMangas(listOf(id), patch) + updateMangas(userId, listOf(id), patch) val manga = transaction { - MangaType(MangaTable.select { MangaTable.id eq id }.first()) + MangaType(MangaTable.getWithUserData(userId).select { MangaTable.id eq id }.first()) } return UpdateMangaCategoriesPayload( @@ -409,14 +460,18 @@ class CategoryMutation { ) } - fun updateMangasCategories(input: UpdateMangasCategoriesInput): UpdateMangasCategoriesPayload { + fun updateMangasCategories( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateMangasCategoriesInput, + ): UpdateMangasCategoriesPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, ids, patch) = input - updateMangas(ids, patch) + updateMangas(userId, ids, patch) val mangas = transaction { - MangaTable.select { MangaTable.id inList ids }.map { MangaType(it) } + MangaTable.getWithUserData(userId).select { MangaTable.id inList ids }.map { MangaType(it) } } return UpdateMangasCategoriesPayload( 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 5ad52d488..282286acf 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt @@ -1,18 +1,25 @@ package suwayomi.tachidesk.graphql.mutations +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.select import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update +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 @@ -51,13 +58,23 @@ class ChapterMutation { ) private fun updateChapters( + userId: Int, ids: List, patch: UpdateChapterPatch, ) { transaction { + val currentChapterUserItems = + ChapterUserTable.select { 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 - ChapterTable.update({ ChapterTable.id inList ids }) { update -> + ChapterUserTable.update({ ChapterUserTable.chapter inList ids }) { update -> patch.isRead?.also { update[isRead] = it } @@ -73,14 +90,18 @@ class ChapterMutation { } } - fun updateChapter(input: UpdateChapterInput): UpdateChapterPayload { + fun updateChapter( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateChapterInput, + ): UpdateChapterPayload { + 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.select { ChapterTable.id eq id }.first()) + ChapterType(ChapterTable.getWithUserData(userId).select { ChapterTable.id eq id }.first()) } return UpdateChapterPayload( @@ -89,14 +110,18 @@ class ChapterMutation { ) } - fun updateChapters(input: UpdateChaptersInput): UpdateChaptersPayload { + fun updateChapters( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateChaptersInput, + ): UpdateChaptersPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, ids, patch) = input - updateChapters(ids, patch) + updateChapters(userId, ids, patch) val chapters = transaction { - ChapterTable.select { ChapterTable.id inList ids }.map { ChapterType(it) } + ChapterTable.getWithUserData(userId).select { ChapterTable.id inList ids }.map { ChapterType(it) } } return UpdateChaptersPayload( @@ -115,15 +140,19 @@ class ChapterMutation { val chapters: List, ) - fun fetchChapters(input: FetchChaptersInput): CompletableFuture { + fun fetchChapters( + dataFetchingEnvironment: DataFetchingEnvironment, + input: FetchChaptersInput, + ): CompletableFuture { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, mangaId) = input return future { - Chapter.fetchChapterList(mangaId) + Chapter.fetchChapterList(userId, mangaId) }.thenApply { val chapters = transaction { - ChapterTable.select { ChapterTable.manga eq mangaId } + ChapterTable.getWithUserData(userId).select { ChapterTable.manga eq mangaId } .orderBy(ChapterTable.sourceOrder) .map { ChapterType(it) } } @@ -145,10 +174,14 @@ class ChapterMutation { val meta: ChapterMetaType, ) - fun setChapterMeta(input: SetChapterMetaInput): SetChapterMetaPayload { + fun setChapterMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetChapterMetaInput, + ): SetChapterMetaPayload { + 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) return SetChapterMetaPayload(clientMutationId, meta) } @@ -165,20 +198,31 @@ class ChapterMutation { val chapter: ChapterType, ) - fun deleteChapterMeta(input: DeleteChapterMetaInput): DeleteChapterMetaPayload { + fun deleteChapterMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteChapterMetaInput, + ): DeleteChapterMetaPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, chapterId, key) = input val (meta, chapter) = transaction { val meta = - ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } - .firstOrNull() - - ChapterMetaTable.deleteWhere { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } + ChapterMetaTable.select { + 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.select { ChapterTable.id eq chapterId }.first()) + ChapterType(ChapterTable.getWithUserData(userId).select { ChapterTable.id eq chapterId }.first()) } if (meta != null) { @@ -202,11 +246,15 @@ class ChapterMutation { val chapter: ChapterType, ) - fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture { + fun fetchChapterPages( + dataFetchingEnvironment: DataFetchingEnvironment, + input: FetchChapterPagesInput, + ): CompletableFuture { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, chapterId) = input return future { - getChapterDownloadReadyById(chapterId) + getChapterDownloadReadyById(userId, chapterId) }.thenApply { chapter -> 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 974e30524..d5eabda83 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/DownloadMutation.kt @@ -1,9 +1,11 @@ package suwayomi.tachidesk.graphql.mutations +import graphql.schema.DataFetchingEnvironment import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeout import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.ChapterType import suwayomi.tachidesk.graphql.types.DownloadStatus import suwayomi.tachidesk.manga.impl.Chapter @@ -11,7 +13,9 @@ import suwayomi.tachidesk.manga.impl.Manga import suwayomi.tachidesk.manga.impl.download.DownloadManager 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 @@ -26,7 +30,11 @@ class DownloadMutation { val chapters: List, ) - fun deleteDownloadedChapters(input: DeleteDownloadedChaptersInput): DeleteDownloadedChaptersPayload { + fun deleteDownloadedChapters( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteDownloadedChaptersInput, + ): DeleteDownloadedChaptersPayload { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, chapters) = input Chapter.deleteChapters(chapters) @@ -51,7 +59,11 @@ class DownloadMutation { val chapters: ChapterType, ) - fun deleteDownloadedChapter(input: DeleteDownloadedChapterInput): DeleteDownloadedChapterPayload { + fun deleteDownloadedChapter( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteDownloadedChapterInput, + ): DeleteDownloadedChapterPayload { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, chapter) = input Chapter.deleteChapters(listOf(chapter)) @@ -75,7 +87,11 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun enqueueChapterDownloads(input: EnqueueChapterDownloadsInput): CompletableFuture { + fun enqueueChapterDownloads( + dataFetchingEnvironment: DataFetchingEnvironment, + input: EnqueueChapterDownloadsInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, chapters) = input DownloadManager.enqueue(DownloadManager.EnqueueInput(chapters)) @@ -101,7 +117,11 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun enqueueChapterDownload(input: EnqueueChapterDownloadInput): CompletableFuture { + fun enqueueChapterDownload( + dataFetchingEnvironment: DataFetchingEnvironment, + input: EnqueueChapterDownloadInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, chapter) = input DownloadManager.enqueue(DownloadManager.EnqueueInput(listOf(chapter))) @@ -127,7 +147,11 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun dequeueChapterDownloads(input: DequeueChapterDownloadsInput): CompletableFuture { + fun dequeueChapterDownloads( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DequeueChapterDownloadsInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, chapters) = input DownloadManager.dequeue(DownloadManager.EnqueueInput(chapters)) @@ -153,7 +177,11 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun dequeueChapterDownload(input: DequeueChapterDownloadInput): CompletableFuture { + fun dequeueChapterDownload( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DequeueChapterDownloadInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, chapter) = input DownloadManager.dequeue(DownloadManager.EnqueueInput(listOf(chapter))) @@ -178,7 +206,11 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun startDownloader(input: StartDownloaderInput): CompletableFuture { + fun startDownloader( + dataFetchingEnvironment: DataFetchingEnvironment, + input: StartDownloaderInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.start() return future { @@ -203,7 +235,11 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun stopDownloader(input: StopDownloaderInput): CompletableFuture { + fun stopDownloader( + dataFetchingEnvironment: DataFetchingEnvironment, + input: StopDownloaderInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() return future { DownloadManager.stop() StopDownloaderPayload( @@ -227,7 +263,11 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun clearDownloader(input: ClearDownloaderInput): CompletableFuture { + fun clearDownloader( + dataFetchingEnvironment: DataFetchingEnvironment, + input: ClearDownloaderInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() return future { DownloadManager.clear() ClearDownloaderPayload( @@ -253,7 +293,11 @@ class DownloadMutation { val downloadStatus: DownloadStatus, ) - fun reorderChapterDownload(input: ReorderChapterDownloadInput): CompletableFuture { + fun reorderChapterDownload( + dataFetchingEnvironment: DataFetchingEnvironment, + input: ReorderChapterDownloadInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, chapter, to) = input DownloadManager.reorder(chapter, to) @@ -278,10 +322,14 @@ class DownloadMutation { data class DownloadAheadPayload(val clientMutationId: String?) - fun downloadAhead(input: DownloadAheadInput): DownloadAheadPayload { + fun downloadAhead( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DownloadAheadInput, + ): DownloadAheadPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, mangaIds, latestReadChapterIds) = input - Manga.downloadAhead(mangaIds, latestReadChapterIds ?: emptyList()) + Manga.downloadAhead(userId, mangaIds, latestReadChapterIds ?: emptyList()) return DownloadAheadPayload(clientMutationId) } 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 15c874712..e6fa5b81f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ExtensionMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ExtensionMutation.kt @@ -1,14 +1,18 @@ package suwayomi.tachidesk.graphql.mutations import eu.kanade.tachiyomi.source.local.LocalSource +import graphql.schema.DataFetchingEnvironment import io.javalin.http.UploadedFile import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction +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 { @@ -69,7 +73,11 @@ class ExtensionMutation { } } - fun updateExtension(input: UpdateExtensionInput): CompletableFuture { + fun updateExtension( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateExtensionInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() val (clientMutationId, id, patch) = input return future { @@ -87,7 +95,11 @@ class ExtensionMutation { } } - fun updateExtensions(input: UpdateExtensionsInput): CompletableFuture { + fun updateExtensions( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateExtensionsInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() val (clientMutationId, ids, patch) = input return future { @@ -115,7 +127,11 @@ class ExtensionMutation { val extensions: List, ) - fun fetchExtensions(input: FetchExtensionsInput): CompletableFuture { + fun fetchExtensions( + dataFetchingEnvironment: DataFetchingEnvironment, + input: FetchExtensionsInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(JavalinSetup.Attribute.TachideskUser).requireUser() val (clientMutationId) = input return future { 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 17214936b..9e0acfebf 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/InfoMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/InfoMutation.kt @@ -1,13 +1,17 @@ package suwayomi.tachidesk.graphql.mutations +import graphql.schema.DataFetchingEnvironment import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeout +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.UpdateState.DOWNLOADING import suwayomi.tachidesk.graphql.types.UpdateState.STOPPED import suwayomi.tachidesk.graphql.types.WebUIUpdateInfo import suwayomi.tachidesk.graphql.types.WebUIUpdateStatus +import suwayomi.tachidesk.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future import suwayomi.tachidesk.server.serverConfig +import suwayomi.tachidesk.server.user.requireUser import suwayomi.tachidesk.server.util.WebInterfaceManager import java.util.concurrent.CompletableFuture import kotlin.time.Duration.Companion.seconds @@ -22,7 +26,11 @@ class InfoMutation { val updateStatus: WebUIUpdateStatus, ) - fun updateWebUI(input: WebUIUpdateInput): CompletableFuture { + fun updateWebUI( + dataFetchingEnvironment: DataFetchingEnvironment, + input: WebUIUpdateInput, + ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() return future { withTimeout(30.seconds) { if (WebInterfaceManager.status.value.state === DOWNLOADING) { 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 16895ea06..89d99d367 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt @@ -1,18 +1,26 @@ package suwayomi.tachidesk.graphql.mutations +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.select import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.MangaMetaType import suwayomi.tachidesk.graphql.types.MangaType import suwayomi.tachidesk.manga.impl.Library import suwayomi.tachidesk.manga.impl.Manga 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.server.JavalinSetup.Attribute import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.user.requireUser +import java.time.Instant import java.util.concurrent.CompletableFuture /** @@ -48,35 +56,54 @@ class MangaMutation { ) private suspend fun updateMangas( + userId: Int, ids: List, patch: UpdateMangaPatch, ) { transaction { + val currentMangaUserItems = + MangaUserTable.select { 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 (patch.inLibrary) { + update[inLibraryAt] = now + } } } } }.apply { if (patch.inLibrary != null) { ids.forEach { - Library.handleMangaThumbnail(it, patch.inLibrary) + Library.handleMangaThumbnail(it) } } } } - fun updateManga(input: UpdateMangaInput): CompletableFuture { + fun updateManga( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateMangaInput, + ): CompletableFuture { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, id, patch) = input return future { - updateMangas(listOf(id), patch) + updateMangas(userId, listOf(id), patch) }.thenApply { val manga = transaction { - MangaType(MangaTable.select { MangaTable.id eq id }.first()) + MangaType(MangaTable.getWithUserData(userId).select { MangaTable.id eq id }.first()) } UpdateMangaPayload( @@ -86,15 +113,19 @@ class MangaMutation { } } - fun updateMangas(input: UpdateMangasInput): CompletableFuture { + fun updateMangas( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateMangasInput, + ): CompletableFuture { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, ids, patch) = input return future { - updateMangas(ids, patch) + updateMangas(userId, ids, patch) }.thenApply { val mangas = transaction { - MangaTable.select { MangaTable.id inList ids }.map { MangaType(it) } + MangaTable.getWithUserData(userId).select { MangaTable.id inList ids }.map { MangaType(it) } } UpdateMangasPayload( @@ -114,7 +145,11 @@ class MangaMutation { val manga: MangaType, ) - fun fetchManga(input: FetchMangaInput): CompletableFuture { + fun fetchManga( + dataFetchingEnvironment: DataFetchingEnvironment, + input: FetchMangaInput, + ): CompletableFuture { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, id) = input return future { @@ -122,7 +157,7 @@ class MangaMutation { }.thenApply { val manga = transaction { - MangaTable.select { MangaTable.id eq id }.first() + MangaTable.getWithUserData(userId).select { MangaTable.id eq id }.first() } FetchMangaPayload( clientMutationId = clientMutationId, @@ -141,10 +176,14 @@ class MangaMutation { val meta: MangaMetaType, ) - fun setMangaMeta(input: SetMangaMetaInput): SetMangaMetaPayload { + fun setMangaMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetMangaMetaInput, + ): SetMangaMetaPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, meta) = input - Manga.modifyMangaMeta(meta.mangaId, meta.key, meta.value) + Manga.modifyMangaMeta(userId, meta.mangaId, meta.key, meta.value) return SetMangaMetaPayload(clientMutationId, meta) } @@ -161,20 +200,31 @@ class MangaMutation { val manga: MangaType, ) - fun deleteMangaMeta(input: DeleteMangaMetaInput): DeleteMangaMetaPayload { + fun deleteMangaMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteMangaMetaInput, + ): DeleteMangaMetaPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, mangaId, key) = input val (meta, manga) = transaction { val meta = - MangaMetaTable.select { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) } - .firstOrNull() - - MangaMetaTable.deleteWhere { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) } + MangaMetaTable.select { + 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.select { MangaTable.id eq mangaId }.first()) + MangaType(MangaTable.getWithUserData(userId).select { 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 dbe1c96ac..e04d283b7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MetaMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MetaMutation.kt @@ -1,12 +1,17 @@ package suwayomi.tachidesk.graphql.mutations +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.select import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.global.impl.GlobalMeta import suwayomi.tachidesk.global.model.table.GlobalMetaTable +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( @@ -19,10 +24,14 @@ class MetaMutation { val meta: GlobalMetaType, ) - fun setGlobalMeta(input: SetGlobalMetaInput): SetGlobalMetaPayload { + fun setGlobalMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetGlobalMetaInput, + ): SetGlobalMetaPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, meta) = input - GlobalMeta.modifyMeta(meta.key, meta.value) + GlobalMeta.modifyMeta(userId, meta.key, meta.value) return SetGlobalMetaPayload(clientMutationId, meta) } @@ -37,16 +46,20 @@ class MetaMutation { val meta: GlobalMetaType?, ) - fun deleteGlobalMeta(input: DeleteGlobalMetaInput): DeleteGlobalMetaPayload { + fun deleteGlobalMeta( + dataFetchingEnvironment: DataFetchingEnvironment, + input: DeleteGlobalMetaInput, + ): DeleteGlobalMetaPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, key) = input val meta = transaction { val meta = - GlobalMetaTable.select { GlobalMetaTable.key eq key } + GlobalMetaTable.select { 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 8c3fa9fd3..d61ec86b4 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt @@ -1,11 +1,15 @@ package suwayomi.tachidesk.graphql.mutations +import graphql.schema.DataFetchingEnvironment +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.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 class SettingsMutation { @@ -88,6 +92,9 @@ class SettingsMutation { if (settings.basicAuthPassword != null) { serverConfig.basicAuthPassword.value = settings.basicAuthPassword!! } + if (settings.multiUser != null) { + serverConfig.multiUser.value = settings.multiUser!! + } if (settings.debugLogsEnabled != null) { serverConfig.debugLogsEnabled.value = settings.debugLogsEnabled!! @@ -114,7 +121,11 @@ class SettingsMutation { } } - fun setSettings(input: SetSettingsInput): SetSettingsPayload { + fun setSettings( + dataFetchingEnvironment: DataFetchingEnvironment, + input: SetSettingsInput, + ): SetSettingsPayload { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, settings) = input updateSettings(settings) @@ -129,7 +140,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 a7911b8c3..35b2ba478 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SourceMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SourceMutation.kt @@ -5,8 +5,10 @@ import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.SwitchPreferenceCompat +import graphql.schema.DataFetchingEnvironment import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.graphql.server.getAttribute import suwayomi.tachidesk.graphql.types.FilterChange import suwayomi.tachidesk.graphql.types.MangaType import suwayomi.tachidesk.graphql.types.Preference @@ -16,7 +18,10 @@ import suwayomi.tachidesk.manga.impl.MangaList.insertOrGet import suwayomi.tachidesk.manga.impl.Source import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource import suwayomi.tachidesk.manga.model.table.MangaTable +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 { @@ -41,7 +46,11 @@ class SourceMutation { val hasNextPage: Boolean, ) - fun fetchSourceManga(input: FetchSourceMangaInput): CompletableFuture { + fun fetchSourceManga( + dataFetchingEnvironment: DataFetchingEnvironment, + input: FetchSourceMangaInput, + ): CompletableFuture { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, sourceId, type, page, query, filters) = input return future { @@ -68,7 +77,7 @@ class SourceMutation { val mangas = transaction { - MangaTable.select { MangaTable.id inList mangaIds } + MangaTable.getWithUserData(userId).select { MangaTable.id inList mangaIds } .map { MangaType(it) } }.sortedBy { mangaIds.indexOf(it.id) @@ -102,7 +111,11 @@ class SourceMutation { val preferences: List, ) - fun updateSourcePreference(input: UpdateSourcePreferenceInput): UpdateSourcePreferencePayload { + fun updateSourcePreference( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateSourcePreferenceInput, + ): UpdateSourcePreferencePayload { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val (clientMutationId, sourceId, change) = input Source.setSourcePreference(sourceId, change.position, "") { preference -> 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 b7ce3eaa7..a1076dab1 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/UpdateMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/UpdateMutation.kt @@ -1,15 +1,20 @@ package suwayomi.tachidesk.graphql.mutations +import graphql.schema.DataFetchingEnvironment +import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction import org.kodein.di.DI import org.kodein.di.conf.global import org.kodein.di.instance +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.user.requireUser class UpdateMutation { private val updater by DI.global.instance() @@ -23,9 +28,14 @@ class UpdateMutation { val updateStatus: UpdateStatus, ) - fun updateLibraryManga(input: UpdateLibraryMangaInput): UpdateLibraryMangaPayload { + fun updateLibraryManga( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateLibraryMangaInput, + ): UpdateLibraryMangaPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + updater.addCategoriesToUpdateQueue( - Category.getCategoryList(), + Category.getCategoryList(userId), clear = true, forceAll = false, ) @@ -43,10 +53,14 @@ class UpdateMutation { val updateStatus: UpdateStatus, ) - fun updateCategoryManga(input: UpdateCategoryMangaInput): UpdateCategoryMangaPayload { + fun updateCategoryManga( + dataFetchingEnvironment: DataFetchingEnvironment, + input: UpdateCategoryMangaInput, + ): UpdateCategoryMangaPayload { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val categories = transaction { - CategoryTable.select { CategoryTable.id inList input.categories }.map { + CategoryTable.select { CategoryTable.id inList input.categories and (CategoryTable.user eq userId) }.map { CategoryTable.toDataClass(it) } } 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 000000000..74a8ecf62 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/UserMutation.kt @@ -0,0 +1,137 @@ +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.select +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.select { 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.select { 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 e5aa5f10a..665b8c8ea 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( @@ -20,14 +24,19 @@ class BackupQuery { val missingSources: List, ) - fun validateBackup(input: ValidateBackupInput): ValidateBackupResult { + fun validateBackup( + dataFetchingEnvironment: DataFetchingEnvironment, + input: ValidateBackupInput, + ): ValidateBackupResult { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val result = ProtoBackupValidator.validate(input.backup.content) return ValidateBackupResult( result.missingSourceIds.map { ValidateBackupSource(it.first, it.second) }, ) } - fun restoreStatus(): BackupRestoreStatus { + fun restoreStatus(dataFetchingEnvironment: DataFetchingEnvironment): BackupRestoreStatus { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() return ProtoBackupImport.backupRestoreState.value.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 9d6a393c2..5fd253336 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/CategoryQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/CategoryQuery.kt @@ -21,7 +21,7 @@ import org.jetbrains.exposed.sql.SortOrder.DESC_NULLS_LAST 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.selectAll +import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter import suwayomi.tachidesk.graphql.queries.filter.Filter @@ -33,6 +33,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.OrderBy import suwayomi.tachidesk.graphql.server.primitives.PageInfo @@ -43,6 +44,8 @@ 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 { @@ -50,6 +53,7 @@ class CategoryQuery { dataFetchingEnvironment: DataFetchingEnvironment, id: Int, ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() return dataFetchingEnvironment.getValueFromDataLoader("CategoryDataLoader", id) } @@ -123,6 +127,7 @@ class CategoryQuery { } fun categories( + dataFetchingEnvironment: DataFetchingEnvironment, condition: CategoryCondition? = null, filter: CategoryFilter? = null, orderBy: CategoryOrderBy? = null, @@ -133,9 +138,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.select { 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 307746516..e742b36a1 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt @@ -18,9 +18,11 @@ import org.jetbrains.exposed.sql.SortOrder.ASC_NULLS_LAST import org.jetbrains.exposed.sql.SortOrder.DESC import org.jetbrains.exposed.sql.SortOrder.DESC_NULLS_FIRST import org.jetbrains.exposed.sql.SortOrder.DESC_NULLS_LAST +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.ChapterQuery.ChapterOrderBy.ID @@ -36,6 +38,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.OrderBy import suwayomi.tachidesk.graphql.server.primitives.PageInfo @@ -46,7 +49,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 /** @@ -59,6 +67,7 @@ class ChapterQuery { dataFetchingEnvironment: DataFetchingEnvironment, id: Int, ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() return dataFetchingEnvironment.getValueFromDataLoader("ChapterDataLoader", id) } @@ -68,7 +77,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), ; @@ -79,7 +88,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) } } @@ -91,7 +100,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) } } @@ -138,10 +147,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) @@ -183,10 +192,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), @@ -195,10 +204,11 @@ class ChapterQuery { ) } - fun getLibraryOp() = andFilterWithCompare(MangaTable.inLibrary, inLibrary) + fun getLibraryOp() = andFilterWithCompare(MangaUserTable.inLibrary, inLibrary) } fun chapters( + dataFetchingEnvironment: DataFetchingEnvironment, condition: ChapterCondition? = null, filter: ChapterFilter? = null, orderBy: ChapterOrderBy? = null, @@ -209,14 +219,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 44141b1c7..9c7e95a8f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt @@ -34,6 +34,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.OrderBy import suwayomi.tachidesk.graphql.server.primitives.PageInfo @@ -44,6 +45,8 @@ 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 { @@ -51,6 +54,7 @@ class ExtensionQuery { dataFetchingEnvironment: DataFetchingEnvironment, pkgName: String, ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() return dataFetchingEnvironment.getValueFromDataLoader("ExtensionDataLoader", pkgName) } @@ -151,6 +155,7 @@ class ExtensionQuery { } fun extensions( + dataFetchingEnvironment: DataFetchingEnvironment, condition: ExtensionCondition? = null, filter: ExtensionFilter? = null, orderBy: ExtensionOrderBy? = null, @@ -161,6 +166,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 c2b7dfca0..efeaaca25 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/InfoQuery.kt @@ -1,11 +1,15 @@ 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.WebUIUpdateInfo 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 java.util.concurrent.CompletableFuture @@ -20,7 +24,9 @@ class InfoQuery { val discord: String, ) - fun about(): AboutPayload { + fun about(dataFetchingEnvironment: DataFetchingEnvironment): AboutPayload { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return AboutPayload( BuildConfig.NAME, BuildConfig.VERSION, @@ -39,7 +45,9 @@ class InfoQuery { val url: String, ) - fun checkForServerUpdates(): CompletableFuture> { + fun checkForServerUpdates(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture> { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() + return future { AppUpdate.checkUpdate().map { CheckForServerUpdatesPayload( @@ -51,7 +59,8 @@ class InfoQuery { } } - fun checkForWebUIUpdate(): CompletableFuture { + fun checkForWebUIUpdate(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() return future { val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable() WebUIUpdateInfo( @@ -62,7 +71,9 @@ class InfoQuery { } } - fun getWebUIUpdateStatus(): WebUIUpdateStatus { + 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 af5aa8ad3..939affb13 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MangaQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MangaQuery.kt @@ -35,6 +35,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.OrderBy import suwayomi.tachidesk.graphql.server.primitives.PageInfo @@ -46,6 +47,10 @@ import suwayomi.tachidesk.graphql.types.MangaNodeList import suwayomi.tachidesk.graphql.types.MangaType 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 { @@ -53,13 +58,14 @@ class MangaQuery { dataFetchingEnvironment: DataFetchingEnvironment, id: Int, ): 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), ; @@ -67,7 +73,7 @@ class MangaQuery { return 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) } } @@ -76,7 +82,7 @@ class MangaQuery { return 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 +130,8 @@ class MangaQuery { opAnd.eq(description, MangaTable.description) opAnd.eq(genre?.joinToString(), MangaTable.genre) 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) @@ -195,8 +201,8 @@ class MangaQuery { andFilterWithCompareString(MangaTable.author, author), andFilterWithCompareString(MangaTable.description, description), 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), @@ -205,6 +211,7 @@ class MangaQuery { } fun mangas( + dataFetchingEnvironment: DataFetchingEnvironment, condition: MangaCondition? = null, filter: MangaFilter? = null, orderBy: MangaOrderBy? = null, @@ -215,9 +222,10 @@ class MangaQuery { last: Int? = null, offset: Int? = null, ): MangaNodeList { + val userId = dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() val queryResults = transaction { - val res = MangaTable.selectAll() + val res = MangaTable.getWithUserData(userId).selectAll() 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 15d5adf06..4b0044449 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MetaQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/MetaQuery.kt @@ -21,7 +21,7 @@ import org.jetbrains.exposed.sql.SortOrder.DESC_NULLS_LAST 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.selectAll +import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.global.model.table.GlobalMetaTable import suwayomi.tachidesk.graphql.queries.filter.Filter @@ -30,6 +30,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.OrderBy import suwayomi.tachidesk.graphql.server.primitives.PageInfo @@ -39,6 +40,8 @@ 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 { @@ -46,6 +49,7 @@ class MetaQuery { dataFetchingEnvironment: DataFetchingEnvironment, key: String, ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() return dataFetchingEnvironment.getValueFromDataLoader("GlobalMetaDataLoader", key) } @@ -107,6 +111,7 @@ class MetaQuery { } fun metas( + dataFetchingEnvironment: DataFetchingEnvironment, condition: MetaCondition? = null, filter: MetaFilter? = null, orderBy: MetaOrderBy? = null, @@ -117,9 +122,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.select { 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 6be6aafd8..9574eee10 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SettingsQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SettingsQuery.kt @@ -1,9 +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 { + 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 a98fc9bbb..8bbdd25fd 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SourceQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/SourceQuery.kt @@ -33,6 +33,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.OrderBy import suwayomi.tachidesk.graphql.server.primitives.PageInfo @@ -43,6 +44,9 @@ 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 { @@ -50,6 +54,7 @@ class SourceQuery { dataFetchingEnvironment: DataFetchingEnvironment, id: Long, ): CompletableFuture { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() return dataFetchingEnvironment.getValueFromDataLoader("SourceDataLoader", id) } @@ -123,6 +128,7 @@ class SourceQuery { } fun sources( + dataFetchingEnvironment: DataFetchingEnvironment, condition: SourceCondition? = null, filter: SourceFilter? = null, orderBy: SourceOrderBy? = null, @@ -133,6 +139,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/UpdateQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/UpdateQuery.kt index 344c054bb..5f9f09395 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/UpdateQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/UpdateQuery.kt @@ -1,21 +1,27 @@ package suwayomi.tachidesk.graphql.queries +import graphql.schema.DataFetchingEnvironment import org.kodein.di.DI import org.kodein.di.conf.global import org.kodein.di.instance +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.user.requireUser class UpdateQuery { private val updater by DI.global.instance() - fun updateStatus(): UpdateStatus { + fun updateStatus(dataFetchingEnvironment: DataFetchingEnvironment): UpdateStatus { + dataFetchingEnvironment.getAttribute(Attribute.TachideskUser).requireUser() return UpdateStatus(updater.status.value) } data class LastUpdateTimestampPayload(val timestamp: Long) - fun lastUpdateTimestamp(): LastUpdateTimestampPayload { + 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 000000000..a205b5de2 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataFetcherExceptionHandler.kt @@ -0,0 +1,33 @@ +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.HttpCode +import suwayomi.tachidesk.server.user.ForbiddenException +import suwayomi.tachidesk.server.user.UnauthorizedException + +class TachideskDataFetcherExceptionHandler : SimpleDataFetcherExceptionHandler() { + @Suppress("OVERRIDE_DEPRECATION") + override fun onException(handlerParameters: DataFetcherExceptionHandlerParameters): DataFetcherExceptionHandlerResult { + 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(HttpCode.UNAUTHORIZED) + return 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(HttpCode.FORBIDDEN) + return DataFetcherExceptionHandlerResult.newResult().error(error).build() + } + return super.onException(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 4eed39b9c..1dfc5f0df 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLContextFactory.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLContextFactory.kt @@ -7,34 +7,56 @@ package suwayomi.tachidesk.graphql.server -import com.expediagroup.graphql.generator.execution.GraphQLContext 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 Tachidesk should create its context given the [Context] */ -class TachideskGraphQLContextFactory : GraphQLContextFactory { - override suspend fun generateContextMap(request: Context): Map<*, Any> = emptyMap() -// 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 -// } -// } - - fun generateContextMap(request: WsContext): Map<*, Any> = emptyMap() +@Suppress("DEPRECATION") +class TachideskGraphQLContextFactory : GraphQLContextFactory { + override suspend fun generateContextMap(request: Context): Map { + return mapOf( + Context::class to request, + request.getPair(Attribute.TachideskUser), + ) + } + + fun generateContextMap(request: WsContext): Map { + return 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 { + return get(attribute) +} + +fun DataFetchingEnvironment.getAttribute(attribute: Attribute): T { + return graphQlContext.get(attribute) +} + +val BatchLoaderEnvironment.graphQlContext: GraphQLContext + get() = keyContextsList.filterIsInstance().first() + +fun BatchLoaderEnvironment.getAttribute(attribute: Attribute): T { + return 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 b0d350482..7ad80bc50 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt @@ -24,6 +24,7 @@ import suwayomi.tachidesk.graphql.mutations.MetaMutation import suwayomi.tachidesk.graphql.mutations.SettingsMutation import suwayomi.tachidesk.graphql.mutations.SourceMutation 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 @@ -90,6 +91,7 @@ val schema = TopLevelObject(SettingsMutation()), TopLevelObject(SourceMutation()), 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 3f2c84c9d..15d0044a0 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLServer.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLServer.kt @@ -45,6 +45,7 @@ class TachideskGraphQLServer( private fun getGraphQLObject(): GraphQL = GraphQL.newGraphQL(schema) .subscriptionExecutionStrategy(FlowSubscriptionExecutionStrategy()) + .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 c354e52bb..100d3772d 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( @@ -46,10 +47,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 d006f82ee..c12475adb 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt @@ -20,6 +20,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 @@ -53,8 +54,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, row[MangaTable.realUrl], row[MangaTable.lastFetchedAt], row[MangaTable.chaptersLastFetchedAt], 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 3d16415e6..176892e6a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt @@ -51,6 +51,7 @@ interface Settings : Node { val basicAuthEnabled: Boolean? val basicAuthUsername: String? val basicAuthPassword: String? + val multiUser: Boolean? // misc val debugLogsEnabled: Boolean? @@ -95,6 +96,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?, override val systemTrayEnabled: Boolean?, @@ -136,6 +138,7 @@ class SettingsType( override val basicAuthEnabled: Boolean, override val basicAuthUsername: String, override val basicAuthPassword: String, + override val multiUser: Boolean?, // misc override val debugLogsEnabled: Boolean, override val systemTrayEnabled: Boolean, @@ -170,6 +173,7 @@ class SettingsType( config.basicAuthEnabled.value, config.basicAuthUsername.value, config.basicAuthPassword.value, + config.multiUser.value, config.debugLogsEnabled.value, config.systemTrayEnabled.value, config.backupPath.value, 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 b6858ef0d..83ee4e175 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/BackupController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/BackupController.kt @@ -5,7 +5,10 @@ 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.ProtoBackupValidator +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 @@ -27,9 +30,10 @@ object BackupController { } }, behaviorOf = { ctx -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( future { - ProtoBackupImport.performRestore(ctx.bodyAsInputStream()) + ProtoBackupImport.performRestore(userId, ctx.bodyAsInputStream()) }, ) }, @@ -53,9 +57,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.performRestore(ctx.uploadedFile("backup.proto.gz")!!.content) + ProtoBackupImport.performRestore(userId, ctx.uploadedFile("backup.proto.gz")!!.content) }, ) }, @@ -75,10 +80,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, @@ -105,12 +112,14 @@ object BackupController { } }, behaviorOf = { ctx -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.contentType("application/octet-stream") ctx.header("Content-Disposition", """attachment; filename="${ProtoBackupExport.getBackupFilename()}"""") ctx.future( future { ProtoBackupExport.createBackup( + userId, BackupFlags( includeManga = true, includeCategories = true, @@ -137,6 +146,7 @@ object BackupController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( future { ProtoBackupValidator.validate(ctx.bodyAsInputStream()) @@ -166,6 +176,7 @@ object BackupController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( future { ProtoBackupValidator.validate(ctx.uploadedFile("backup.proto.gz")!!.content) 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 d96a6f5bb..51d2ad745 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>(HttpCode.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(HttpCode.BAD_REQUEST) @@ -72,7 +77,8 @@ object CategoryController { } }, behaviorOf = { ctx, categoryId, name, isDefault, includeInUpdate -> - Category.updateCategory(categoryId, name, isDefault, includeInUpdate) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + Category.updateCategory(userId, categoryId, name, isDefault, includeInUpdate) ctx.status(200) }, withResults = { @@ -91,7 +97,8 @@ object CategoryController { } }, behaviorOf = { ctx, categoryId -> - Category.removeCategory(categoryId) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + Category.removeCategory(userId, categoryId) ctx.status(200) }, withResults = { @@ -110,7 +117,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>(HttpCode.OK) @@ -129,7 +137,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 = { @@ -150,7 +159,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 6dd26d366..964ed72cb 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/DownloadController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/DownloadController.kt @@ -16,7 +16,10 @@ import org.kodein.di.conf.global import org.kodein.di.instance 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 @@ -47,7 +50,8 @@ object DownloadController { description("Start the downloader") } }, - behaviorOf = { + behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.start() }, withResults = { @@ -65,6 +69,7 @@ object DownloadController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( future { DownloadManager.stop() }, ) @@ -84,6 +89,7 @@ object DownloadController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( future { DownloadManager.clear() }, ) @@ -105,6 +111,7 @@ object DownloadController { } }, behaviorOf = { ctx, chapterIndex, mangaId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( future { DownloadManager.enqueueWithChapterIndex(mangaId, chapterIndex) @@ -127,6 +134,7 @@ object DownloadController { body() }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val inputs = json.decodeFromString(ctx.body()) ctx.future( future { @@ -150,6 +158,7 @@ object DownloadController { body() }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val input = json.decodeFromString(ctx.body()) ctx.future( future { @@ -174,6 +183,7 @@ object DownloadController { } }, behaviorOf = { ctx, chapterIndex, mangaId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() DownloadManager.dequeue(chapterIndex, mangaId) ctx.status(200) @@ -195,7 +205,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 605f20740..489b8a67e 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 mu.KotlinLogging 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 @@ -30,6 +33,7 @@ object ExtensionController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( future { ExtensionsList.getExtensionList() @@ -52,6 +56,7 @@ object ExtensionController { } }, behaviorOf = { ctx, pkgName -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( future { Extension.installExtension(pkgName) @@ -79,6 +84,7 @@ object ExtensionController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val uploadedFile = ctx.uploadedFile("file")!! logger.debug { "Uploaded extension file name: " + uploadedFile.filename } @@ -106,6 +112,7 @@ object ExtensionController { } }, behaviorOf = { ctx, pkgName -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( future { Extension.updateExtension(pkgName) @@ -131,6 +138,7 @@ object ExtensionController { } }, behaviorOf = { ctx, pkgName -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() Extension.uninstallExtension(pkgName) ctx.status(200) }, @@ -153,6 +161,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 b9f1f594c..fe1ffddac 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/MangaController.kt @@ -21,7 +21,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 @@ -43,9 +46,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) }, ) }, @@ -67,9 +71,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) }, ) }, @@ -90,6 +95,7 @@ object MangaController { } }, behaviorOf = { ctx, mangaId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( future { Manga.getMangaThumbnail(mangaId) } .thenApply { @@ -117,8 +123,9 @@ object MangaController { } }, behaviorOf = { ctx, mangaId -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( - future { Library.addMangaToLibrary(mangaId) }, + future { Library.addMangaToLibrary(userId, mangaId) }, ) }, withResults = { @@ -138,8 +145,9 @@ object MangaController { } }, behaviorOf = { ctx, mangaId -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( - future { Library.removeMangaFromLibrary(mangaId) }, + future { Library.removeMangaFromLibrary(userId, mangaId) }, ) }, withResults = { @@ -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>(HttpCode.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,7 +256,8 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, onlineFetch -> - ctx.future(future { Chapter.getChapterList(mangaId, onlineFetch) }) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + ctx.future(future { Chapter.getChapterList(userId, mangaId, onlineFetch) }) }, withResults = { json>(HttpCode.OK) @@ -264,8 +277,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(HttpCode.OK) @@ -283,8 +297,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, @@ -309,7 +325,8 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, chapterIndex -> - ctx.future(future { getChapterDownloadReadyByIndex(chapterIndex, mangaId) }) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + ctx.future(future { getChapterDownloadReadyByIndex(userId, chapterIndex, mangaId) }) }, withResults = { json(HttpCode.OK) @@ -333,7 +350,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) }, @@ -354,6 +372,7 @@ object MangaController { } }, behaviorOf = { ctx, mangaId, chapterIndex -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() Chapter.deleteChapter(mangaId, chapterIndex) ctx.status(200) @@ -378,7 +397,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) }, @@ -403,6 +423,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 1bba233b7..c6a38d6a7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/SourceController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/SourceController.kt @@ -21,7 +21,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 @@ -38,6 +41,7 @@ object SourceController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.json(Source.getSourceList()) }, withResults = { @@ -56,6 +60,7 @@ object SourceController { } }, behaviorOf = { ctx, sourceId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.json(Source.getSource(sourceId)!!) }, withResults = { @@ -76,9 +81,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) }, ) }, @@ -99,9 +105,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) }, ) }, @@ -121,6 +128,7 @@ object SourceController { } }, behaviorOf = { ctx, sourceId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.json(Source.getSourcePreferences(sourceId)) }, withResults = { @@ -140,6 +148,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)) }, @@ -160,6 +169,7 @@ object SourceController { } }, behaviorOf = { ctx, sourceId, reset -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.json(Search.getFilterList(sourceId, reset)) }, withResults = { @@ -182,6 +192,7 @@ object SourceController { body>() }, behaviorOf = { ctx, sourceId -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val filterChange = try { json.decodeFromString>(ctx.body()) @@ -209,7 +220,8 @@ object SourceController { } }, behaviorOf = { ctx, sourceId, searchTerm, pageNum -> - ctx.future(future { Search.sourceSearch(sourceId, searchTerm, pageNum) }) + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() + ctx.future(future { Search.sourceSearch(userId, sourceId, searchTerm, pageNum) }) }, withResults = { json(HttpCode.OK) @@ -229,8 +241,9 @@ 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) }) + ctx.future(future { Search.sourceFilter(userId, sourceId, pageNum, filter) }) }, withResults = { json(HttpCode.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 7f762b09f..ce380ef07 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/UpdateController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/UpdateController.kt @@ -13,7 +13,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 @@ -40,9 +43,10 @@ object UpdateController { } }, behaviorOf = { ctx, pageNum -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() ctx.future( future { - Chapter.getRecentChapters(pageNum) + Chapter.getRecentChapters(userId, pageNum) }, ) }, @@ -67,16 +71,17 @@ object UpdateController { } }, behaviorOf = { ctx, categoryId -> + val userId = ctx.getAttribute(Attribute.TachideskUser).requireUser() val updater by DI.global.instance() 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), @@ -116,6 +121,7 @@ object UpdateController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val updater by DI.global.instance() ctx.json(updater.status.value) }, @@ -133,6 +139,7 @@ object UpdateController { } }, behaviorOf = { ctx -> + ctx.getAttribute(Attribute.TachideskUser).requireUser() val updater by DI.global.instance() 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 0e04a2d1e..c574ccae6 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Category.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Category.kt @@ -14,8 +14,8 @@ 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.select -import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass @@ -23,25 +23,31 @@ 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.select { CategoryTable.name eq name }.firstOrNull() == null) { + if (CategoryTable.select { 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,13 +57,14 @@ object Category { } fun updateCategory( + userId: Int, categoryId: Int, name: String?, isDefault: Boolean?, includeInUpdate: 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 && !name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true) @@ -74,37 +81,41 @@ object Category { * Move the category from order number `from` to `to` */ fun reorderCategory( + userId: Int, from: Int, to: Int, ) { if (from == 0 || to == 0) return transaction { val categories = - CategoryTable.select { - CategoryTable.id neq DEFAULT_CATEGORY_ID - }.orderBy(CategoryTable.order to SortOrder.ASC).toMutableList() + CategoryTable.select { 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() + CategoryTable.select { CategoryTable.user eq userId } .orderBy(CategoryTable.order to SortOrder.ASC) .sortedWith(compareBy({ it[CategoryTable.id].value != 0 }, { it[CategoryTable.order] })) .forEachIndexed { index, cat -> @@ -115,11 +126,11 @@ object Category { } } - private fun needsDefaultCategory() = + private fun needsDefaultCategory(userId: Int) = transaction { - MangaTable + MangaTable.getWithUserData(userId) .leftJoin(CategoryMangaTable) - .select { MangaTable.inLibrary eq true } + .select { MangaUserTable.inLibrary eq true and (CategoryMangaTable.user eq userId) } .andWhere { CategoryMangaTable.manga.isNull() } .empty() .not() @@ -128,12 +139,12 @@ object Category { const val DEFAULT_CATEGORY_ID = 0 const val DEFAULT_CATEGORY_NAME = "Default" - fun getCategoryList(): List { + fun getCategoryList(userId: Int): List { return transaction { - CategoryTable.selectAll() + CategoryTable.select { 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 } @@ -145,36 +156,48 @@ object Category { } } - fun getCategoryById(categoryId: Int): CategoryDataClass? { + fun getCategoryById( + userId: Int, + categoryId: Int, + ): CategoryDataClass? { return transaction { - CategoryTable.select { CategoryTable.id eq categoryId }.firstOrNull()?.let { + CategoryTable.select { 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 { return transaction { if (categoryId == DEFAULT_CATEGORY_ID) { - MangaTable + MangaTable.getWithUserData(userId) .leftJoin(CategoryMangaTable) - .select { MangaTable.inLibrary eq true } + .select { MangaUserTable.inLibrary eq true and (CategoryMangaTable.user eq userId) } .andWhere { CategoryMangaTable.manga.isNull() } } else { - CategoryMangaTable.leftJoin(MangaTable).select { CategoryMangaTable.category eq categoryId } - .andWhere { MangaTable.inLibrary eq true } + CategoryMangaTable + .leftJoin(MangaTable.getWithUserData(userId)) + .select { 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 { return transaction { - CategoryMetaTable.select { CategoryMetaTable.ref eq categoryId } + CategoryMetaTable.select { 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, @@ -182,7 +205,11 @@ object Category { transaction { val meta = transaction { - CategoryMetaTable.select { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) } + CategoryMetaTable.select { + (CategoryMetaTable.ref eq categoryId) and + (CategoryMetaTable.user eq userId) and + (CategoryMetaTable.key eq key) + } }.firstOrNull() if (meta == null) { @@ -190,9 +217,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 1c8b865b4..e5e6a59a3 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.select 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, ) { @@ -39,7 +45,9 @@ object CategoryManga { fun notAlreadyInCategory() = CategoryMangaTable.select { - (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 { @@ -47,31 +55,42 @@ 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.slice( - ChapterTable.id.count(), - ).select((ChapterTable.isRead eq false) and (ChapterTable.manga eq MangaTable.id)), + ChapterTable.getWithUserData(userId) + .slice(ChapterTable.id.count()) + .select( + (ChapterUserTable.isRead eq false or (ChapterUserTable.isRead.isNull())) and (ChapterTable.manga eq MangaTable.id), + ), ) val downloadedCount = wrapAsExpression( @@ -81,12 +100,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] @@ -98,17 +117,21 @@ object CategoryManga { // Fetch data from the MangaTable and join with the CategoryMangaTable, if a category is specified val query = if (categoryId == DEFAULT_CATEGORY_ID) { - MangaTable - .leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga }) + MangaTable.getWithUserData(userId) + .leftJoin(ChapterTable.getWithUserData(userId), { MangaTable.id }, { ChapterTable.manga }) .leftJoin(CategoryMangaTable) .slice(columns = selectedColumns) - .select { (MangaTable.inLibrary eq true) and CategoryMangaTable.category.isNull() } + .select { + (MangaUserTable.inLibrary eq true) and + (CategoryMangaTable.user eq userId) and + CategoryMangaTable.category.isNull() + } } else { - MangaTable + MangaTable.getWithUserData(userId) .innerJoin(CategoryMangaTable) - .leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga }) + .leftJoin(ChapterTable.getWithUserData(userId), { MangaTable.id }, { ChapterTable.manga }) .slice(columns = selectedColumns) - .select { (MangaTable.inLibrary eq true) and (CategoryMangaTable.category eq categoryId) } + .select { (MangaUserTable.inLibrary eq true) and (CategoryMangaTable.category eq categoryId) } } // Join with the ChapterTable to fetch the last read chapter for each manga @@ -119,21 +142,32 @@ object CategoryManga { /** * list of categories that a manga belongs to */ - fun getMangaCategories(mangaId: Int): List { + fun getMangaCategories( + userId: Int, + mangaId: Int, + ): List { return transaction { - CategoryMangaTable.innerJoin(CategoryTable).select { - CategoryMangaTable.manga eq mangaId - }.orderBy(CategoryTable.order to SortOrder.ASC).map { - CategoryTable.toDataClass(it) - } + CategoryMangaTable.innerJoin(CategoryTable) + .select { 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> { return buildMap { transaction { CategoryMangaTable.innerJoin(CategoryTable) - .select { CategoryMangaTable.manga inList mangaIDs } + .select { + (CategoryTable.user eq userId) and + (CategoryMangaTable.user eq userId) and + (CategoryMangaTable.manga inList mangaIDs) + } .groupBy { it[CategoryMangaTable.manga] } .forEach { val mangaId = it.key.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 fdc8f8cce..b581abf9f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt @@ -20,14 +20,15 @@ import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder.ASC 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.insert import org.jetbrains.exposed.sql.select 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.download.DownloadManager import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput +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 @@ -35,8 +36,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 @@ -46,20 +50,22 @@ object Chapter { /** get chapter list when showing a manga */ suspend fun getChapterList( + userId: Int, mangaId: Int, onlineFetch: Boolean = false, ): List { return if (onlineFetch) { - getSourceChapters(mangaId) + getSourceChapters(userId, mangaId) } else { transaction { - ChapterTable.select { ChapterTable.manga eq mangaId } + ChapterTable.getWithUserData(userId) + .select { ChapterTable.manga eq mangaId } .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) .map { - ChapterTable.toDataClass(it) + ChapterTable.toDataClass(userId, it) } }.ifEmpty { - getSourceChapters(mangaId) + getSourceChapters(userId, mangaId) } } } @@ -68,17 +74,20 @@ object Chapter { return transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.count().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.select { ChapterTable.manga eq mangaId } + ChapterTable.getWithUserData(userId).select { 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 -> @@ -92,10 +101,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], @@ -107,14 +116,17 @@ object Chapter { } } - suspend fun fetchChapterList(mangaId: Int): List { - val manga = getManga(mangaId) - val source = getCatalogueSourceOrStub(manga.sourceId.toLong()) + suspend fun fetchChapterList( + userId: Int, + mangaId: Int, + ): List { + val manga = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } + val source = getCatalogueSourceOrStub(manga[MangaTable.sourceReference]) val sManga = SManga.create().apply { - title = manga.title - url = manga.url + title = manga[MangaTable.title] + url = manga[MangaTable.url] } val numberOfCurrentChapters = getCountOfMangaChapters(mangaId) @@ -123,7 +135,12 @@ object Chapter { // Recognize number for new chapters. chapterList.forEach { chapter -> (source as? HttpSource)?.prepareNewChapter(chapter, sManga) - val chapterNumber = ChapterRecognition.parseChapterNumber(manga.title, chapter.name, chapter.chapter_number.toDouble()) + val chapterNumber = + ChapterRecognition.parseChapterNumber( + manga[MangaTable.title], + chapter.name, + chapter.chapter_number.toDouble(), + ) chapter.chapter_number = chapterNumber.toFloat() } @@ -174,7 +191,7 @@ object Chapter { val newChapters = transaction { - ChapterTable.select { ChapterTable.manga eq mangaId } + ChapterTable.getWithUserData(userId).select { ChapterTable.manga eq mangaId } .orderBy(ChapterTable.sourceOrder to SortOrder.DESC).toList() } @@ -201,13 +218,19 @@ object Chapter { } } - if (manga.inLibrary) { + val isInALibrary = + transaction { + MangaUserTable.select { MangaUserTable.manga eq mangaId and (MangaUserTable.inLibrary eq true) }.isNotEmpty() + } + + if (isInALibrary) { downloadNewChapters(mangaId, numberOfCurrentChapters, newChapters) } return chapterList } + // todo user accounts private fun downloadNewChapters( mangaId: Int, prevNumberOfChapters: Int, @@ -232,10 +255,10 @@ object Chapter { // make sure to only consider the latest chapters. e.g. old unread chapters should be ignored val latestReadChapterIndex = - updatedChapterList.indexOfFirst { it[ChapterTable.isRead] }.takeIf { it > -1 } ?: return + updatedChapterList.indexOfFirst { it.getOrNull(ChapterUserTable.isRead) == true }.takeIf { it > -1 } ?: return val unreadChapters = updatedChapterList.subList(numberOfNewChapters, latestReadChapterIndex) - .filter { !it[ChapterTable.isRead] } + .filter { it.getOrNull(ChapterUserTable.isRead) != true } val skipDueToUnreadChapters = serverConfig.excludeEntryWithUnreadChapters.value && unreadChapters.isNotEmpty() if (skipDueToUnreadChapters) { @@ -247,7 +270,7 @@ object Chapter { val chapterIdsToDownload = newChapters.subList(firstChapterToDownloadIndex, numberOfNewChapters) - .filter { !it[ChapterTable.isRead] && !it[ChapterTable.isDownloaded] } + .filter { it.getOrNull(ChapterUserTable.isRead) != true && !it[ChapterTable.isDownloaded] } .map { it[ChapterTable.id].value } if (chapterIdsToDownload.isEmpty()) { @@ -260,6 +283,7 @@ object Chapter { } fun modifyChapter( + userId: Int, mangaId: Int, chapterIndex: Int, isRead: Boolean?, @@ -269,23 +293,61 @@ 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.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }.first() + val userDataExists = + ChapterUserTable.select { + 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.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder less chapterIndex) } + .map { it[ChapterTable.id].value } + val existingUserData = + ChapterUserTable.select { + 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 } } } @@ -313,6 +375,7 @@ object Chapter { ) fun modifyChapters( + userId: Int, input: MangaChapterBatchEditInput, mangaId: Int? = null, ) { @@ -352,38 +415,66 @@ object Chapter { transaction { val now = Instant.now().epochSecond - ChapterTable.update({ condition }) { update -> + val chapters = ChapterTable.select { condition }.map { it[ChapterTable.id].value } + val existingUserData = + ChapterUserTable.select { + 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 } } } } - fun getChaptersMetaMaps(chapterIds: List>): Map, Map> { + fun getChaptersMetaMaps( + userId: Int, + chapterIds: List>, + ): Map, Map> { return transaction { - ChapterMetaTable.select { ChapterMetaTable.ref inList chapterIds } + ChapterMetaTable.select { 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 { return transaction { - ChapterMetaTable.select { ChapterMetaTable.ref eq chapter } + ChapterMetaTable.select { 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, @@ -391,30 +482,44 @@ object Chapter { ) { transaction { val chapterId = - ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) } - .first()[ChapterTable.id].value - modifyChapterMeta(chapterId, key, value) + ChapterTable.select { + ChapterMetaTable.user eq userId and + (ChapterTable.manga eq mangaId) and + (ChapterTable.sourceOrder eq chapterIndex) + }.first()[ChapterTable.id].value + modifyChapterMeta(userId, chapterId, key, value) } } fun modifyChapterMeta( + userId: Int, chapterId: Int, key: String, value: String, ) { transaction { val meta = - ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } - .firstOrNull() + ChapterMetaTable.select { + ChapterMetaTable.user eq userId and + (ChapterMetaTable.ref eq chapterId) and + (ChapterMetaTable.key eq key) + }.firstOrNull() if (meta == null) { ChapterMetaTable.insert { 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 } } @@ -479,16 +584,19 @@ object Chapter { } } - fun getRecentChapters(pageNum: Int): PaginatedList { + fun getRecentChapters( + userId: Int, + pageNum: Int, + ): PaginatedList { return paginatedFrom(pageNum) { transaction { - (ChapterTable innerJoin MangaTable) - .select { (MangaTable.inLibrary eq true) and (ChapterTable.fetchedAt greater MangaTable.inLibraryAt) } + (ChapterTable.getWithUserData(userId) innerJoin MangaTable.getWithUserData(userId)) + .select { (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 f5199bb67..5acc78f05 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Library.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Library.kt @@ -17,27 +17,46 @@ import org.jetbrains.exposed.sql.select 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.select { - (CategoryTable.isDefault eq true) and (CategoryTable.id neq Category.DEFAULT_CATEGORY_ID) + MangaUserTable.user eq userId and + (CategoryTable.isDefault eq true) and + (CategoryTable.id neq Category.DEFAULT_CATEGORY_ID) + }.toList() + val existingCategories = + CategoryMangaTable.select { + MangaUserTable.user eq userId and (CategoryMangaTable.manga eq mangaId) }.toList() - val existingCategories = CategoryMangaTable.select { CategoryMangaTable.manga eq mangaId }.toList() - MangaTable.update({ MangaTable.id eq manga.id }) { - it[inLibrary] = true - it[inLibraryAt] = Instant.now().epochSecond + if (MangaUserTable.select { 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()) { @@ -45,35 +64,42 @@ 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.select { + 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 9e4f5d910..f98b1c91e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt @@ -29,6 +29,7 @@ import suwayomi.tachidesk.manga.impl.Source.getSource import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.MissingThumbnailException +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 @@ -40,9 +41,12 @@ import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir 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 suwayomi.tachidesk.server.serverConfig @@ -70,17 +74,18 @@ object Manga { } suspend fun getManga( + userId: Int, mangaId: Int, onlineFetch: Boolean = false, ): MangaDataClass { - var mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } + var mangaEntry = transaction { MangaTable.getWithUserData(userId).select { 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) + val sManga = fetchManga(mangaId) ?: return getMangaDataClass(userId, mangaId, mangaEntry) - mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } + mangaEntry = transaction { MangaTable.getWithUserData(userId).select { MangaTable.id eq mangaId }.first() } MangaDataClass( id = mangaId, @@ -95,10 +100,10 @@ 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], @@ -159,15 +164,17 @@ 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 - .select { (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq false) } + .getWithUserData(userId) + .select { (ChapterTable.manga eq mangaId) and (ChapterUserTable.isRead eq false) } .count() val downloadCount = @@ -181,21 +188,22 @@ object Manga { .count() val lastChapterRead = - ChapterTable + ChapterTable.getWithUserData(userId) .select { (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( @@ -211,10 +219,10 @@ 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], @@ -222,31 +230,45 @@ object Manga { freshData = false, ) - fun getMangaMetaMap(mangaId: Int): Map { + fun getMangaMetaMap( + userId: Int, + mangaId: Int, + ): Map { return transaction { - MangaMetaTable.select { MangaMetaTable.ref eq mangaId } + MangaMetaTable.select { 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, ) { transaction { val meta = - MangaMetaTable.select { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) } - .firstOrNull() + MangaMetaTable.select { + MangaMetaTable.user eq userId and + (MangaMetaTable.ref eq mangaId) and + (MangaMetaTable.key eq key) + }.firstOrNull() if (meta == null) { MangaMetaTable.insert { 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 } } @@ -270,7 +292,8 @@ object Manga { mangaEntry[MangaTable.thumbnail_url] ?: if (!mangaEntry[MangaTable.initialized]) { // initialize then try again - getManga(mangaId) + // no need for a user id since we are just fetching if required + getManga(1, mangaId) transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }[MangaTable.thumbnail_url]!! @@ -315,9 +338,14 @@ object Manga { } suspend fun getMangaThumbnail(mangaId: Int): Pair { - val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } + val mangaInLibrary = + transaction { + MangaUserTable.select { + MangaUserTable.manga eq mangaId and (MangaUserTable.inLibrary eq true) + }.isNotEmpty() + } - if (mangaEntry[MangaTable.inLibrary]) { + if (mangaInLibrary) { return try { ThumbnailDownloadHelper.getImage(mangaId) } catch (_: MissingThumbnailException) { @@ -343,6 +371,7 @@ object Manga { private const val CHAPTERS_KEY = "chapterIds" fun downloadAhead( + userId: Int, mangaIds: List, latestReadChapterIds: List = emptyList(), ) { @@ -368,6 +397,7 @@ object Manga { object : TimerTask() { override fun run() { downloadAheadChapters( + userId, downloadAheadQueue[MANGAS_KEY]?.toList().orEmpty(), downloadAheadQueue[CHAPTERS_KEY]?.toList().orEmpty(), ) @@ -398,18 +428,23 @@ object Manga { * will download the unread chapters starting from chapter 15 */ private fun downloadAheadChapters( + userId: Int, mangaIds: List, latestReadChapterIds: List, ) { val mangaToLatestReadChapterIndex = transaction { - ChapterTable.select { (ChapterTable.manga inList mangaIds) and (ChapterTable.isRead eq true) } - .orderBy(ChapterTable.sourceOrder to SortOrder.DESC).groupBy { it[ChapterTable.manga].value } + ChapterTable.getWithUserData(userId).select { + (ChapterTable.manga inList mangaIds) and (ChapterUserTable.isRead eq true) + } + .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) + .groupBy { it[ChapterTable.manga].value } }.mapValues { (_, chapters) -> chapters.firstOrNull()?.let { it[ChapterTable.sourceOrder] } ?: 0 } val mangaToUnreadChaptersMap = transaction { - ChapterTable.select { (ChapterTable.manga inList mangaIds) and (ChapterTable.isRead eq false) } + ChapterTable.getWithUserData(userId) + .select { (ChapterTable.manga inList mangaIds) and (ChapterUserTable.isRead eq false) } .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) .groupBy { it[ChapterTable.manga].value } } 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 1dc780d65..5f64746cb 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/MangaList.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/MangaList.kt @@ -20,6 +20,8 @@ import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass 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 suwayomi.tachidesk.manga.model.table.getWithUserData object MangaList { fun proxyThumbnailUrl(mangaId: Int): String { @@ -27,6 +29,7 @@ object MangaList { } suspend fun getMangaList( + userId: Int, sourceId: Long, pageNum: Int = 1, popular: Boolean, @@ -45,7 +48,7 @@ object MangaList { throw Exception("Source $source doesn't support latest") } } - return mangasPage.processEntries(sourceId) + return mangasPage.processEntries(userId, sourceId) } fun MangasPage.insertOrGet(sourceId: Long): List { @@ -77,13 +80,16 @@ object MangaList { } } - fun MangasPage.processEntries(sourceId: Long): PagedMangaListDataClass { + fun MangasPage.processEntries( + userId: Int, + sourceId: Long, + ): PagedMangaListDataClass { val mangasPage = this val mangaList = transaction { return@transaction mangasPage.mangas.map { manga -> var mangaEntry = - MangaTable.select { + MangaTable.getWithUserData(userId).select { (MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId) }.firstOrNull() if (mangaEntry == null) { // create manga entry @@ -123,7 +129,7 @@ object MangaList { status = MangaStatus.valueOf(manga.status).name, inLibrary = false, // It's a new manga entry inLibraryAt = 0, - meta = getMangaMetaMap(mangaId), + meta = getMangaMetaMap(userId, mangaId), realUrl = mangaEntry[MangaTable.realUrl], lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt], chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt], @@ -145,9 +151,9 @@ object MangaList { description = mangaEntry[MangaTable.description], genre = mangaEntry[MangaTable.genre].toGenreList(), status = MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, - inLibrary = mangaEntry[MangaTable.inLibrary], - inLibraryAt = mangaEntry[MangaTable.inLibraryAt], - meta = getMangaMetaMap(mangaId), + inLibrary = mangaEntry.getOrNull(MangaUserTable.inLibrary) ?: false, + inLibraryAt = mangaEntry.getOrNull(MangaUserTable.inLibraryAt) ?: 0, + meta = getMangaMetaMap(userId, mangaId), realUrl = mangaEntry[MangaTable.realUrl], lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt], chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt], 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 e2c07cdf7..a82aed4ac 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Search.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Search.kt @@ -21,16 +21,18 @@ import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass 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, @@ -38,7 +40,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/backup/models/ChapterImpl.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/ChapterImpl.kt index c218b2c18..82f743ea1 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/ChapterImpl.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/models/ChapterImpl.kt @@ -2,9 +2,6 @@ package suwayomi.tachidesk.manga.impl.backup.models -import org.jetbrains.exposed.sql.ResultRow -import suwayomi.tachidesk.manga.model.table.ChapterTable - class ChapterImpl : Chapter { override var id: Long? = null @@ -42,17 +39,4 @@ class ChapterImpl : Chapter { override fun hashCode(): Int { return url.hashCode() + id.hashCode() } - - // Tachidesk --> - companion object { - fun fromQuery(chapterRecord: ResultRow): ChapterImpl { - return ChapterImpl().apply { - url = chapterRecord[ChapterTable.url] - read = chapterRecord[ChapterTable.isRead] - bookmark = chapterRecord[ChapterTable.isBookmarked] - last_page_read = chapterRecord[ChapterTable.lastPageRead] - } - } - } - // Tachidesk <-- } 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 14a7d9569..bdc9bc5a3 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 @@ -16,7 +16,6 @@ import okio.sink import org.jetbrains.exposed.sql.Query import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.select -import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.kodein.di.DI import org.kodein.di.conf.global @@ -33,7 +32,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 @@ -100,6 +101,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, @@ -157,16 +159,19 @@ object ProtoBackupExport : ProtoBackupBase() { return "tachidesk_$currentDate.proto.gz" } - fun createBackup(flags: BackupFlags): InputStream { + fun createBackup( + userId: Int, + flags: BackupFlags, + ): InputStream { // Create root object - val databaseManga = transaction { MangaTable.select { MangaTable.inLibrary eq true } } + val databaseManga = transaction { MangaTable.getWithUserData(userId).select { MangaUserTable.inLibrary eq true } } val backup: Backup = transaction { Backup( - backupManga(databaseManga, flags), - backupCategories(), + backupManga(userId, databaseManga, flags), + backupCategories(userId), emptyList(), backupExtensionInfo(databaseManga), ) @@ -181,6 +186,7 @@ object ProtoBackupExport : ProtoBackupBase() { } private fun backupManga( + userId: Int, databaseManga: Query, flags: BackupFlags, ): List { @@ -196,7 +202,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]), ) @@ -206,10 +212,10 @@ object ProtoBackupExport : ProtoBackupBase() { if (flags.includeChapters) { val chapters = transaction { - ChapterTable.select { ChapterTable.manga eq mangaId } + ChapterTable.getWithUserData(userId).select { ChapterTable.manga eq mangaId } .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) .map { - ChapterTable.toDataClass(it) + ChapterTable.toDataClass(userId, it) } } @@ -231,7 +237,7 @@ 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) { @@ -246,8 +252,8 @@ object ProtoBackupExport : ProtoBackupBase() { } } - private fun backupCategories(): List { - return CategoryTable.selectAll().orderBy(CategoryTable.order to SortOrder.ASC).map { + private fun backupCategories(userId: Int): List { + return CategoryTable.select { CategoryTable.user eq userId }.orderBy(CategoryTable.order to SortOrder.ASC).map { CategoryTable.toDataClass(it) }.map { BackupCategory( 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 6b7f257bf..ea7b85fd3 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 @@ -33,7 +33,9 @@ import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer 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.lang.Integer.max import java.util.Date @@ -58,7 +60,10 @@ object ProtoBackupImport : ProtoBackupBase() { val backupRestoreState = MutableStateFlow(BackupRestoreState.Idle) - suspend fun performRestore(sourceStream: InputStream): ValidationResult { + suspend fun performRestore( + userId: Int, + sourceStream: InputStream, + ): ValidationResult { return backupMutex.withLock { val backupString = sourceStream.source().gzip().buffer().use { it.readByteArray() } val backup = parser.decodeFromByteArray(BackupSerializer, backupString) @@ -70,13 +75,15 @@ object ProtoBackupImport : ProtoBackupBase() { backupRestoreState.value = BackupRestoreState.RestoringCategories(backup.backupManga.size) // Restore categories if (backup.backupCategories.isNotEmpty()) { - restoreCategories(backup.backupCategories) + restoreCategories(userId, backup.backupCategories) } val categoryMapping = transaction { backup.backupCategories.associate { - it.order to CategoryTable.select { CategoryTable.name eq it.name }.first()[CategoryTable.id].value + it.order to + CategoryTable.select { CategoryTable.user eq userId and (CategoryTable.name eq it.name) } + .first()[CategoryTable.id].value } } @@ -92,6 +99,7 @@ object ProtoBackupImport : ProtoBackupBase() { title = manga.title, ) restoreManga( + userId = userId, backupManga = manga, backupCategories = backup.backupCategories, categoryMapping = categoryMapping, @@ -117,18 +125,22 @@ object ProtoBackupImport : ProtoBackupBase() { } } - 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, @@ -140,7 +152,7 @@ object ProtoBackupImport : ProtoBackupBase() { val tracks = backupManga.getTrackingImpl() try { - restoreMangaData(manga, chapters, categories, history, tracks, backupCategories, categoryMapping) + restoreMangaData(userId, manga, chapters, categories, history, tracks, backupCategories, categoryMapping) } catch (e: Exception) { val sourceName = sourceMapping[manga.source] ?: manga.source.toString() errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}") @@ -149,6 +161,7 @@ object ProtoBackupImport : ProtoBackupBase() { @Suppress("UNUSED_PARAMETER") // TODO: remove private fun restoreMangaData( + userId: Int, manga: Manga, chapters: List, categories: List, @@ -182,36 +195,44 @@ 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) + } + // insert chapter data val chaptersLength = chapters.size chapters.forEach { chapter -> - ChapterTable.insert { - it[url] = chapter.url - it[name] = chapter.name - it[date_upload] = chapter.date_upload - it[chapter_number] = chapter.chapter_number - it[scanlator] = chapter.scanlator + val chapterId = + ChapterTable.insertAndGetId { + it[url] = chapter.url + it[name] = chapter.name + 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[sourceOrder] = chaptersLength - chapter.source_order + it[ChapterTable.manga] = mangaId - it[isRead] = chapter.read - it[lastPageRead] = chapter.last_page_read - it[isBookmarked] = chapter.bookmark + it[fetchedAt] = TimeUnit.MILLISECONDS.toSeconds(chapter.date_fetch) + }.value - it[fetchedAt] = TimeUnit.MILLISECONDS.toSeconds(chapter.date_fetch) + ChapterUserTable.insert { + it[ChapterUserTable.chapter] = chapterId + it[ChapterUserTable.user] = userId + it[ChapterUserTable.isRead] = chapter.read + it[ChapterUserTable.lastPageRead] = chapter.last_page_read + it[ChapterUserTable.isBookmarked] = chapter.bookmark } } // insert categories categories.forEach { backupCategoryOrder -> - CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!) + CategoryManga.addMangaToCategory(userId, mangaId, categoryMapping[backupCategoryOrder]!!) } } } else { // Manga in database @@ -229,10 +250,24 @@ 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.select { + 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 @@ -243,32 +278,52 @@ 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 - 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[isRead] = chapter.read - it[lastPageRead] = chapter.last_page_read - it[isBookmarked] = chapter.bookmark + val chapterId = + ChapterTable.insertAndGetId { + it[url] = chapter.url + it[name] = chapter.name + 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 + }.value + + 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.select { + 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]!!) } } } 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 a05771dbf..c4e190cc6 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 @@ -24,33 +24,41 @@ import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogue import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse 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 import java.io.File 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 { - return getChapterDownloadReady(chapterId = chapterId) +suspend fun getChapterDownloadReadyById( + userId: Int, + chapterId: Int, +): ChapterDataClass { + return getChapterDownloadReady(userId = userId, chapterId = chapterId) } suspend fun getChapterDownloadReadyByIndex( + userId: Int, chapterIndex: Int, mangaId: Int, ): ChapterDataClass { - return getChapterDownloadReady(chapterIndex = chapterIndex, mangaId = mangaId) + return getChapterDownloadReady(userId = userId, chapterIndex = chapterIndex, mangaId = mangaId) } private class ChapterForDownload( + private val userId: Int, optChapterId: Int? = null, optChapterIndex: Int? = null, optMangaId: Int? = null, @@ -67,7 +75,7 @@ private class ChapterForDownload( return asDataClass() } - private fun asDataClass() = ChapterTable.toDataClass(chapterEntry) + private fun asDataClass() = ChapterTable.toDataClass(userId, chapterEntry) // no need for user id var chapterEntry: ResultRow val chapterId: Int @@ -86,7 +94,7 @@ private class ChapterForDownload( optChapterIndex: Int? = null, optMangaId: Int? = null, ) = transaction { - ChapterTable.select { + ChapterTable.getWithUserData(userId).select { if (optChapterId != null) { ChapterTable.id eq optChapterId } else if (optChapterIndex != null && optMangaId != null) { @@ -143,7 +151,14 @@ 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.select { + 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 205d0c297..2b4f9adab 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 @@ -265,20 +265,22 @@ 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 b3abde53f..a2bb1dbc0 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 @@ -123,7 +123,7 @@ class Downloader( download.state = Downloading step(download, true) - download.chapter = getChapterDownloadReadyByIndex(download.chapterIndex, download.mangaId) + download.chapter = getChapterDownloadReadyByIndex(0, download.chapterIndex, download.mangaId) // no need for user id here step(download, false) ChapterDownloadHelper.download(download.mangaId, download.chapter.id, download, scope, this::step) 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 7cd820fd8..f73ded669 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 @@ -92,7 +92,8 @@ class Updater : IUpdater { lastAutomatedUpdate, )})" } - addCategoriesToUpdateQueue(Category.getCategoryList(), clear = true, forceAll = false) + // todo User accounts + addCategoriesToUpdateQueue(Category.getCategoryList(1), clear = true, forceAll = false) } fun scheduleUpdateTask() { @@ -163,7 +164,7 @@ class Updater : IUpdater { tracker[job.manga.id] = try { logger.info { "Updating \"${job.manga.title}\" (source: ${job.manga.sourceId})" } - Chapter.getChapterList(job.manga.id, true) + Chapter.getChapterList(0, job.manga.id, true) job.copy(status = JobStatus.COMPLETE) } catch (e: Exception) { if (e is CancellationException) throw e @@ -205,9 +206,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 5cf445ab7..909980ff1 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 ba45aaf85..a69c42a6e 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 6ab21fe01..314670497 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.IncludeInUpdate @@ -18,6 +20,7 @@ object CategoryTable : IntIdTable() { val order = integer("order").default(0) val isDefault = bool("is_default").default(false) val includeInUpdate = integer("include_in_update").default(IncludeInUpdate.UNSET.value) + val user = reference("user", UserTable, ReferenceOption.CASCADE) } fun CategoryTable.toDataClass(categoryEntry: ResultRow) = @@ -26,7 +29,7 @@ fun CategoryTable.toDataClass(categoryEntry: ResultRow) = categoryEntry[order], categoryEntry[name], categoryEntry[isDefault], - Category.getCategorySize(categoryEntry[id].value), + Category.getCategorySize(categoryEntry[user].value, categoryEntry[id].value), IncludeInUpdate.fromValue(categoryEntry[includeInUpdate]), - 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 6584679fe..559941c1c 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 f3abc528b..57c5bc64e 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,24 +37,26 @@ 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.select { 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.select { 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 000000000..a7c69cfa6 --- /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 b4a7098a8..92c6ed6f8 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 17d89c889..1dfbdb53c 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) { UNKNOWN(0), 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 000000000..11706b645 --- /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/server/JavalinSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt index 0d0b8c0a4..52267e036 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt @@ -10,10 +10,14 @@ package suwayomi.tachidesk.server import io.javalin.Javalin import io.javalin.apibuilder.ApiBuilder.path import io.javalin.core.security.RouteRole +import io.javalin.core.util.Header +import io.javalin.http.Context +import io.javalin.http.HttpCode import io.javalin.http.staticfiles.Location import io.javalin.plugin.openapi.OpenApiOptions import io.javalin.plugin.openapi.OpenApiPlugin import io.javalin.plugin.openapi.ui.SwaggerOptions +import io.javalin.websocket.WsContext import io.swagger.v3.oas.models.info.Info import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -28,8 +32,13 @@ import org.kodein.di.DI import org.kodein.di.conf.global import org.kodein.di.instance 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 java.io.IOException @@ -92,7 +101,24 @@ object JavalinSetup { return username == serverConfig.basicAuthUsername.value && password == serverConfig.basicAuthPassword.value } - if (serverConfig.basicAuthEnabled.value && !(ctx.basicAuthCredentialsExist() && 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 && + !(ctx.basicAuthCredentialsExist() && credentialsValid()) + ) { ctx.header("WWW-Authenticate", "Basic") ctx.status(401).json("Unauthorized") } else { @@ -107,6 +133,28 @@ object JavalinSetup { } }.start() + 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) { @@ -134,6 +182,17 @@ object JavalinSetup { ctx.result(e.message ?: "Bad Request") } + app.exception(UnauthorizedException::class.java) { e, ctx -> + logger.info("UnauthorizedException while handling the request", e) + ctx.status(HttpCode.UNAUTHORIZED) + ctx.result(e.message ?: "Unauthorized") + } + app.exception(ForbiddenException::class.java) { e, ctx -> + logger.info("ForbiddenException while handling the request", e) + ctx.status(HttpCode.FORBIDDEN) + ctx.result(e.message ?: "Forbidden") + } + app.routes { path("api/") { path("v1/") { @@ -164,4 +223,30 @@ object JavalinSetup { object Auth { enum class Role : RouteRole { ANYONE, USER_READ, USER_WRITE } } + + 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 { + return attribute(attribute.name)!! + } + + fun WsContext.getAttribute(attribute: Attribute): T { + return 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 407f7aae1..40a3df84a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt @@ -96,6 +96,7 @@ class ServerConfig(getConfig: () -> Config, val moduleName: String = SERVER_CONF 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/database/migration/M0030_AddUsers.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0030_AddUsers.kt new file mode 100644 index 000000000..02fceafb8 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0030_AddUsers.kt @@ -0,0 +1,144 @@ +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 M0030_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 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; + + -- 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 CATEGORY + 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; + + -- 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 000000000..a6204a728 --- /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 000000000..a318096f0 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/user/UserType.kt @@ -0,0 +1,37 @@ +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 { + return 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 46cfb0e9f..7b9753c12 100644 --- a/server/src/main/resources/server-reference.conf +++ b/server/src/main/resources/server-reference.conf @@ -32,11 +32,14 @@ server.excludeNotStarted = true 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 -# Authentication +# authentication server.basicAuthEnabled = false server.basicAuthUsername = "" server.basicAuthPassword = "" +# user +server.multiUser = false # Will ignore basic auth if enabled + # misc server.debugLogsEnabled = false server.gqlDebugLogsEnabled = false # this includes logs with non privacy safe information 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 589e52d96..3a95b626e 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/controller/UpdateControllerTest.kt b/server/src/test/kotlin/suwayomi/tachidesk/manga/controller/UpdateControllerTest.kt index cd150b6a6..1383b0568 100644 --- a/server/src/test/kotlin/suwayomi/tachidesk/manga/controller/UpdateControllerTest.kt +++ b/server/src/test/kotlin/suwayomi/tachidesk/manga/controller/UpdateControllerTest.kt @@ -29,7 +29,7 @@ internal class UpdateControllerTest : ApplicationTest() { @Test fun `POST non existent Category Id should give error`() { every { ctx.formParam("category") } returns "1" - UpdateController.categoryUpdate(ctx) + // UpdateController.categoryUpdate(ctx) verify { ctx.status(HttpCode.BAD_REQUEST) } val updater by DI.global.instance() assertEquals(0, updater.status.value.numberOfJobs) @@ -37,11 +37,11 @@ internal class UpdateControllerTest : ApplicationTest() { @Test fun `POST existent Category Id should give success`() { - Category.createCategory("foo") + Category.createCategory(1, "foo") createLibraryManga("bar") - CategoryManga.addMangaToCategory(1, 1) + CategoryManga.addMangaToCategory(1, 1, 1) every { ctx.formParam("category") } returns "1" - UpdateController.categoryUpdate(ctx) + // UpdateController.categoryUpdate(ctx) verify { ctx.status(HttpCode.OK) } val updater by DI.global.instance() assertEquals(1, updater.status.value.numberOfJobs) @@ -49,15 +49,15 @@ internal class UpdateControllerTest : ApplicationTest() { @Test fun `POST null or empty category should update library`() { - val fooCatId = Category.createCategory("foo") + val fooCatId = Category.createCategory(1, "foo") val fooMangaId = createLibraryManga("foo") - CategoryManga.addMangaToCategory(fooMangaId, fooCatId) - val barCatId = Category.createCategory("bar") + CategoryManga.addMangaToCategory(1, fooMangaId, fooCatId) + val barCatId = Category.createCategory(1, "bar") val barMangaId = createLibraryManga("bar") - CategoryManga.addMangaToCategory(barMangaId, barCatId) + CategoryManga.addMangaToCategory(1, barMangaId, barCatId) createLibraryManga("mangaInDefault") every { ctx.formParam("category") } returns null - UpdateController.categoryUpdate(ctx) + // UpdateController.categoryUpdate(ctx) verify { ctx.status(HttpCode.OK) } val updater by DI.global.instance() assertEquals(3, updater.status.value.numberOfJobs) @@ -69,7 +69,6 @@ internal class UpdateControllerTest : ApplicationTest() { it[title] = _title it[url] = _title it[sourceReference] = 1 - it[inLibrary] = true }.value } } 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 731356d99..f3c093b06 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 a7a7fb453..2d6dc35eb 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 b9ee64558..0a04f5656 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") @@ -193,12 +194,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) @@ -220,7 +221,7 @@ class FilterListTest : ApplicationTest() { setFilter( source.id, - FilterChange(2, "1"), + listOf(FilterChange(2, "1")), ) val filterList = getFilterList(source.id, false) @@ -237,7 +238,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) @@ -254,7 +255,7 @@ class FilterListTest : ApplicationTest() { setFilter( source.id, - FilterChange(4, "true"), + listOf(FilterChange(4, "true")), ) val filterList = getFilterList(source.id, false) @@ -271,7 +272,7 @@ class FilterListTest : ApplicationTest() { setFilter( source.id, - FilterChange(5, "1"), + listOf(FilterChange(5, "1")), ) val filterList = getFilterList(source.id, false) @@ -288,7 +289,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) @@ -305,7 +306,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 bc4554a44..c8be6abda 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 mu.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,12 +38,18 @@ const val BASE_PATH = "build/tmp/TestDesk" fun createLibraryManga(_title: String): Int { return 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 } } @@ -63,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 + } } }