feat: series download

This commit is contained in:
Snd-R 2021-01-18 01:44:01 +03:00 committed by Gauthier
parent 65132ccb97
commit e44bc7b491
4 changed files with 98 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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