feat(api): restrict content according to user's restrictions

This commit is contained in:
Gauthier Roebroeck 2022-02-23 16:43:42 +08:00
parent 2621500666
commit b0d6314ec9
27 changed files with 983 additions and 341 deletions

View File

@ -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<String>) : 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<String>) : 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<String>) : LabelsRestriction(labels)
}
}

View File

@ -22,6 +22,7 @@ data class KomgaUser(
val rolePageStreaming: Boolean = true,
val sharedLibrariesIds: Set<String> = emptySet(),
val sharedAllLibraries: Boolean = true,
val restrictions: Set<ContentRestriction> = 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<String> = 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)"
}

View File

@ -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<ReadList>
/**
* Find one ReadList by [readListId],
* optionally with only bookIds filtered by the provided [filterOnLibraryIds] it not null.
*/
fun findByIdOrNull(readListId: String, filterOnLibraryIds: Collection<String>? = null, restrictions: Set<ContentRestriction> = 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<String>?): ReadList?
fun findAll(belongsToLibraryIds: Collection<String>? = null, filterOnLibraryIds: Collection<String>? = null, search: String? = null, pageable: Pageable, restrictions: Set<ContentRestriction> = emptySet()): Page<ReadList>
/**
* 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<String>, filterOnLibraryIds: Collection<String>?, search: String? = null, pageable: Pageable): Page<ReadList>
/**
* Find all ReadList that contains the provided containsBookId,
* optionally with only bookIds filtered by the provided filterOnLibraryIds.
*/
fun findAllContainingBookId(containsBookId: String, filterOnLibraryIds: Collection<String>?): Collection<ReadList>
fun findAllContainingBookId(containsBookId: String, filterOnLibraryIds: Collection<String>?, restrictions: Set<ContentRestriction> = emptySet()): Collection<ReadList>
fun findAllEmpty(): Collection<ReadList>

View File

@ -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<SeriesCollection>
/**
* Find one SeriesCollection by [collectionId],
* optionally with only seriesId filtered by the provided [filterOnLibraryIds] if not null.
*/
fun findByIdOrNull(collectionId: String, filterOnLibraryIds: Collection<String>? = null, restrictions: Set<ContentRestriction> = 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<String>?): SeriesCollection?
fun findAll(belongsToLibraryIds: Collection<String>? = null, filterOnLibraryIds: Collection<String>? = null, search: String? = null, pageable: Pageable, restrictions: Set<ContentRestriction> = emptySet()): Page<SeriesCollection>
/**
* 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<String>, filterOnLibraryIds: Collection<String>?, search: String? = null, pageable: Pageable): Page<SeriesCollection>
/**
* Find all SeriesCollection that contains the provided containsSeriesId,
* optionally with only seriesId filtered by the provided filterOnLibraryIds.
*/
fun findAllContainingSeriesId(containsSeriesId: String, filterOnLibraryIds: Collection<String>?): Collection<SeriesCollection>
fun findAllContainingSeriesId(containsSeriesId: String, filterOnLibraryIds: Collection<String>?, restrictions: Set<ContentRestriction> = emptySet()): Collection<SeriesCollection>
fun findAllEmpty(): Collection<SeriesCollection>

View File

@ -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<BookDto> {
val conditions = search.toCondition()
override fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable, restrictions: Set<ContentRestriction>): Page<BookDto> {
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<String>?, pageable: Pageable): Page<BookDto> {
override fun findAllOnDeck(userId: String, filterOnLibraryIds: Collection<String>?, pageable: Pageable, restrictions: Set<ContentRestriction>): Page<BookDto> {
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())))

View File

