From e44bc7b4915e8d8d55ad3c89c6148bdaba33278f Mon Sep 17 00:00:00 2001
From: Snd-R <76580768+Snd-R@users.noreply.github.com>
Date: Mon, 18 Jan 2021 01:44:01 +0300
Subject: [PATCH] feat: series download
---
.../components/menus/SeriesActionsMenu.vue | 10 ++++
komga-webui/src/functions/urls.ts | 4 ++
.../komga/interfaces/rest/SeriesController.kt | 52 +++++++++++++++++++
.../interfaces/rest/SeriesControllerTest.kt | 32 ++++++++++++
4 files changed, 98 insertions(+)
diff --git a/komga-webui/src/components/menus/SeriesActionsMenu.vue b/komga-webui/src/components/menus/SeriesActionsMenu.vue
index db3488fef..b6a422592 100644
--- a/komga-webui/src/components/menus/SeriesActionsMenu.vue
+++ b/komga-webui/src/components/menus/SeriesActionsMenu.vue
@@ -22,6 +22,9 @@
{{ $t('menu.mark_unread') }}
+
+ Download series
+
@@ -30,6 +33,7 @@
import {SERIES_CHANGED, seriesToEventSeriesChanged} from '@/types/events'
import Vue from 'vue'
import {SeriesDto} from "@/types/komga-series";
+import {seriesFileUrl} from "@/functions/urls";
export default Vue.extend({
name: 'SeriesActionsMenu',
@@ -57,6 +61,12 @@ export default Vue.extend({
isAdmin (): boolean {
return this.$store.getters.meAdmin
},
+ canDownload (): boolean {
+ return this.$store.getters.meFileDownload
+ },
+ fileUrl (): string {
+ return seriesFileUrl(this.series.id)
+ },
isRead (): boolean {
return this.series.booksReadCount === this.series.booksCount
},
diff --git a/komga-webui/src/functions/urls.ts b/komga-webui/src/functions/urls.ts
index 2788f4fab..849fc498f 100644
--- a/komga-webui/src/functions/urls.ts
+++ b/komga-webui/src/functions/urls.ts
@@ -32,6 +32,10 @@ export function bookPageThumbnailUrl (bookId: string, page: number): string {
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 {
return `${urls.originNoSlash}/api/v1/series/${seriesId}/thumbnail`
}
diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt
index 26f8ca962..1cdbe55de 100644
--- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt
+++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt
@@ -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.responses.ApiResponse
import mu.KotlinLogging
+import org.apache.commons.io.IOUtils
import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.Media
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.SeriesMetadata
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.persistence.BookDtoRepository
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.PageRequest
import org.springframework.data.domain.Pageable
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.MediaType
+import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
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.RestController
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
private val logger = KotlinLogging.logger {}
@@ -356,4 +366,46 @@ class SeriesController(
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 {
+ 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)
+ }
}
diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt
index de45e01da..eb93a9ede 100644
--- a/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt
+++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/rest/SeriesControllerTest.kt
@@ -253,6 +253,38 @@ class SeriesControllerTest(
mockMvc.get("/api/v1/series/${createdSeries.id}/books")
.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