mirror of
https://github.com/gotson/komga.git
synced 2026-05-07 12:01:40 +02:00
feat: series download
This commit is contained in:
parent
65132ccb97
commit
e44bc7b491
4 changed files with 98 additions and 0 deletions
|
|
@ -22,6 +22,9 @@
|
||||||
<v-list-item @click="markUnread" v-if="!isUnread">
|
<v-list-item @click="markUnread" v-if="!isUnread">
|
||||||
<v-list-item-title>{{ $t('menu.mark_unread') }}</v-list-item-title>
|
<v-list-item-title>{{ $t('menu.mark_unread') }}</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
<v-list-item v-if="canDownload" :href="fileUrl">
|
||||||
|
<v-list-item-title>Download series</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -30,6 +33,7 @@
|
||||||
import {SERIES_CHANGED, seriesToEventSeriesChanged} from '@/types/events'
|
import {SERIES_CHANGED, seriesToEventSeriesChanged} from '@/types/events'
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import {SeriesDto} from "@/types/komga-series";
|
import {SeriesDto} from "@/types/komga-series";
|
||||||
|
import {seriesFileUrl} from "@/functions/urls";
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
name: 'SeriesActionsMenu',
|
name: 'SeriesActionsMenu',
|
||||||
|
|
@ -57,6 +61,12 @@ export default Vue.extend({
|
||||||
isAdmin (): boolean {
|
isAdmin (): boolean {
|
||||||
return this.$store.getters.meAdmin
|
return this.$store.getters.meAdmin
|
||||||
},
|
},
|
||||||
|
canDownload (): boolean {
|
||||||
|
return this.$store.getters.meFileDownload
|
||||||
|
},
|
||||||
|
fileUrl (): string {
|
||||||
|
return seriesFileUrl(this.series.id)
|
||||||
|
},
|
||||||
isRead (): boolean {
|
isRead (): boolean {
|
||||||
return this.series.booksReadCount === this.series.booksCount
|
return this.series.booksReadCount === this.series.booksCount
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ export function bookPageThumbnailUrl (bookId: string, page: number): string {
|
||||||
return `${urls.originNoSlash}/api/v1/books/${bookId}/pages/${page}/thumbnail`
|
return `${urls.originNoSlash}/api/v1/books/${bookId}/pages/${page}/thumbnail`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function seriesFileUrl (seriesId: string): string {
|
||||||
|
return `${urls.originNoSlash}/api/v1/series/${seriesId}/file`
|
||||||
|
}
|
||||||
|
|
||||||
export function seriesThumbnailUrl (seriesId: string): string {
|
export function seriesThumbnailUrl (seriesId: string): string {
|
||||||
return `${urls.originNoSlash}/api/v1/series/${seriesId}/thumbnail`
|
return `${urls.originNoSlash}/api/v1/series/${seriesId}/thumbnail`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@ import io.swagger.v3.oas.annotations.media.Content
|
||||||
import io.swagger.v3.oas.annotations.media.Schema
|
import io.swagger.v3.oas.annotations.media.Schema
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
|
import org.apache.commons.io.IOUtils
|
||||||
import org.gotson.komga.application.tasks.TaskReceiver
|
import org.gotson.komga.application.tasks.TaskReceiver
|
||||||
import org.gotson.komga.domain.model.BookSearchWithReadProgress
|
import org.gotson.komga.domain.model.BookSearchWithReadProgress
|
||||||
import org.gotson.komga.domain.model.Media
|
import org.gotson.komga.domain.model.Media
|
||||||
import org.gotson.komga.domain.model.ROLE_ADMIN
|
import org.gotson.komga.domain.model.ROLE_ADMIN
|
||||||
|
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
|
||||||
import org.gotson.komga.domain.model.ReadStatus
|
import org.gotson.komga.domain.model.ReadStatus
|
||||||
import org.gotson.komga.domain.model.SeriesMetadata
|
import org.gotson.komga.domain.model.SeriesMetadata
|
||||||
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
|
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
|
||||||
|
|
@ -31,12 +33,16 @@ import org.gotson.komga.interfaces.rest.dto.restrictUrl
|
||||||
import org.gotson.komga.interfaces.rest.dto.toDto
|
import org.gotson.komga.interfaces.rest.dto.toDto
|
||||||
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
|
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
|
||||||
import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository
|
import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository
|
||||||
|
import org.springframework.core.io.FileSystemResource
|
||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.data.domain.Sort
|
import org.springframework.data.domain.Sort
|
||||||
|
import org.springframework.http.ContentDisposition
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
|
@ -50,6 +56,10 @@ import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
import javax.validation.Valid
|
import javax.validation.Valid
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
@ -356,4 +366,46 @@ class SeriesController(
|
||||||
bookLifecycle.deleteReadProgress(it, principal.user)
|
bookLifecycle.deleteReadProgress(it, principal.user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("{seriesId}/file", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
|
||||||
|
@PreAuthorize("hasRole('$ROLE_FILE_DOWNLOAD')")
|
||||||
|
fun getSeriesFile(
|
||||||
|
@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)
|
||||||
|
|
||||||
|
val books = bookRepository.findBySeriesId(seriesId)
|
||||||
|
|
||||||
|
val streamingResponse = StreamingResponseBody { responseStream: OutputStream ->
|
||||||
|
ZipOutputStream(responseStream).use { zipStream ->
|
||||||
|
zipStream.setLevel(0)
|
||||||
|
books.forEach { book ->
|
||||||
|
val file = FileSystemResource(book.path())
|
||||||
|
if (!file.exists()) {
|
||||||
|
logger.warn { "Book file not found, skipping archive entry: ${file.path}" }
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug { "Adding file to zip archive: ${file.path}" }
|
||||||
|
file.inputStream.use {
|
||||||
|
zipStream.putNextEntry(ZipEntry(file.filename))
|
||||||
|
IOUtils.copyLarge(it, zipStream, ByteArray(8192))
|
||||||
|
zipStream.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.headers(HttpHeaders().apply {
|
||||||
|
contentDisposition = ContentDisposition.builder("attachment")
|
||||||
|
.filename(seriesMetadataRepository.findById(seriesId).title + ".zip")
|
||||||
|
.build()
|
||||||
|
})
|
||||||
|
.contentType(MediaType.parseMediaType("application/zip"))
|
||||||
|
.body(streamingResponse)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,38 @@ class SeriesControllerTest(
|
||||||
mockMvc.get("/api/v1/series/${createdSeries.id}/books")
|
mockMvc.get("/api/v1/series/${createdSeries.id}/books")
|
||||||
.andExpect { status { isForbidden() } }
|
.andExpect { status { isForbidden() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = [])
|
||||||
|
fun `given user with no access to any library when getting specific series file then returns forbidden`() {
|
||||||
|
val createdSeries = makeSeries(name = "series", libraryId = library.id).let { series ->
|
||||||
|
seriesLifecycle.createSeries(series).also { created ->
|
||||||
|
val books = listOf(makeBook("1", libraryId = library.id))
|
||||||
|
seriesLifecycle.addBooks(created, books)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/series/${createdSeries.id}/file")
|
||||||
|
.andExpect { status { isForbidden() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
inner class RestrictedUserByRole {
|
||||||
|
@Test
|
||||||
|
@WithMockCustomUser(roles = [])
|
||||||
|
fun `given user without file download role when getting specific series file then returns forbidden`() {
|
||||||
|
val createdSeries = makeSeries(name = "series", libraryId = library.id).let { series ->
|
||||||
|
seriesLifecycle.createSeries(series).also { created ->
|
||||||
|
val books = listOf(makeBook("1", libraryId = library.id))
|
||||||
|
seriesLifecycle.addBooks(created, books)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.get("/api/v1/series/${createdSeries.id}/file")
|
||||||
|
.andExpect { status { isForbidden() } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue