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