@ -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<String>?): ReadList? =
selectBase()
override fun findByIdOrNull(readListId: String, filterOnLibraryIds: Collection<String>?, restrictions: Set<ContentRestriction>): 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<ReadList> {
override fun findAll(belongsToLibraryIds: Collection<String>?, filterOnLibraryIds: Collection<String>?, search: String?, pageable: Pageable, restrictions: Set<ContentRestriction>): Page<ReadList> {
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<String>, filterOnLibraryIds: Collection<String>?, search: String?, pageable: Pageable): Page<ReadList> {
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<String>?): Collection<ReadList> {
val ids = dsl.select(rl.ID)
override fun findAllContainingBookId(containsBookId: String, filterOnLibraryIds: Collection<String>?, restrictions: Set<ContentRestriction>): Collection<ReadList> {
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<ReadList> =
@ -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<Record>.fetchAndMap(filterOnLibraryIds: Collection<String>?): List<ReadList> =
private fun ResultQuery<Record>.fetchAndMap(filterOnLibraryIds: Collection<String>?, restrictions: Set<ContentRestriction> = emptySet()): List<ReadList> =
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 }

View File

@ -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<String>?): SeriesCollection? =
selectBase()
override fun findByIdOrNull(collectionId: String, filterOnLibraryIds: Collection<String>?, restrictions: Set<ContentRestriction>): 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<SeriesCollection> {
override fun findAll(belongsToLibraryIds: Collection<String>?, filterOnLibraryIds: Collection<String>?, search: String?, pageable: Pageable, restrictions: Set<ContentRestriction>): Page<SeriesCollection> {
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<String>, filterOnLibraryIds: Collection<String>?, search: String?, pageable: Pageable): Page<SeriesCollection> {
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<String>?): Collection<SeriesCollection> {
val ids = dsl.select(c.ID)
override fun findAllContainingSeriesId(containsSeriesId: String, filterOnLibraryIds: Collection<String>?, restrictions: Set<ContentRestriction>): Collection<SeriesCollection> {
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<SeriesCollection> =
@ -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<Record>.fetchAndMap(filterOnLibraryIds: Collection<String>?): List<SeriesCollection> =
private fun ResultQuery<Record>.fetchAndMap(filterOnLibraryIds: Collection<String>?, restrictions: Set<ContentRestriction> = emptySet()): List<SeriesCollection> =
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 }

View File

@ -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<SeriesDto> {
val conditions = search.toCondition()
override fun findAll(search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable, restrictions: Set<ContentRestriction>): Page<SeriesDto> {
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<ContentRestriction>,
): Page<SeriesDto> {
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<ContentRestriction>,
pageable: Pageable,
): Page<SeriesDto> {
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<GroupCountDto> {
val conditions = search.toCondition()
override fun countByFirstCharacter(search: SeriesSearchWithReadProgress, userId: String, restrictions: Set<ContentRestriction>): List<GroupCountDto> {
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)

View File

@ -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<String>.sortByValues(values: List<String>, asc: Boolean = true): Field
return c.otherwise(Int.MAX_VALUE)
}
fun Field<String>.inOrNoCondition(list: List<String>?): Condition =
fun Field<String>.inOrNoCondition(list: Collection<String>?): Condition =
when {
list == null -> DSL.noCondition()
list.isEmpty() -> DSL.falseCondition()
@ -63,3 +64,16 @@ fun DSLContext.insertTempStrings(batchSize: Int, collection: Collection<String>)
}
fun DSLContext.selectTempStrings() = this.select(Tables.TEMP_STRING_LIST.STRING).from(Tables.TEMP_STRING_LIST)
fun Set<ContentRestriction>.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()
},
)
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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<BookDto>
fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable, restrictions: Set<ContentRestriction> = emptySet()): Page<BookDto>
/**
* Find books that are part of a readlist, optionally filtered by library
@ -38,7 +39,7 @@ interface BookDtoRepository {
filterOnLibraryIds: Collection<String>?,
): BookDto?
fun findAllOnDeck(userId: String, filterOnLibraryIds: Collection<String>?, pageable: Pageable): Page<BookDto>
fun findAllOnDeck(userId: String, filterOnLibraryIds: Collection<String>?, pageable: Pageable, restrictions: Set<ContentRestriction> = emptySet()): Page<BookDto>
fun findAllDuplicates(userId: String, pageable: Pageable): Page<BookDto>
}

View File

@ -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<SeriesDto>
fun findAllByCollectionId(collectionId: String, search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable): Page<SeriesDto>
fun findAllRecentlyUpdated(search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable): Page<SeriesDto>
fun findAll(search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable, restrictions: Set<ContentRestriction> = emptySet()): Page<SeriesDto>
fun findAllByCollectionId(collectionId: String, search: SeriesSearchWithReadProgress, userId: String, pageable: Pageable, restrictions: Set<ContentRestriction> = emptySet()): Page<SeriesDto>
fun findAllRecentlyUpdated(search: SeriesSearchWithReadProgress, userId: String, restrictions: Set<ContentRestriction>, pageable: Pageable): Page<SeriesDto>
fun countByFirstCharacter(search: SeriesSearchWithReadProgress, userId: String): List<GroupCountDto>
fun countByFirstCharacter(search: SeriesSearchWithReadProgress, userId: String, restrictions: Set<ContentRestriction>): List<GroupCountDto>
}

View File

@ -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<ReadListDto> {
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<ThumbnailBookDto> {
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<StreamingResponseBody> =
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<PageDto> =
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)
}
}

View File

@ -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<ByteArray> {
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<ThumbnailReadListDto> {
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)

View File

@ -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<ByteArray> {
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<ThumbnailSeriesCollectionDto> {
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<Author>?,
@Parameter(hidden = true) page: Pageable,
): Page<SeriesDto> =
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)
}

View File

@ -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<SeriesThumbnailDto> {
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<Author>?,
@Parameter(hidden = true) page: Pageable,
): Page<BookDto> {
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<CollectionDto> {
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<StreamingResponseBody> {
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)
}
}
}

View File

@ -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
}
}
}

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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<String> = [],
val id: String = "0",
val allowAgeUnder: Int = -1,
val excludeAgeOver: Int = -1,
val allowLabels: Array<String> = [],
val excludeLabels: Array<String> = [],
)
class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> {
@ -35,6 +40,16 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<With
rolePageStreaming = customUser.roles.contains(ROLE_PAGE_STREAMING),
sharedAllLibraries = customUser.sharedAllLibraries,
sharedLibrariesIds = customUser.sharedLibraries.toSet(),
restrictions = buildSet {
if (customUser.allowAgeUnder >= 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,
),
)

View File

@ -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

View File

@ -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

View File

@ -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