diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestriction.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestriction.kt new file mode 100644 index 000000000..e75aa3fce --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ContentRestriction.kt @@ -0,0 +1,35 @@ +package org.gotson.komga.domain.model + +sealed class ContentRestriction { + sealed class AgeRestriction(val age: Int) : ContentRestriction() { + /** + * Allow only content that has an age rating equal to or under the provided [age] + * + * @param[age] the age rating to allow + */ + class AllowOnlyUnder(age: Int) : AgeRestriction(age) + + /** + * Exclude content that has an age rating equal to or over the provided [age] + * + * @param[age] the age rating to exclude + */ + class ExcludeOver(age: Int) : AgeRestriction(age) + } + + sealed class LabelsRestriction(val labels: Collection) : ContentRestriction() { + /** + * Allow only content that has at least one of the provided sharing [labels] + * + * @param[labels] a set of sharing labels to allow access to + */ + class AllowOnly(labels: Set) : LabelsRestriction(labels) + + /** + * Exclude content that has at least one of the provided sharing [labels] + * + * @param[labels] a set of sharing labels to exclude + */ + class Exclude(labels: Set) : LabelsRestriction(labels) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt index a60ba8cd7..c9d5a2d4f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/KomgaUser.kt @@ -22,6 +22,7 @@ data class KomgaUser( val rolePageStreaming: Boolean = true, val sharedLibrariesIds: Set = emptySet(), val sharedAllLibraries: Boolean = true, + val restrictions: Set = emptySet(), val id: String = TsidCreator.getTsid256().toString(), override val createdDate: LocalDateTime = LocalDateTime.now(), override val lastModifiedDate: LocalDateTime = createdDate, @@ -66,6 +67,18 @@ data class KomgaUser( return sharedAllLibraries || sharedLibrariesIds.any { it == library.id } } + fun isContentRestricted(ageRating: Int? = null, sharingLabels: Set = emptySet()): Boolean { + restrictions.forEach { restriction -> + when (restriction) { + is ContentRestriction.AgeRestriction.AllowOnlyUnder -> if (ageRating == null || ageRating > restriction.age) return true + is ContentRestriction.AgeRestriction.ExcludeOver -> ageRating?.let { if (it >= restriction.age) return true } + is ContentRestriction.LabelsRestriction.AllowOnly -> if (restriction.labels.intersect(sharingLabels).isEmpty()) return true + is ContentRestriction.LabelsRestriction.Exclude -> if (restriction.labels.intersect(sharingLabels).isNotEmpty()) return true + } + } + return false + } + override fun toString(): String { return "KomgaUser(email='$email', roleAdmin=$roleAdmin, roleFileDownload=$roleFileDownload, rolePageStreaming=$rolePageStreaming, sharedLibrariesIds=$sharedLibrariesIds, sharedAllLibraries=$sharedAllLibraries, id=$id, createdDate=$createdDate, lastModifiedDate=$lastModifiedDate)" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRepository.kt index f5a8f1b54..92d5e29d1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/ReadListRepository.kt @@ -1,31 +1,29 @@ package org.gotson.komga.domain.persistence +import org.gotson.komga.domain.model.ContentRestriction import org.gotson.komga.domain.model.ReadList import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable interface ReadListRepository { - fun findByIdOrNull(readListId: String): ReadList? - - fun findAll(search: String? = null, pageable: Pageable): Page + /** + * Find one ReadList by [readListId], + * optionally with only bookIds filtered by the provided [filterOnLibraryIds] it not null. + */ + fun findByIdOrNull(readListId: String, filterOnLibraryIds: Collection? = null, restrictions: Set = emptySet()): ReadList? /** - * Find one ReadList by readListId, - * optionally with only bookIds filtered by the provided filterOnLibraryIds. + * Find all ReadList + * optionally with at least one Book belonging to the provided [belongsToLibraryIds] if not null, + * optionally with only bookIds filtered by the provided [filterOnLibraryIds] if not null. */ - fun findByIdOrNull(readListId: String, filterOnLibraryIds: Collection?): ReadList? + fun findAll(belongsToLibraryIds: Collection? = null, filterOnLibraryIds: Collection? = null, search: String? = null, pageable: Pageable, restrictions: Set = emptySet()): Page /** - * Find all ReadList with at least one Book belonging to the provided belongsToLibraryIds, - * optionally with only bookIds filtered by the provided filterOnLibraryIds. + * Find all ReadList that contains the provided [containsBookId], + * optionally with only bookIds filtered by the provided [filterOnLibraryIds] if not null. */ - fun findAllByLibraryIds(belongsToLibraryIds: Collection, filterOnLibraryIds: Collection?, search: String? = null, pageable: Pageable): Page - - /** - * Find all ReadList that contains the provided containsBookId, - * optionally with only bookIds filtered by the provided filterOnLibraryIds. - */ - fun findAllContainingBookId(containsBookId: String, filterOnLibraryIds: Collection?): Collection + fun findAllContainingBookId(containsBookId: String, filterOnLibraryIds: Collection?, restrictions: Set = emptySet()): Collection fun findAllEmpty(): Collection diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesCollectionRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesCollectionRepository.kt index 7107d73bc..819bcaca3 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesCollectionRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/SeriesCollectionRepository.kt @@ -1,31 +1,29 @@ package org.gotson.komga.domain.persistence +import org.gotson.komga.domain.model.ContentRestriction import org.gotson.komga.domain.model.SeriesCollection import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable interface SeriesCollectionRepository { - fun findByIdOrNull(collectionId: String): SeriesCollection? - - fun findAll(search: String? = null, pageable: Pageable): Page + /** + * Find one SeriesCollection by [collectionId], + * optionally with only seriesId filtered by the provided [filterOnLibraryIds] if not null. + */ + fun findByIdOrNull(collectionId: String, filterOnLibraryIds: Collection? = null, restrictions: Set = emptySet()): SeriesCollection? /** - * Find one SeriesCollection by collectionId, - * optionally with only seriesId filtered by the provided filterOnLibraryIds. + * Find all SeriesCollection + * optionally with at least one Series belonging to the provided [belongsToLibraryIds] if not null, + * optionally with only seriesId filtered by the provided [filterOnLibraryIds] if not null. */ - fun findByIdOrNull(collectionId: String, filterOnLibraryIds: Collection?): SeriesCollection? + fun findAll(belongsToLibraryIds: Collection? = null, filterOnLibraryIds: Collection? = null, search: String? = null, pageable: Pageable, restrictions: Set = emptySet()): Page /** - * Find all SeriesCollection with at least one Series belonging to the provided belongsToLibraryIds, - * optionally with only seriesId filtered by the provided filterOnLibraryIds. + * Find all SeriesCollection that contains the provided [containsSeriesId], + * optionally with only seriesId filtered by the provided [filterOnLibraryIds] if not null. */ - fun findAllByLibraryIds(belongsToLibraryIds: Collection, filterOnLibraryIds: Collection?, search: String? = null, pageable: Pageable): Page - - /** - * Find all SeriesCollection that contains the provided containsSeriesId, - * optionally with only seriesId filtered by the provided filterOnLibraryIds. - */ - fun findAllContainingSeriesId(containsSeriesId: String, filterOnLibraryIds: Collection?): Collection + fun findAllContainingSeriesId(containsSeriesId: String, filterOnLibraryIds: Collection?, restrictions: Set = emptySet()): Collection fun findAllEmpty(): Collection diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt index 2ec1716d3..72fbf17f4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDtoDao.kt @@ -1,6 +1,7 @@ package org.gotson.komga.infrastructure.jooq import org.gotson.komga.domain.model.BookSearchWithReadProgress +import org.gotson.komga.domain.model.ContentRestriction import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource import org.gotson.komga.infrastructure.search.LuceneEntity @@ -77,8 +78,8 @@ class BookDtoDao( "readList.number" to rlb.NUMBER, ) - override fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page { - val conditions = search.toCondition() + override fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable, restrictions: Set): Page { + val conditions = search.toCondition().and(restrictions.toCondition()) return findAll(conditions, userId, pageable, search.toJoinConditions(), null, search.searchTerm) } @@ -106,20 +107,21 @@ class BookDtoDao( val bookIds = luceneHelper.searchEntitiesIds(searchTerm, LuceneEntity.Book) val searchCondition = b.ID.inOrNoCondition(bookIds) - val count = dsl.selectDistinct(b.ID) - .from(b) - .leftJoin(m).on(b.ID.eq(m.BOOK_ID)) - .leftJoin(d).on(b.ID.eq(d.BOOK_ID)) - .leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(readProgressCondition(userId)) - .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } - .apply { if (joinConditions.tag) leftJoin(bt).on(b.ID.eq(bt.BOOK_ID)) } - .apply { if (joinConditions.selectReadListNumber) leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID)) } - .apply { if (joinConditions.author) leftJoin(a).on(b.ID.eq(a.BOOK_ID)) } - .where(conditions) - .and(searchCondition) - .groupBy(b.ID) - .fetch() - .size + val count = dsl.fetchCount( + dsl.selectDistinct(b.ID) + .from(b) + .leftJoin(m).on(b.ID.eq(m.BOOK_ID)) + .leftJoin(d).on(b.ID.eq(d.BOOK_ID)) + .leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(readProgressCondition(userId)) + .leftJoin(sd).on(b.SERIES_ID.eq(sd.SERIES_ID)) + .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .apply { if (joinConditions.tag) leftJoin(bt).on(b.ID.eq(bt.BOOK_ID)) } + .apply { if (joinConditions.selectReadListNumber) leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID)) } + .apply { if (joinConditions.author) leftJoin(a).on(b.ID.eq(a.BOOK_ID)) } + .where(conditions) + .and(searchCondition) + .groupBy(b.ID), + ) val orderBy = pageable.sort.mapNotNull { @@ -172,12 +174,14 @@ class BookDtoDao( ): BookDto? = findSiblingReadList(readListId, bookId, userId, filterOnLibraryIds, next = true) - override fun findAllOnDeck(userId: String, filterOnLibraryIds: Collection?, pageable: Pageable): Page { + override fun findAllOnDeck(userId: String, filterOnLibraryIds: Collection?, pageable: Pageable, restrictions: Set): Page { val seriesIds = dsl.select(s.ID) .from(s) .leftJoin(b).on(s.ID.eq(b.SERIES_ID)) .leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(readProgressCondition(userId)) - .apply { filterOnLibraryIds?.let { where(s.LIBRARY_ID.`in`(it)) } } + .leftJoin(sd).on(b.SERIES_ID.eq(sd.SERIES_ID)) + .where(restrictions.toCondition()) + .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } .groupBy(s.ID) .having(countUnread.ge(inline(1.toBigDecimal()))) .and(countRead.ge(inline(1.toBigDecimal()))) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDao.kt index de6206c7d..46005de5f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDao.kt @@ -1,5 +1,6 @@ package org.gotson.komga.infrastructure.jooq +import org.gotson.komga.domain.model.ContentRestriction import org.gotson.komga.domain.model.ReadList import org.gotson.komga.domain.persistence.ReadListRepository import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource @@ -32,32 +33,42 @@ class ReadListDao( private val rl = Tables.READLIST private val rlb = Tables.READLIST_BOOK private val b = Tables.BOOK + private val sd = Tables.SERIES_METADATA private val sorts = mapOf( "name" to rl.NAME.collate(SqliteUdfDataSource.collationUnicode3), ) - override fun findByIdOrNull(readListId: String): ReadList? = - selectBase() - .where(rl.ID.eq(readListId)) - .fetchAndMap(null) - .firstOrNull() - - override fun findByIdOrNull(readListId: String, filterOnLibraryIds: Collection?): ReadList? = - selectBase() + override fun findByIdOrNull(readListId: String, filterOnLibraryIds: Collection?, restrictions: Set): ReadList? = + selectBase(restrictions.isNotEmpty()) .where(rl.ID.eq(readListId)) .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } - .fetchAndMap(filterOnLibraryIds) + .apply { if (restrictions.isNotEmpty()) and(restrictions.toCondition()) } + .fetchAndMap(filterOnLibraryIds, restrictions) .firstOrNull() - override fun findAll(search: String?, pageable: Pageable): Page { + override fun findAll(belongsToLibraryIds: Collection?, filterOnLibraryIds: Collection?, search: String?, pageable: Pageable, restrictions: Set): Page { val readListIds = luceneHelper.searchEntitiesIds(search, LuceneEntity.ReadList) val searchCondition = rl.ID.inOrNoCondition(readListIds) - val count = dsl.selectCount() - .from(rl) - .where(searchCondition) - .fetchOne(0, Long::class.java) ?: 0 + val conditions = searchCondition + .and(b.LIBRARY_ID.inOrNoCondition(belongsToLibraryIds)) + .and(b.LIBRARY_ID.inOrNoCondition(filterOnLibraryIds)) + .and(restrictions.toCondition()) + + val queryIds = + if (belongsToLibraryIds == null && filterOnLibraryIds == null && restrictions.isEmpty()) null + else + dsl.selectDistinct(rl.ID) + .from(rl) + .leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID)) + .leftJoin(b).on(rlb.BOOK_ID.eq(b.ID)) + .apply { if (restrictions.isNotEmpty()) leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) } + .where(conditions) + + val count = + if (queryIds != null) dsl.fetchCount(queryIds) + else dsl.fetchCount(rl, searchCondition) val orderBy = pageable.sort.mapNotNull { @@ -65,50 +76,12 @@ class ReadListDao( else it.toSortField(sorts) } - val items = selectBase() - .where(searchCondition) - .orderBy(orderBy) - .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } - .fetchAndMap(null) - - val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() - return PageImpl( - items, - if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) - else PageRequest.of(0, maxOf(count.toInt(), 20), pageSort), - count, - ) - } - - override fun findAllByLibraryIds(belongsToLibraryIds: Collection, filterOnLibraryIds: Collection?, search: String?, pageable: Pageable): Page { - val readListIds = luceneHelper.searchEntitiesIds(search, LuceneEntity.ReadList) - val searchCondition = rl.ID.inOrNoCondition(readListIds) - - val conditions = b.LIBRARY_ID.`in`(belongsToLibraryIds) - .and(searchCondition) - .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } - - val ids = dsl.selectDistinct(rl.ID) - .from(rl) - .leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID)) - .leftJoin(b).on(rlb.BOOK_ID.eq(b.ID)) + val items = selectBase(restrictions.isNotEmpty()) .where(conditions) - .fetch(0, String::class.java) - - val count = ids.size - - val orderBy = - pageable.sort.mapNotNull { - if (it.property == "relevance" && !readListIds.isNullOrEmpty()) rl.ID.sortByValues(readListIds, it.isAscending) - else it.toSortField(sorts) - } - - val items = selectBase() - .where(rl.ID.`in`(ids)) - .and(conditions) + .apply { if (queryIds != null) and(rl.ID.`in`(queryIds)) } .orderBy(orderBy) .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } - .fetchAndMap(filterOnLibraryIds) + .fetchAndMap(filterOnLibraryIds, restrictions) val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() return PageImpl( @@ -119,17 +92,19 @@ class ReadListDao( ) } - override fun findAllContainingBookId(containsBookId: String, filterOnLibraryIds: Collection?): Collection { - val ids = dsl.select(rl.ID) + override fun findAllContainingBookId(containsBookId: String, filterOnLibraryIds: Collection?, restrictions: Set): Collection { + val queryIds = dsl.select(rl.ID) .from(rl) .leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID)) + .apply { if (restrictions.isNotEmpty()) leftJoin(b).on(rlb.BOOK_ID.eq(b.ID)).leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) } .where(rlb.BOOK_ID.eq(containsBookId)) - .fetch(0, String::class.java) + .apply { if (restrictions.isNotEmpty()) and(restrictions.toCondition()) } - return selectBase() - .where(rl.ID.`in`(ids)) + return selectBase(restrictions.isNotEmpty()) + .where(rl.ID.`in`(queryIds)) .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } - .fetchAndMap(filterOnLibraryIds) + .apply { if (restrictions.isNotEmpty()) and(restrictions.toCondition()) } + .fetchAndMap(filterOnLibraryIds, restrictions) } override fun findAllEmpty(): Collection = @@ -150,20 +125,23 @@ class ReadListDao( .fetchAndMap(null) .firstOrNull() - private fun selectBase() = + private fun selectBase(joinOnSeriesMetadata: Boolean = false) = dsl.selectDistinct(*rl.fields()) .from(rl) .leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID)) .leftJoin(b).on(rlb.BOOK_ID.eq(b.ID)) + .apply { if (joinOnSeriesMetadata) leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) } - private fun ResultQuery.fetchAndMap(filterOnLibraryIds: Collection?): List = + private fun ResultQuery.fetchAndMap(filterOnLibraryIds: Collection?, restrictions: Set = emptySet()): List = fetchInto(rl) .map { rr -> val bookIds = dsl.select(*rlb.fields()) .from(rlb) .leftJoin(b).on(rlb.BOOK_ID.eq(b.ID)) + .apply { if (restrictions.isNotEmpty()) leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) } .where(rlb.READLIST_ID.eq(rr.id)) .apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } } + .apply { if (restrictions.isNotEmpty()) and(restrictions.toCondition()) } .orderBy(rlb.NUMBER.asc()) .fetchInto(rlb) .mapNotNull { it.number to it.bookId } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDao.kt index e94bdf7cd..4d97af5b4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDao.kt @@ -1,5 +1,6 @@ package org.gotson.komga.infrastructure.jooq +import org.gotson.komga.domain.model.ContentRestriction import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource @@ -31,32 +32,42 @@ class SeriesCollectionDao( private val c = Tables.COLLECTION private val cs = Tables.COLLECTION_SERIES private val s = Tables.SERIES + private val sd = Tables.SERIES_METADATA private val sorts = mapOf( "name" to c.NAME.collate(SqliteUdfDataSource.collationUnicode3), ) - override fun findByIdOrNull(collectionId: String): SeriesCollection? = - selectBase() - .where(c.ID.eq(collectionId)) - .fetchAndMap(null) - .firstOrNull() - - override fun findByIdOrNull(collectionId: String, filterOnLibraryIds: Collection?): SeriesCollection? = - selectBase() + override fun findByIdOrNull(collectionId: String, filterOnLibraryIds: Collection?, restrictions: Set): SeriesCollection? = + selectBase(restrictions.isNotEmpty()) .where(c.ID.eq(collectionId)) .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } - .fetchAndMap(filterOnLibraryIds) + .apply { if (restrictions.isNotEmpty()) and(restrictions.toCondition()) } + .fetchAndMap(filterOnLibraryIds, restrictions) .firstOrNull() - override fun findAll(search: String?, pageable: Pageable): Page { + override fun findAll(belongsToLibraryIds: Collection?, filterOnLibraryIds: Collection?, search: String?, pageable: Pageable, restrictions: Set): Page { val collectionIds = luceneHelper.searchEntitiesIds(search, LuceneEntity.Collection) val searchCondition = c.ID.inOrNoCondition(collectionIds) - val count = dsl.selectCount() - .from(c) - .where(searchCondition) - .fetchOne(0, Long::class.java) ?: 0 + val conditions = searchCondition + .and(s.LIBRARY_ID.inOrNoCondition(belongsToLibraryIds)) + .and(s.LIBRARY_ID.inOrNoCondition(filterOnLibraryIds)) + .and(restrictions.toCondition()) + + val queryIds = + if (belongsToLibraryIds == null && filterOnLibraryIds == null && restrictions.isEmpty()) null + else + dsl.selectDistinct(c.ID) + .from(c) + .leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID)) + .leftJoin(s).on(cs.SERIES_ID.eq(s.ID)) + .leftJoin(sd).on(cs.SERIES_ID.eq(sd.SERIES_ID)) + .where(conditions) + + val count = + if (queryIds != null) dsl.fetchCount(queryIds) + else dsl.fetchCount(c, searchCondition) val orderBy = pageable.sort.mapNotNull { @@ -64,50 +75,12 @@ class SeriesCollectionDao( else it.toSortField(sorts) } - val items = selectBase() - .where(searchCondition) - .orderBy(orderBy) - .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } - .fetchAndMap(null) - - val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() - return PageImpl( - items, - if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort) - else PageRequest.of(0, maxOf(count.toInt(), 20), pageSort), - count, - ) - } - - override fun findAllByLibraryIds(belongsToLibraryIds: Collection, filterOnLibraryIds: Collection?, search: String?, pageable: Pageable): Page { - val collectionIds = luceneHelper.searchEntitiesIds(search, LuceneEntity.Collection) - val searchCondition = c.ID.inOrNoCondition(collectionIds) - - val conditions = s.LIBRARY_ID.`in`(belongsToLibraryIds) - .and(searchCondition) - .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } - - val ids = dsl.selectDistinct(c.ID) - .from(c) - .leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID)) - .leftJoin(s).on(cs.SERIES_ID.eq(s.ID)) + val items = selectBase(restrictions.isNotEmpty()) .where(conditions) - .fetch(0, String::class.java) - - val count = ids.size - - val orderBy = - pageable.sort.mapNotNull { - if (it.property == "relevance" && !collectionIds.isNullOrEmpty()) c.ID.sortByValues(collectionIds, it.isAscending) - else it.toSortField(sorts) - } - - val items = selectBase() - .where(c.ID.`in`(ids)) - .and(conditions) + .apply { if (queryIds != null) and(c.ID.`in`(queryIds)) } .orderBy(orderBy) .apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) } - .fetchAndMap(filterOnLibraryIds) + .fetchAndMap(filterOnLibraryIds, restrictions) val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted() return PageImpl( @@ -118,17 +91,19 @@ class SeriesCollectionDao( ) } - override fun findAllContainingSeriesId(containsSeriesId: String, filterOnLibraryIds: Collection?): Collection { - val ids = dsl.select(c.ID) + override fun findAllContainingSeriesId(containsSeriesId: String, filterOnLibraryIds: Collection?, restrictions: Set): Collection { + val queryIds = dsl.select(c.ID) .from(c) .leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID)) + .apply { if (restrictions.isNotEmpty()) leftJoin(sd).on(cs.SERIES_ID.eq(sd.SERIES_ID)) } .where(cs.SERIES_ID.eq(containsSeriesId)) - .fetch(0, String::class.java) + .apply { if (restrictions.isNotEmpty()) and(restrictions.toCondition()) } - return selectBase() - .where(c.ID.`in`(ids)) + return selectBase(restrictions.isNotEmpty()) + .where(c.ID.`in`(queryIds)) .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } - .fetchAndMap(filterOnLibraryIds) + .apply { if (restrictions.isNotEmpty()) and(restrictions.toCondition()) } + .fetchAndMap(filterOnLibraryIds, restrictions) } override fun findAllEmpty(): Collection = @@ -149,20 +124,23 @@ class SeriesCollectionDao( .fetchAndMap(null) .firstOrNull() - private fun selectBase() = + private fun selectBase(joinOnSeriesMetadata: Boolean = false) = dsl.selectDistinct(*c.fields()) .from(c) .leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID)) .leftJoin(s).on(cs.SERIES_ID.eq(s.ID)) + .apply { if (joinOnSeriesMetadata) leftJoin(sd).on(cs.SERIES_ID.eq(sd.SERIES_ID)) } - private fun ResultQuery.fetchAndMap(filterOnLibraryIds: Collection?): List = + private fun ResultQuery.fetchAndMap(filterOnLibraryIds: Collection?, restrictions: Set = emptySet()): List = fetchInto(c) .map { cr -> val seriesIds = dsl.select(*cs.fields()) .from(cs) .leftJoin(s).on(cs.SERIES_ID.eq(s.ID)) + .apply { if (restrictions.isNotEmpty()) leftJoin(sd).on(cs.SERIES_ID.eq(sd.SERIES_ID)) } .where(cs.COLLECTION_ID.eq(cr.id)) .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } } + .apply { if (restrictions.isNotEmpty()) and(restrictions.toCondition()) } .orderBy(cs.NUMBER.asc()) .fetchInto(cs) .mapNotNull { it.seriesId } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt index fe1f2ad6a..71fa38aec 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt @@ -1,6 +1,7 @@ package org.gotson.komga.infrastructure.jooq import mu.KotlinLogging +import org.gotson.komga.domain.model.ContentRestriction import org.gotson.komga.domain.model.ReadStatus import org.gotson.komga.domain.model.SeriesSearch import org.gotson.komga.domain.model.SeriesSearchWithReadProgress @@ -37,7 +38,6 @@ import java.net.URL private val logger = KotlinLogging.logger {} -const val BOOKS_COUNT = "booksCount" const val BOOKS_UNREAD_COUNT = "booksUnreadCount" const val BOOKS_IN_PROGRESS_COUNT = "booksInProgressCount" const val BOOKS_READ_COUNT = "booksReadCount" @@ -77,8 +77,8 @@ class SeriesDtoDao( "booksCount" to s.BOOK_COUNT, ) - override fun findAll(search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable): Page { - val conditions = search.toCondition() + override fun findAll(search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable, restrictions: Set): Page { + val conditions = search.toCondition().and(restrictions.toCondition()) return findAll(conditions, userId, pageable, search.toJoinConditions(), search.searchTerm) } @@ -88,8 +88,9 @@ class SeriesDtoDao( search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable, + restrictions: Set, ): Page { - val conditions = search.toCondition().and(cs.COLLECTION_ID.eq(collectionId)) + val conditions = search.toCondition().and(restrictions.toCondition()).and(cs.COLLECTION_ID.eq(collectionId)) val joinConditions = search.toJoinConditions().copy(selectCollectionNumber = true, collection = true) return findAll(conditions, userId, pageable, joinConditions, search.searchTerm) @@ -98,16 +99,18 @@ class SeriesDtoDao( override fun findAllRecentlyUpdated( search: SeriesSearchWithReadProgress, userId: String, + restrictions: Set, pageable: Pageable, ): Page { val conditions = search.toCondition() - .and(s.CREATED_DATE.ne(s.LAST_MODIFIED_DATE)) + .and(restrictions.toCondition()) + .and(s.CREATED_DATE.notEqual(s.LAST_MODIFIED_DATE)) return findAll(conditions, userId, pageable, search.toJoinConditions(), search.searchTerm) } - override fun countByFirstCharacter(search: SeriesSearchWithReadProgress, userId: String): List { - val conditions = search.toCondition() + override fun countByFirstCharacter(search: SeriesSearchWithReadProgress, userId: String, restrictions: Set): List { + val conditions = search.toCondition().and(restrictions.toCondition()) val joinConditions = search.toJoinConditions() val seriesIds = luceneHelper.searchEntitiesIds(search.searchTerm, LuceneEntity.Series) val searchCondition = s.ID.inOrNoCondition(seriesIds) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt index cde2683e6..88c0dbfe4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/Utils.kt @@ -1,5 +1,6 @@ package org.gotson.komga.infrastructure.jooq +import org.gotson.komga.domain.model.ContentRestriction import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource import org.gotson.komga.jooq.Tables import org.jooq.Condition @@ -34,7 +35,7 @@ fun Field.sortByValues(values: List, asc: Boolean = true): Field return c.otherwise(Int.MAX_VALUE) } -fun Field.inOrNoCondition(list: List?): Condition = +fun Field.inOrNoCondition(list: Collection?): Condition = when { list == null -> DSL.noCondition() list.isEmpty() -> DSL.falseCondition() @@ -63,3 +64,16 @@ fun DSLContext.insertTempStrings(batchSize: Int, collection: Collection) } fun DSLContext.selectTempStrings() = this.select(Tables.TEMP_STRING_LIST.STRING).from(Tables.TEMP_STRING_LIST) + +fun Set.toCondition(): Condition = + this.fold(DSL.noCondition()) { accumulator, restriction -> + accumulator.and( + when (restriction) { + is ContentRestriction.AgeRestriction.AllowOnlyUnder -> Tables.SERIES_METADATA.AGE_RATING.lessOrEqual(restriction.age) + is ContentRestriction.AgeRestriction.ExcludeOver -> Tables.SERIES_METADATA.AGE_RATING.isNull.or(Tables.SERIES_METADATA.AGE_RATING.lessThan(restriction.age)) + // TODO: add conditions for sharing labels + is ContentRestriction.LabelsRestriction.AllowOnly -> DSL.noCondition() + is ContentRestriction.LabelsRestriction.Exclude -> DSL.noCondition() + }, + ) + } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt new file mode 100644 index 000000000..2d418fea6 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/Utils.kt @@ -0,0 +1,16 @@ +package org.gotson.komga.interfaces.api + +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.interfaces.api.rest.dto.SeriesDto +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException + +/** + * Convenience function to check for content restriction. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ +fun KomgaUser.checkContentRestriction(series: SeriesDto) { + if (!canAccessLibrary(series.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + if (isContentRestricted(ageRating = series.metadata.ageRating)) throw ResponseStatusException(HttpStatus.FORBIDDEN) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsController.kt index 50b0f6fc1..b7e4eb6c8 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/opds/OpdsController.kt @@ -18,6 +18,7 @@ import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageAsQueryParam +import org.gotson.komga.interfaces.api.checkContentRestriction import org.gotson.komga.interfaces.api.opds.dto.OpdsAuthor import org.gotson.komga.interfaces.api.opds.dto.OpdsEntryAcquisition import org.gotson.komga.interfaces.api.opds.dto.OpdsEntryNavigation @@ -225,6 +226,7 @@ class OpdsController( principal.user.id, principal.user.getAuthorizedLibraryIds(null), page, + principal.user.restrictions, ) val builder = uriBuilder(ROUTE_ON_DECK) @@ -263,6 +265,7 @@ class OpdsController( bookSearch, principal.user.id, pageable, + principal.user.restrictions, ) val builder = uriBuilder(ROUTE_ON_DECK) @@ -301,7 +304,7 @@ class OpdsController( deleted = false, ) - val seriesPage = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable) + val seriesPage = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable, principal.user.restrictions) val builder = uriBuilder(ROUTE_SERIES_ALL) .queryParamIfPresent("search", Optional.ofNullable(searchTerm)) @@ -335,7 +338,7 @@ class OpdsController( deleted = false, ) - val seriesPage = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable) + val seriesPage = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable, principal.user.restrictions) val uriBuilder = uriBuilder(ROUTE_SERIES_LATEST) @@ -372,7 +375,7 @@ class OpdsController( ) val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("createdDate"))) - val bookPage = bookDtoRepository.findAll(bookSearch, principal.user.id, pageable) + val bookPage = bookDtoRepository.findAll(bookSearch, principal.user.id, pageable, principal.user.restrictions) val uriBuilder = uriBuilder(ROUTE_BOOKS_LATEST) @@ -420,12 +423,7 @@ class OpdsController( @Parameter(hidden = true) page: Pageable, ): OpdsFeed { val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("name"))) - val collections = - if (principal.user.sharedAllLibraries) { - collectionRepository.findAll(pageable = pageable) - } else { - collectionRepository.findAllByLibraryIds(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, pageable = pageable) - } + val collections = collectionRepository.findAll(principal.user.getAuthorizedLibraryIds(null), principal.user.getAuthorizedLibraryIds(null), pageable = pageable) val uriBuilder = uriBuilder(ROUTE_COLLECTIONS_ALL) @@ -450,12 +448,7 @@ class OpdsController( @Parameter(hidden = true) page: Pageable, ): OpdsFeed { val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("name"))) - val readLists = - if (principal.user.sharedAllLibraries) { - readListRepository.findAll(pageable = pageable) - } else { - readListRepository.findAllByLibraryIds(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, pageable = pageable) - } + val readLists = readListRepository.findAll(principal.user.getAuthorizedLibraryIds(null), principal.user.getAuthorizedLibraryIds(null), pageable = pageable) val uriBuilder = uriBuilder(ROUTE_READLISTS_ALL) @@ -514,7 +507,7 @@ class OpdsController( @Parameter(hidden = true) page: Pageable, ): OpdsFeed = seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { series -> - if (!principal.user.canAccessLibrary(series.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + principal.user.checkContentRestriction(series) val bookSearch = BookSearchWithReadProgress( seriesIds = listOf(id), @@ -559,7 +552,7 @@ class OpdsController( val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.titleSort"))) - val entries = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable) + val entries = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable, principal.user.restrictions) .map { it.toOpdsEntry() } val uriBuilder = uriBuilder("libraries/$id") @@ -597,7 +590,7 @@ class OpdsController( deleted = false, ) - val entries = seriesDtoRepository.findAllByCollectionId(collection.id, seriesSearch, principal.user.id, pageable) + val entries = seriesDtoRepository.findAllByCollectionId(collection.id, seriesSearch, principal.user.id, pageable, principal.user.restrictions) .map { seriesDto -> val index = if (shouldEnforceSort(userAgent)) collection.seriesIds.indexOf(seriesDto.id) + 1 else null seriesDto.toOpdsEntry(index) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/BookDtoRepository.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/BookDtoRepository.kt index c9e85a0b1..e37f1bfe7 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/BookDtoRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/BookDtoRepository.kt @@ -1,12 +1,13 @@ package org.gotson.komga.interfaces.api.persistence import org.gotson.komga.domain.model.BookSearchWithReadProgress +import org.gotson.komga.domain.model.ContentRestriction import org.gotson.komga.interfaces.api.rest.dto.BookDto import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable interface BookDtoRepository { - fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page + fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable, restrictions: Set = emptySet()): Page /** * Find books that are part of a readlist, optionally filtered by library @@ -38,7 +39,7 @@ interface BookDtoRepository { filterOnLibraryIds: Collection?, ): BookDto? - fun findAllOnDeck(userId: String, filterOnLibraryIds: Collection?, pageable: Pageable): Page + fun findAllOnDeck(userId: String, filterOnLibraryIds: Collection?, pageable: Pageable, restrictions: Set = emptySet()): Page fun findAllDuplicates(userId: String, pageable: Pageable): Page } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/SeriesDtoRepository.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/SeriesDtoRepository.kt index ee6f2720a..5de247b78 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/SeriesDtoRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/persistence/SeriesDtoRepository.kt @@ -1,5 +1,6 @@ package org.gotson.komga.interfaces.api.persistence +import org.gotson.komga.domain.model.ContentRestriction import org.gotson.komga.domain.model.SeriesSearchWithReadProgress import org.gotson.komga.interfaces.api.rest.dto.GroupCountDto import org.gotson.komga.interfaces.api.rest.dto.SeriesDto @@ -9,9 +10,9 @@ import org.springframework.data.domain.Pageable interface SeriesDtoRepository { fun findByIdOrNull(seriesId: String, userId: String): SeriesDto? - fun findAll(search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable): Page - fun findAllByCollectionId(collectionId: String, search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable): Page - fun findAllRecentlyUpdated(search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable): Page + fun findAll(search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable, restrictions: Set = emptySet()): Page + fun findAllByCollectionId(collectionId: String, search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable, restrictions: Set = emptySet()): Page + fun findAllRecentlyUpdated(search: SeriesSearchWithReadProgress, userId: String, restrictions: Set, pageable: Pageable): Page - fun countByFirstCharacter(search: SeriesSearchWithReadProgress, userId: String): List + fun countByFirstCharacter(search: SeriesSearchWithReadProgress, userId: String, restrictions: Set): List } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt index 22e47b2d8..0cf3a891f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/BookController.kt @@ -11,9 +11,11 @@ import org.gotson.komga.application.events.EventPublisher import org.gotson.komga.application.tasks.HIGHEST_PRIORITY import org.gotson.komga.application.tasks.HIGH_PRIORITY import org.gotson.komga.application.tasks.TaskEmitter +import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.BookSearchWithReadProgress import org.gotson.komga.domain.model.DomainEvent import org.gotson.komga.domain.model.ImageConversionException +import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaNotReadyException @@ -26,6 +28,7 @@ import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.ThumbnailBookRepository import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.infrastructure.image.ImageType @@ -93,6 +96,7 @@ class BookController( private val bookLifecycle: BookLifecycle, private val bookRepository: BookRepository, private val bookMetadataRepository: BookMetadataRepository, + private val seriesMetadataRepository: SeriesMetadataRepository, private val mediaRepository: MediaRepository, private val bookDtoRepository: BookDtoRepository, private val readListRepository: ReadListRepository, @@ -138,7 +142,7 @@ class BookController( tags = tags, ) - return bookDtoRepository.findAll(bookSearch, principal.user.id, pageRequest) + return bookDtoRepository.findAll(bookSearch, principal.user.id, pageRequest, principal.user.restrictions) .map { it.restrictUrl(!principal.user.roleAdmin) } } @@ -166,6 +170,7 @@ class BookController( ), principal.user.id, pageRequest, + principal.user.restrictions, ).map { it.restrictUrl(!principal.user.roleAdmin) } } @@ -181,6 +186,7 @@ class BookController( principal.user.id, principal.user.getAuthorizedLibraryIds(libraryIds), page, + principal.user.restrictions, ).map { it.restrictUrl(!principal.user.roleAdmin) } @PageableAsQueryParam @@ -214,7 +220,8 @@ class BookController( @PathVariable bookId: String, ): BookDto = bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { - if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + principal.user.checkContentRestriction(it) + it.restrictUrl(!principal.user.roleAdmin) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -223,9 +230,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): BookDto { - bookRepository.getLibraryIdOrNull(bookId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(bookId) return bookDtoRepository.findPreviousInSeriesOrNull(bookId, principal.user.id) ?.restrictUrl(!principal.user.roleAdmin) @@ -237,9 +242,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): BookDto { - bookRepository.getLibraryIdOrNull(bookId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(bookId) return bookDtoRepository.findNextInSeriesOrNull(bookId, principal.user.id) ?.restrictUrl(!principal.user.roleAdmin) @@ -251,11 +254,9 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable(name = "bookId") bookId: String, ): List { - bookRepository.getLibraryIdOrNull(bookId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(bookId) - return readListRepository.findAllContainingBookId(bookId, principal.user.getAuthorizedLibraryIds(null)) + return readListRepository.findAllContainingBookId(bookId, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions) .map { it.toDto() } } @@ -271,9 +272,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookId: String, ): ByteArray { - bookRepository.getLibraryIdOrNull(bookId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(bookId) return bookLifecycle.getThumbnailBytes(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } @@ -285,9 +284,7 @@ class BookController( @PathVariable(name = "bookId") bookId: String, @PathVariable(name = "thumbnailId") thumbnailId: String, ): ByteArray { - bookRepository.getLibraryIdOrNull(bookId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(bookId) return bookLifecycle.getThumbnailBytesByThumbnailId(thumbnailId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -298,9 +295,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable(name = "bookId") bookId: String, ): Collection { - bookRepository.getLibraryIdOrNull(bookId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(bookId) return thumbnailBookRepository.findAllByBookId(bookId) .map { it.toDto() } @@ -316,7 +311,6 @@ class BookController( @RequestParam("selected") selected: Boolean = true, ) { val book = bookRepository.findByIdOrNull(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) if (!contentDetector.isImage(file.inputStream.buffered().use { contentDetector.detectMediaType(it) })) throw ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE) @@ -340,10 +334,6 @@ class BookController( @PathVariable(name = "bookId") bookId: String, @PathVariable(name = "thumbnailId") thumbnailId: String, ) { - bookRepository.findByIdOrNull(bookId)?.let { book -> - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - thumbnailBookRepository.findByIdOrNull(thumbnailId)?.let { thumbnailBookRepository.markSelected(it) eventPublisher.publishEvent(DomainEvent.ThumbnailBookAdded(it.copy(selected = true))) @@ -358,10 +348,6 @@ class BookController( @PathVariable(name = "bookId") bookId: String, @PathVariable(name = "thumbnailId") thumbnailId: String, ) { - bookRepository.findByIdOrNull(bookId)?.let { book -> - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) - thumbnailBookRepository.findByIdOrNull(thumbnailId)?.let { try { bookLifecycle.deleteThumbnailForBook(it) @@ -386,7 +372,7 @@ class BookController( @PathVariable bookId: String, ): ResponseEntity = bookRepository.findByIdOrNull(bookId)?.let { book -> - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + principal.user.checkContentRestriction(book) try { val media = mediaRepository.findById(book.id) with(FileSystemResource(book.path)) { @@ -421,7 +407,7 @@ class BookController( @PathVariable bookId: String, ): List = bookRepository.findByIdOrNull(bookId)?.let { book -> - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + principal.user.checkContentRestriction(book) val media = mediaRepository.findById(book.id) when (media.status) { @@ -482,7 +468,9 @@ class BookController( .setNotModified(media) .body(ByteArray(0)) } - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + + principal.user.checkContentRestriction(book) + try { val convertFormat = when (convertTo?.lowercase()) { "jpeg" -> ImageType.JPEG @@ -539,7 +527,9 @@ class BookController( .setNotModified(media) .body(ByteArray(0)) } - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + + principal.user.checkContentRestriction(book) + try { val pageContent = bookLifecycle.getBookPage(book, pageNumber, resizeTo = 300) @@ -626,7 +616,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, ) { bookRepository.findByIdOrNull(bookId)?.let { book -> - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + principal.user.checkContentRestriction(book) try { if (readProgress.completed != null && readProgress.completed) @@ -647,7 +637,7 @@ class BookController( @AuthenticationPrincipal principal: KomgaPrincipal, ) { bookRepository.findByIdOrNull(bookId)?.let { book -> - if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + principal.user.checkContentRestriction(book) bookLifecycle.deleteReadProgress(book, principal.user) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -692,4 +682,49 @@ class BookController( private fun getBookLastModified(media: Media) = media.lastModifiedDate.toInstant(ZoneOffset.UTC).toEpochMilli() + + /** + * Convenience function to check for content restriction. + * This will retrieve data from repositories if needed. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + private fun KomgaUser.checkContentRestriction(book: BookDto) { + if (!canAccessLibrary(book.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + if (restrictions.isNotEmpty()) seriesMetadataRepository.findById(book.seriesId).let { + if (isContentRestricted(ageRating = it.ageRating)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } + + /** + * Convenience function to check for content restriction. + * This will retrieve data from repositories if needed. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + private fun KomgaUser.checkContentRestriction(book: Book) { + if (!canAccessLibrary(book.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + if (restrictions.isNotEmpty()) seriesMetadataRepository.findById(book.seriesId).let { + if (isContentRestricted(ageRating = it.ageRating)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } + + /** + * Convenience function to check for content restriction. + * This will retrieve data from repositories if needed. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + private fun KomgaUser.checkContentRestriction(bookId: String) { + if (!sharedAllLibraries) { + bookRepository.getLibraryIdOrNull(bookId)?.let { + if (!canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + if (restrictions.isNotEmpty()) bookRepository.getSeriesIdOrNull(bookId)?.let { seriesId -> + seriesMetadataRepository.findById(seriesId).let { + if (isContentRestricted(ageRating = it.ageRating)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReadListController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReadListController.kt index 3cc07e55e..79d34eae8 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReadListController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/ReadListController.kt @@ -114,30 +114,8 @@ class ReadListController( sort, ) - return when { - principal.user.sharedAllLibraries && libraryIds == null -> readListRepository.findAll( - searchTerm, - pageable = pageRequest, - ) - principal.user.sharedAllLibraries && libraryIds != null -> readListRepository.findAllByLibraryIds( - libraryIds, - null, - searchTerm, - pageable = pageRequest, - ) - !principal.user.sharedAllLibraries && libraryIds != null -> readListRepository.findAllByLibraryIds( - libraryIds, - principal.user.sharedLibrariesIds, - searchTerm, - pageable = pageRequest, - ) - else -> readListRepository.findAllByLibraryIds( - principal.user.sharedLibrariesIds, - principal.user.sharedLibrariesIds, - searchTerm, - pageable = pageRequest, - ) - }.map { it.toDto() } + return readListRepository.findAll(principal.user.getAuthorizedLibraryIds(libraryIds), principal.user.getAuthorizedLibraryIds(null), searchTerm, pageRequest, principal.user.restrictions) + .map { it.toDto() } } @GetMapping("{id}") @@ -145,7 +123,7 @@ class ReadListController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable id: String, ): ReadListDto = - readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null)) + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions) ?.toDto() ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -155,7 +133,7 @@ class ReadListController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable id: String, ): ResponseEntity { - readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate()) .body(readListLifecycle.getThumbnailBytes(it)) @@ -169,7 +147,7 @@ class ReadListController( @PathVariable(name = "id") id: String, @PathVariable(name = "thumbnailId") thumbnailId: String, ): ByteArray { - readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { return readListLifecycle.getThumbnailBytes(thumbnailId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -180,7 +158,7 @@ class ReadListController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable(name = "id") id: String, ): Collection { - readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { return thumbnailReadListRepository.findAllByReadListId(id).map { it.toDto() } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } @@ -194,7 +172,7 @@ class ReadListController( @RequestParam("file") file: MultipartFile, @RequestParam("selected") selected: Boolean = true, ) { - readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { readList -> if (!contentDetector.isImage(file.inputStream.buffered().use { contentDetector.detectMediaType(it) })) throw ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE) @@ -218,7 +196,7 @@ class ReadListController( @PathVariable(name = "id") id: String, @PathVariable(name = "thumbnailId") thumbnailId: String, ) { - readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { thumbnailReadListRepository.findByIdOrNull(thumbnailId)?.let { readListLifecycle.markSelectedThumbnail(it) eventPublisher.publishEvent(DomainEvent.ThumbnailReadListAdded(it.copy(selected = true))) @@ -234,7 +212,7 @@ class ReadListController( @PathVariable(name = "id") id: String, @PathVariable(name = "thumbnailId") thumbnailId: String, ) { - readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { thumbnailReadListRepository.findByIdOrNull(thumbnailId)?.let { readListLifecycle.deleteThumbnail(it) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionController.kt index 09943e871..1a1aa4912 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionController.kt @@ -93,12 +93,8 @@ class SeriesCollectionController( sort, ) - return when { - principal.user.sharedAllLibraries && libraryIds == null -> collectionRepository.findAll(searchTerm, pageable = pageRequest) - principal.user.sharedAllLibraries && libraryIds != null -> collectionRepository.findAllByLibraryIds(libraryIds, null, searchTerm, pageable = pageRequest) - !principal.user.sharedAllLibraries && libraryIds != null -> collectionRepository.findAllByLibraryIds(libraryIds, principal.user.sharedLibrariesIds, searchTerm, pageable = pageRequest) - else -> collectionRepository.findAllByLibraryIds(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, searchTerm, pageable = pageRequest) - }.map { it.toDto() } + return collectionRepository.findAll(principal.user.getAuthorizedLibraryIds(libraryIds), principal.user.getAuthorizedLibraryIds(null), searchTerm, pageRequest, principal.user.restrictions) + .map { it.toDto() } } @GetMapping("{id}") @@ -106,7 +102,7 @@ class SeriesCollectionController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable id: String, ): CollectionDto = - collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null)) + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions) ?.toDto() ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -116,7 +112,7 @@ class SeriesCollectionController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable id: String, ): ResponseEntity { - collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate()) .body(collectionLifecycle.getThumbnailBytes(it, principal.user.id)) @@ -130,7 +126,7 @@ class SeriesCollectionController( @PathVariable(name = "id") id: String, @PathVariable(name = "thumbnailId") thumbnailId: String, ): ByteArray { - collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { return collectionLifecycle.getThumbnailBytes(thumbnailId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -141,7 +137,7 @@ class SeriesCollectionController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable(name = "id") id: String, ): Collection { - collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { return thumbnailSeriesCollectionRepository.findAllByCollectionId(id).map { it.toDto() } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } @@ -272,7 +268,7 @@ class SeriesCollectionController( @Parameter(hidden = true) @Authors authors: List?, @Parameter(hidden = true) page: Pageable, ): Page = - collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection -> + collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions)?.let { collection -> val sort = if (collection.ordered) Sort.by(Sort.Order.asc("collection.number")) else Sort.by(Sort.Order.asc("metadata.titleSort")) @@ -300,7 +296,7 @@ class SeriesCollectionController( authors = authors, ) - seriesDtoRepository.findAllByCollectionId(collection.id, seriesSearch, principal.user.id, pageRequest) + seriesDtoRepository.findAllByCollectionId(collection.id, seriesSearch, principal.user.id, pageRequest, principal.user.restrictions) .map { it.restrictUrl(!principal.user.roleAdmin) } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt index 058e9c804..1a026926a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/SeriesController.kt @@ -18,6 +18,7 @@ import org.gotson.komga.application.tasks.TaskEmitter import org.gotson.komga.domain.model.Author import org.gotson.komga.domain.model.BookSearchWithReadProgress import org.gotson.komga.domain.model.DomainEvent +import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.MarkSelectedPreference import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.MediaType.ZIP @@ -43,6 +44,7 @@ import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam import org.gotson.komga.infrastructure.web.Authors import org.gotson.komga.infrastructure.web.DelimitedPair +import org.gotson.komga.interfaces.api.checkContentRestriction import org.gotson.komga.interfaces.api.persistence.BookDtoRepository import org.gotson.komga.interfaces.api.persistence.ReadProgressDtoRepository import org.gotson.komga.interfaces.api.persistence.SeriesDtoRepository @@ -176,7 +178,7 @@ class SeriesController( authors = authors, ) - return seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageRequest) + return seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageRequest, principal.user.restrictions) .map { it.restrictUrl(!principal.user.roleAdmin) } } @@ -231,7 +233,7 @@ class SeriesController( authors = authors, ) - return seriesDtoRepository.countByFirstCharacter(seriesSearch, principal.user.id) + return seriesDtoRepository.countByFirstCharacter(seriesSearch, principal.user.id, principal.user.restrictions) } @Operation(description = "Return recently added or updated series.") @@ -261,6 +263,7 @@ class SeriesController( ), principal.user.id, pageRequest, + principal.user.restrictions, ).map { it.restrictUrl(!principal.user.roleAdmin) } } @@ -291,6 +294,7 @@ class SeriesController( ), principal.user.id, pageRequest, + principal.user.restrictions, ).map { it.restrictUrl(!principal.user.roleAdmin) } } @@ -320,6 +324,7 @@ class SeriesController( deleted = deleted, ), principal.user.id, + principal.user.restrictions, pageRequest, ).map { it.restrictUrl(!principal.user.roleAdmin) } } @@ -330,7 +335,7 @@ class SeriesController( @PathVariable(name = "seriesId") id: String, ): SeriesDto = seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { - if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + principal.user.checkContentRestriction(it) it.restrictUrl(!principal.user.roleAdmin) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -340,9 +345,7 @@ class SeriesController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable(name = "seriesId") seriesId: String, ): ByteArray { - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(seriesId) return seriesLifecycle.getThumbnailBytes(seriesId, principal.user.id) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -355,9 +358,7 @@ class SeriesController( @PathVariable(name = "seriesId") seriesId: String, @PathVariable(name = "thumbnailId") thumbnailId: String, ): ByteArray { - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(seriesId) return seriesLifecycle.getThumbnailBytesByThumbnailId(thumbnailId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @@ -368,9 +369,7 @@ class SeriesController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable(name = "seriesId") seriesId: String, ): Collection { - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(seriesId) return thumbnailsSeriesRepository.findAllBySeriesId(seriesId) .map { it.toDto() } @@ -443,9 +442,8 @@ class SeriesController( @Parameter(hidden = true) @Authors authors: List?, @Parameter(hidden = true) page: Pageable, ): Page { - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(seriesId) + val sort = if (page.sort.isSorted) page.sort else Sort.by(Sort.Order.asc("metadata.numberSort")) @@ -477,11 +475,9 @@ class SeriesController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable(name = "seriesId") seriesId: String, ): List { - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(seriesId) - return collectionRepository.findAllContainingSeriesId(seriesId, principal.user.getAuthorizedLibraryIds(null)) + return collectionRepository.findAllContainingSeriesId(seriesId, principal.user.getAuthorizedLibraryIds(null), principal.user.restrictions) .map { it.toDto() } } @@ -557,9 +553,7 @@ class SeriesController( @PathVariable seriesId: String, @AuthenticationPrincipal principal: KomgaPrincipal, ) { - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(seriesId) seriesLifecycle.markReadProgressCompleted(seriesId, principal.user) } @@ -571,9 +565,7 @@ class SeriesController( @PathVariable seriesId: String, @AuthenticationPrincipal principal: KomgaPrincipal, ) { - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(seriesId) seriesLifecycle.deleteReadProgress(seriesId, principal.user) } @@ -583,21 +575,21 @@ class SeriesController( fun getReadProgressTachiyomi( @PathVariable seriesId: String, @AuthenticationPrincipal principal: KomgaPrincipal, - ): TachiyomiReadProgressDto = - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - return readProgressDtoRepository.findProgressBySeries(seriesId, principal.user.id) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + ): TachiyomiReadProgressDto { + principal.user.checkContentRestriction(seriesId) + + return readProgressDtoRepository.findProgressBySeries(seriesId, principal.user.id) + } @GetMapping("v2/series/{seriesId}/read-progress/tachiyomi") fun getReadProgressTachiyomiV2( @PathVariable seriesId: String, @AuthenticationPrincipal principal: KomgaPrincipal, - ): TachiyomiReadProgressV2Dto = - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - return readProgressDtoRepository.findProgressV2BySeries(seriesId, principal.user.id) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + ): TachiyomiReadProgressV2Dto { + principal.user.checkContentRestriction(seriesId) + + return readProgressDtoRepository.findProgressV2BySeries(seriesId, principal.user.id) + } @Deprecated("Use v2 for proper handling of chapter number with numberSort") @PutMapping("v1/series/{seriesId}/read-progress/tachiyomi") @@ -607,9 +599,7 @@ class SeriesController( @Valid @RequestBody readProgress: TachiyomiReadProgressUpdateDto, @AuthenticationPrincipal principal: KomgaPrincipal, ) { - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(seriesId) bookDtoRepository.findAll( BookSearchWithReadProgress(seriesIds = listOf(seriesId)), @@ -629,9 +619,7 @@ class SeriesController( @RequestBody readProgress: TachiyomiReadProgressUpdateV2Dto, @AuthenticationPrincipal principal: KomgaPrincipal, ) { - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(seriesId) bookDtoRepository.findAll( BookSearchWithReadProgress(seriesIds = listOf(seriesId)), @@ -650,9 +638,7 @@ class SeriesController( @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable seriesId: String, ): ResponseEntity { - seriesRepository.getLibraryId(seriesId)?.let { - if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + principal.user.checkContentRestriction(seriesId) val books = bookRepository.findAllBySeriesId(seriesId) @@ -700,4 +686,21 @@ class SeriesController( priority = HIGHEST_PRIORITY, ) } + + /** + * Convenience function to check for content restriction. + * This will retrieve data from repositories if needed. + * + * @throws[ResponseStatusException] if the user cannot access the content + */ + private fun KomgaUser.checkContentRestriction(seriesId: String) { + if (!sharedAllLibraries) { + seriesRepository.getLibraryId(seriesId)?.let { + if (!canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + if (restrictions.isNotEmpty()) seriesMetadataRepository.findById(seriesId).let { + if (isContentRestricted(ageRating = it.ageRating)) throw ResponseStatusException(HttpStatus.FORBIDDEN) + } + } } diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/model/KomgaUserTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/model/KomgaUserTest.kt new file mode 100644 index 000000000..1e9dfb906 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/domain/model/KomgaUserTest.kt @@ -0,0 +1,73 @@ +package org.gotson.komga.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.ContentRestriction.AgeRestriction +import org.gotson.komga.domain.model.ContentRestriction.LabelsRestriction +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class KomgaUserTest { + + val defaultUser = KomgaUser("user@example.org", "aPassword", false) + + @Nested + inner class ContentRestriction { + + @Test + fun `given user with age AllowOnlyUnder restriction when checking for content restriction then it is accurate`() { + val user = defaultUser.copy(restrictions = setOf(AgeRestriction.AllowOnlyUnder(5))) + + assertThat(user.isContentRestricted(ageRating = 3)).isFalse + assertThat(user.isContentRestricted(ageRating = 5)).isFalse + assertThat(user.isContentRestricted(ageRating = 8)).isTrue + assertThat(user.isContentRestricted(ageRating = null)).isTrue + } + + @Test + fun `given user with age ExcludeOver restriction when checking for content restriction then it is accurate`() { + val user = defaultUser.copy(restrictions = setOf(AgeRestriction.ExcludeOver(16))) + + assertThat(user.isContentRestricted(ageRating = 10)).isFalse + assertThat(user.isContentRestricted(ageRating = null)).isFalse + assertThat(user.isContentRestricted(ageRating = 16)).isTrue + assertThat(user.isContentRestricted(ageRating = 18)).isTrue + } + + @Test + fun `given user with sharing label AllowOnly restriction when checking for content restriction then it is accurate`() { + val user = defaultUser.copy(restrictions = setOf(LabelsRestriction.AllowOnly(setOf("allow", "this")))) + + assertThat(user.isContentRestricted(sharingLabels = setOf("allow"))).isFalse + assertThat(user.isContentRestricted(sharingLabels = setOf("this"))).isFalse + assertThat(user.isContentRestricted(sharingLabels = setOf("allow", "this"))).isFalse + assertThat(user.isContentRestricted(sharingLabels = setOf("other"))).isTrue + assertThat(user.isContentRestricted(sharingLabels = emptySet())).isTrue + } + + @Test + fun `given user with sharing label Exclude restriction when checking for content restriction then it is accurate`() { + val user = defaultUser.copy(restrictions = setOf(LabelsRestriction.Exclude(setOf("exclude", "this")))) + + assertThat(user.isContentRestricted(sharingLabels = emptySet())).isFalse + assertThat(user.isContentRestricted(sharingLabels = setOf("allow"))).isFalse + assertThat(user.isContentRestricted(sharingLabels = setOf("other", "this"))).isTrue + assertThat(user.isContentRestricted(sharingLabels = setOf("this"))).isTrue + } + + @Test + fun `given user with both sharing label AllowOnly and Exclude restriction when checking for content restriction then it is accurate`() { + val user = defaultUser.copy( + restrictions = setOf( + LabelsRestriction.AllowOnly(setOf("allow", "both")), + LabelsRestriction.Exclude(setOf("exclude", "both")), + ) + ) + + assertThat(user.isContentRestricted(sharingLabels = setOf("allow"))).isFalse + assertThat(user.isContentRestricted(sharingLabels = setOf("allow", "other"))).isFalse + assertThat(user.isContentRestricted(sharingLabels = setOf("allow", "both"))).isTrue + assertThat(user.isContentRestricted(sharingLabels = setOf("exclude"))).isTrue + assertThat(user.isContentRestricted(sharingLabels = emptySet())).isTrue + } + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt index ee310658b..8b93c479d 100644 --- a/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/LibraryContentLifecycleTest.kt @@ -1628,8 +1628,8 @@ class LibraryContentLifecycleTest( libraryContentLifecycle.emptyTrash(library) // then - val collections = collectionRepository.findAll(null, Pageable.unpaged()) - val readLists = readListRepository.findAll(null, Pageable.unpaged()) + val collections = collectionRepository.findAll(pageable = Pageable.unpaged()) + val readLists = readListRepository.findAll(pageable = Pageable.unpaged()) assertThat(collections.content).isEmpty() assertThat(readLists.content).isEmpty() diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDaoTest.kt index 21dd6d974..c6e8e56fd 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDaoTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/ReadListDaoTest.kt @@ -180,11 +180,11 @@ class ReadListDaoTest( ) // when - val foundLibrary1Filtered = readListDao.findAllByLibraryIds(listOf(library.id), listOf(library.id), pageable = Pageable.unpaged()).content - val foundLibrary1Unfiltered = readListDao.findAllByLibraryIds(listOf(library.id), null, pageable = Pageable.unpaged()).content - val foundLibrary2Filtered = readListDao.findAllByLibraryIds(listOf(library2.id), listOf(library2.id), pageable = Pageable.unpaged()).content - val foundLibrary2Unfiltered = readListDao.findAllByLibraryIds(listOf(library2.id), null, pageable = Pageable.unpaged()).content - val foundBothUnfiltered = readListDao.findAllByLibraryIds(listOf(library.id, library2.id), null, pageable = Pageable.unpaged()).content + val foundLibrary1Filtered = readListDao.findAll(listOf(library.id), listOf(library.id), pageable = Pageable.unpaged()).content + val foundLibrary1Unfiltered = readListDao.findAll(listOf(library.id), null, pageable = Pageable.unpaged()).content + val foundLibrary2Filtered = readListDao.findAll(listOf(library2.id), listOf(library2.id), pageable = Pageable.unpaged()).content + val foundLibrary2Unfiltered = readListDao.findAll(listOf(library2.id), null, pageable = Pageable.unpaged()).content + val foundBothUnfiltered = readListDao.findAll(listOf(library.id, library2.id), null, pageable = Pageable.unpaged()).content // then assertThat(foundLibrary1Filtered).hasSize(2) diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDaoTest.kt index b5ab2d5fe..752587403 100644 --- a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDaoTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesCollectionDaoTest.kt @@ -166,11 +166,11 @@ class SeriesCollectionDaoTest( ) // when - val foundLibrary1Filtered = collectionDao.findAllByLibraryIds(listOf(library.id), listOf(library.id), pageable = Pageable.unpaged()).content - val foundLibrary1Unfiltered = collectionDao.findAllByLibraryIds(listOf(library.id), null, pageable = Pageable.unpaged()).content - val foundLibrary2Filtered = collectionDao.findAllByLibraryIds(listOf(library2.id), listOf(library2.id), pageable = Pageable.unpaged()).content - val foundLibrary2Unfiltered = collectionDao.findAllByLibraryIds(listOf(library2.id), null, pageable = Pageable.unpaged()).content - val foundBothUnfiltered = collectionDao.findAllByLibraryIds(listOf(library.id, library2.id), null, pageable = Pageable.unpaged()).content + val foundLibrary1Filtered = collectionDao.findAll(listOf(library.id), listOf(library.id), pageable = Pageable.unpaged()).content + val foundLibrary1Unfiltered = collectionDao.findAll(listOf(library.id), null, pageable = Pageable.unpaged()).content + val foundLibrary2Filtered = collectionDao.findAll(listOf(library2.id), listOf(library2.id), pageable = Pageable.unpaged()).content + val foundLibrary2Unfiltered = collectionDao.findAll(listOf(library2.id), null, pageable = Pageable.unpaged()).content + val foundBothUnfiltered = collectionDao.findAll(listOf(library.id, library2.id), null, pageable = Pageable.unpaged()).content // then assertThat(foundLibrary1Filtered).hasSize(2) diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/opds/OpdsControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/opds/OpdsControllerTest.kt index b989503a9..33a579dfb 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/opds/OpdsControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/opds/OpdsControllerTest.kt @@ -105,6 +105,117 @@ class OpdsControllerTest( } } + @Nested + inner class ContentRestriction { + @Test + @WithMockCustomUser(allowAgeUnder = 10) + fun `given user only allowed content with specific age rating when getting series then only gets series that satisfies this criteria`() { + val series10 = makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val series5 = makeSeries(name = "series_5", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5)) + } + } + + val series15 = makeSeries(name = "series_15", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 15)) + } + } + + val series = makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/opds/v1.2/series/${series5.id}").andExpect { status { isOk() } } + mockMvc.get("/opds/v1.2/series/${series10.id}").andExpect { status { isOk() } } + mockMvc.get("/opds/v1.2/series/${series15.id}").andExpect { status { isForbidden() } } + mockMvc.get("/opds/v1.2/series/${series.id}").andExpect { status { isForbidden() } } + + mockMvc.get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry/id") { nodeCount(2) } + xpath("/feed/entry[1]/id") { string(series10.id) } + xpath("/feed/entry[2]/id") { string(series5.id) } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 16) + fun `given user disallowed content with specific age rating when getting series then only gets series that satisfies this criteria`() { + val series10 = makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val series18 = makeSeries(name = "series_18", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 18)) + } + } + + val series16 = makeSeries(name = "series_16", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 16)) + } + } + + val series = makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/opds/v1.2/series/${series.id}").andExpect { status { isOk() } } + mockMvc.get("/opds/v1.2/series/${series10.id}").andExpect { status { isOk() } } + mockMvc.get("/opds/v1.2/series/${series16.id}").andExpect { status { isForbidden() } } + mockMvc.get("/opds/v1.2/series/${series18.id}").andExpect { status { isForbidden() } } + + mockMvc.get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry/id") { nodeCount(2) } + xpath("/feed/entry[1]/id") { string(series10.id) } + xpath("/feed/entry[2]/id") { string(series.id) } + } + } + } + @Nested inner class SeriesSort { @Test diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerTest.kt index 45345bb79..1db6e361f 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/BookControllerTest.kt @@ -17,6 +17,7 @@ import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.KomgaUserRepository import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.service.BookLifecycle import org.gotson.komga.domain.service.KomgaUserLifecycle @@ -56,6 +57,7 @@ import kotlin.random.Random @AutoConfigureMockMvc(printOnlyOnFailure = false) class BookControllerTest( @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, @Autowired private val seriesLifecycle: SeriesLifecycle, @Autowired private val mediaRepository: MediaRepository, @Autowired private val bookMetadataRepository: BookMetadataRepository, @@ -124,6 +126,125 @@ class BookControllerTest( } } + @Nested + inner class RestrictedContent { + @Test + @WithMockCustomUser(allowAgeUnder = 10) + fun `given user only allowed content with specific age rating when getting series then only gets books that satisfies this criteria`() { + val book10 = makeBook("book_10", libraryId = library.id) + makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book10) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val book5 = makeBook("book_5", libraryId = library.id) + makeSeries(name = "series_5", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book5) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5)) + } + } + + val book15 = makeBook("book_15", libraryId = library.id) + makeSeries(name = "series_15", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book15) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 15)) + } + } + + val book = makeBook("book", libraryId = library.id) + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/books/${book5.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${book10.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${book15.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/books/${book.id}").andExpect { status { isForbidden() } } + + mockMvc.get("/api/v1/books") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value(book10.name) } + jsonPath("$.content[1].name") { value(book5.name) } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 10) + fun `given user disallowed content with specific age rating when getting series then only gets books that satisfies this criteria`() { + val book10 = makeBook("book_10", libraryId = library.id) + makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book10) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val book5 = makeBook("book_5", libraryId = library.id) + makeSeries(name = "series_5", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book5) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5)) + } + } + + val book15 = makeBook("book_15", libraryId = library.id) + makeSeries(name = "series_15", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book15) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 15)) + } + } + + val book = makeBook("book", libraryId = library.id) + makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/books/${book5.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${book.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/books/${book10.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/books/${book15.id}").andExpect { status { isForbidden() } } + + mockMvc.get("/api/v1/books") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value(book.name) } + jsonPath("$.content[1].name") { value(book5.name) } + } + } + } + @Nested inner class UserWithoutLibraryAccess { @Test diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt index 0a11c5bf6..4a1813466 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt @@ -1,5 +1,6 @@ package org.gotson.komga.interfaces.api.rest +import org.gotson.komga.domain.model.ContentRestriction import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD @@ -20,6 +21,10 @@ annotation class WithMockCustomUser( val sharedAllLibraries: Boolean = true, val sharedLibraries: Array = [], val id: String = "0", + val allowAgeUnder: Int = -1, + val excludeAgeOver: Int = -1, + val allowLabels: Array = [], + val excludeLabels: Array = [], ) class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory { @@ -35,6 +40,16 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory= 0) add(ContentRestriction.AgeRestriction.AllowOnlyUnder(customUser.allowAgeUnder)) + if (customUser.excludeAgeOver >= 0) add(ContentRestriction.AgeRestriction.ExcludeOver(customUser.excludeAgeOver)) + if (customUser.allowLabels.isNotEmpty()) { + add(ContentRestriction.LabelsRestriction.AllowOnly(customUser.allowLabels.toSet())) + } + if (customUser.excludeLabels.isNotEmpty()) { + add(ContentRestriction.LabelsRestriction.Exclude(customUser.excludeLabels.toSet())) + } + }, id = customUser.id, ), ) diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ReadListControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ReadListControllerTest.kt index 83d99a104..05f0ac860 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ReadListControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/ReadListControllerTest.kt @@ -8,6 +8,7 @@ import org.gotson.komga.domain.model.makeLibrary import org.gotson.komga.domain.model.makeSeries import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.ReadListRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.service.LibraryLifecycle import org.gotson.komga.domain.service.ReadListLifecycle import org.gotson.komga.domain.service.SeriesLifecycle @@ -39,6 +40,7 @@ class ReadListControllerTest( @Autowired private val libraryLifecycle: LibraryLifecycle, @Autowired private val libraryRepository: LibraryRepository, @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, ) { private val library1 = makeLibrary("Library1", id = "1") @@ -170,6 +172,88 @@ class ReadListControllerTest( } } + @Nested + inner class ContentRestriction { + @Test + @WithMockCustomUser(allowAgeUnder = 10) + fun `given user only allowed content with specific age rating when getting collections then only get collections that satisfies this criteria`() { + val book10 = makeBook("book_10", libraryId = library1.id) + val series10 = makeSeries(name = "series_10", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book10) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val book = makeBook("book", libraryId = library1.id) + val series = makeSeries(name = "series_no", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(book) + seriesLifecycle.addBooks(created, books) + } + } + + val rlAllowed = readListLifecycle.addReadList( + ReadList( + name = "Allowed", + bookIds = listOf(book10.id).toIndexedMap(), + ), + ) + + val rlFiltered = readListLifecycle.addReadList( + ReadList( + name = "Filtered", + bookIds = listOf(book10.id, book.id).toIndexedMap(), + ), + ) + + val rlDenied = readListLifecycle.addReadList( + ReadList( + name = "Denied", + bookIds = listOf(book.id).toIndexedMap(), + ), + ) + + mockMvc.get("/api/v1/readlists") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + + mockMvc.get("/api/v1/readlists/${rlAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(1) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc.get("/api/v1/readlists/${rlFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.bookIds.length()") { value(1) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc.get("/api/v1/readlists/${rlDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc.get("/api/v1/books/${book10.id}/readlists") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${rlAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${rlFiltered.name}')].filtered") { value(true) } + } + } + } + @Nested inner class GetBooksAndFilter { @Test diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionControllerTest.kt index 48d660091..5d57c952b 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesCollectionControllerTest.kt @@ -3,10 +3,12 @@ package org.gotson.komga.interfaces.api.rest import org.gotson.komga.domain.model.ROLE_ADMIN import org.gotson.komga.domain.model.Series import org.gotson.komga.domain.model.SeriesCollection +import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.model.makeLibrary import org.gotson.komga.domain.model.makeSeries import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.service.LibraryLifecycle import org.gotson.komga.domain.service.SeriesCollectionLifecycle import org.gotson.komga.domain.service.SeriesLifecycle @@ -37,6 +39,7 @@ class SeriesCollectionControllerTest( @Autowired private val libraryLifecycle: LibraryLifecycle, @Autowired private val libraryRepository: LibraryRepository, @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, ) { private val library1 = makeLibrary("Library1", id = "1") @@ -165,6 +168,86 @@ class SeriesCollectionControllerTest( } } + @Nested + inner class ContentRestriction { + @Test + @WithMockCustomUser(allowAgeUnder = 10) + fun `given user only allowed content with specific age rating when getting collections then only get collections that satisfies this criteria`() { + val series10 = makeSeries(name = "series_10", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val series = makeSeries(name = "series_no", libraryId = library1.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library1.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val colAllowed = collectionLifecycle.addCollection( + SeriesCollection( + name = "Allowed", + seriesIds = listOf(series10.id), + ), + ) + + val colFiltered = collectionLifecycle.addCollection( + SeriesCollection( + name = "Filtered", + seriesIds = listOf(series10.id, series.id), + ), + ) + + val colDenied = collectionLifecycle.addCollection( + SeriesCollection( + name = "Denied", + seriesIds = listOf(series.id), + ), + ) + + mockMvc.get("/api/v1/collections") + .andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(2) } + jsonPath("$.content[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$.content[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + + mockMvc.get("/api/v1/collections/${colAllowed.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(1) } + jsonPath("$.filtered") { value(false) } + } + + mockMvc.get("/api/v1/collections/${colFiltered.id}") + .andExpect { + status { isOk() } + jsonPath("$.seriesIds.length()") { value(1) } + jsonPath("$.filtered") { value(true) } + } + + mockMvc.get("/api/v1/collections/${colDenied.id}") + .andExpect { + status { isNotFound() } + } + + mockMvc.get("/api/v1/series/${series10.id}/collections") + .andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[?(@.name == '${colAllowed.name}')].filtered") { value(false) } + jsonPath("$[?(@.name == '${colFiltered.name}')].filtered") { value(true) } + } + } + } + @Nested inner class Creation { @Test diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesControllerTest.kt index b0d38ecc9..c4b5873a0 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/SeriesControllerTest.kt @@ -243,6 +243,117 @@ class SeriesControllerTest( } } + @Nested + inner class ContentRestrictedUser { + @Test + @WithMockCustomUser(allowAgeUnder = 10) + fun `given user only allowed content with specific age rating when getting series then only gets series that satisfies this criteria`() { + val series10 = makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val series5 = makeSeries(name = "series_5", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 5)) + } + } + + val series15 = makeSeries(name = "series_15", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 15)) + } + } + + val series = makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/series/${series5.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${series10.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${series15.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/series/${series.id}").andExpect { status { isForbidden() } } + + mockMvc.get("/api/v1/series") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value("series_10") } + jsonPath("$.content[1].name") { value("series_5") } + } + } + + @Test + @WithMockCustomUser(excludeAgeOver = 16) + fun `given user disallowed content with specific age rating when getting series then only gets series that satisfies this criteria`() { + val series10 = makeSeries(name = "series_10", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 10)) + } + } + + val series18 = makeSeries(name = "series_18", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 18)) + } + } + + val series16 = makeSeries(name = "series_16", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + seriesMetadataRepository.findById(series.id).let { + seriesMetadataRepository.update(it.copy(ageRating = 16)) + } + } + + val series = makeSeries(name = "series_no", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + mockMvc.get("/api/v1/series/${series.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${series10.id}").andExpect { status { isOk() } } + mockMvc.get("/api/v1/series/${series16.id}").andExpect { status { isForbidden() } } + mockMvc.get("/api/v1/series/${series18.id}").andExpect { status { isForbidden() } } + + mockMvc.get("/api/v1/series") + .andExpect { + status { isOk() } + jsonPath("$.content.length()") { value(2) } + jsonPath("$.content[0].name") { value("series_10") } + jsonPath("$.content[1].name") { value("series_no") } + } + } + } + @Nested inner class UserWithoutLibraryAccess { @Test