Skip to content

Commit

Permalink
handle the HTTP cache properly for dynamic resources: thumbnails and …
Browse files Browse the repository at this point in the history
…pages (closes #27)
  • Loading branch information
gotson committed Dec 24, 2019
1 parent 9df0352 commit 971467b
Show file tree
Hide file tree
Showing 3 changed files with 83 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.context.request.WebRequest
import org.springframework.web.server.ResponseStatusException
import java.io.File
import java.io.FileNotFoundException
import java.nio.file.NoSuchFileException
import java.time.ZoneOffset
import java.util.concurrent.TimeUnit

private val logger = KotlinLogging.logger {}
Expand Down Expand Up @@ -131,26 +133,32 @@ class BookController(
@GetMapping(value = ["api/v1/series/{seriesId}/books/{bookId}/thumbnail"], produces = [MediaType.IMAGE_PNG_VALUE])
fun getBookThumbnailFromSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
request: WebRequest,
@PathVariable seriesId: Long,
@PathVariable bookId: Long
): ResponseEntity<ByteArray> = getBookThumbnail(principal, bookId)
): ResponseEntity<ByteArray> = getBookThumbnail(principal, request, bookId)

@GetMapping(value = [
"api/v1/books/{bookId}/thumbnail",
"opds/v1.2/books/{bookId}/thumbnail"
], produces = [MediaType.IMAGE_PNG_VALUE])
fun getBookThumbnail(
@AuthenticationPrincipal principal: KomgaPrincipal,
request: WebRequest,
@PathVariable bookId: Long
): ResponseEntity<ByteArray> =
bookRepository.findByIdOrNull(bookId)?.let {
if (!principal.user.canAccessBook(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
if (it.metadata.thumbnail != null) {
bookRepository.findByIdOrNull(bookId)?.let { book ->
if (request.checkNotModified(getBookLastModified(book))) {
return@let ResponseEntity
.status(HttpStatus.NOT_MODIFIED)
.setNotModified(book)
.body(ByteArray(0))
}
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
if (book.metadata.thumbnail != null) {
ResponseEntity.ok()
.cacheControl(CacheControl
.maxAge(4, TimeUnit.HOURS)
.cachePrivate())
.body(it.metadata.thumbnail)
.setNotModified(book)
.body(book.metadata.thumbnail)
} else throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)

Expand Down Expand Up @@ -219,25 +227,33 @@ class BookController(
@GetMapping("api/v1/series/{seriesId}/books/{bookId}/pages/{pageNumber}")
fun getBookPageFromSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
request: WebRequest,
@PathVariable seriesId: Long,
@PathVariable bookId: Long,
@PathVariable pageNumber: Int,
@RequestParam(value = "convert", required = false) convertTo: String?,
@RequestParam(value = "zero_based", defaultValue = "false") zeroBasedIndex: Boolean
): ResponseEntity<ByteArray> = getBookPage(principal, bookId, pageNumber, convertTo, zeroBasedIndex)
): ResponseEntity<ByteArray> = getBookPage(principal, request, bookId, pageNumber, convertTo, zeroBasedIndex)

@GetMapping(value = [
"api/v1/books/{bookId}/pages/{pageNumber}",
"opds/v1.2/books/{bookId}/pages/{pageNumber}"
])
fun getBookPage(
@AuthenticationPrincipal principal: KomgaPrincipal,
request: WebRequest,
@PathVariable bookId: Long,
@PathVariable pageNumber: Int,
@RequestParam(value = "convert", required = false) convertTo: String?,
@RequestParam(value = "zero_based", defaultValue = "false") zeroBasedIndex: Boolean
): ResponseEntity<ByteArray> =
bookRepository.findByIdOrNull((bookId))?.let { book ->
if (request.checkNotModified(getBookLastModified(book))) {
return@let ResponseEntity
.status(HttpStatus.NOT_MODIFIED)
.setNotModified(book)
.body(ByteArray(0))
}
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
try {
val convertFormat = when (convertTo?.toLowerCase()) {
Expand All @@ -253,6 +269,7 @@ class BookController(

ResponseEntity.ok()
.contentType(getMediaTypeOrDefault(pageContent.mediaType))
.setNotModified(book)
.body(pageContent.content)
} catch (ex: IndexOutOfBoundsException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist")
Expand All @@ -264,6 +281,15 @@ class BookController(
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)

private fun ResponseEntity.BodyBuilder.setNotModified(book: Book) =
this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS)
.cachePrivate()
.mustRevalidate()
).lastModified(getBookLastModified(book))

private fun getBookLastModified(book: Book) =
book.fileLastModified.toInstant(ZoneOffset.UTC).toEpochMilli()


private fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType {
mediaTypeString?.let {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.CacheControl
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
Expand All @@ -25,8 +24,8 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.context.request.WebRequest
import org.springframework.web.server.ResponseStatusException
import java.util.concurrent.TimeUnit

private val logger = KotlinLogging.logger {}

Expand All @@ -35,7 +34,8 @@ private val logger = KotlinLogging.logger {}
class SeriesController(
private val seriesRepository: SeriesRepository,
private val libraryRepository: LibraryRepository,
private val bookRepository: BookRepository
private val bookRepository: BookRepository,
private val bookController: BookController
) {

@GetMapping
Expand Down Expand Up @@ -149,19 +149,15 @@ class SeriesController(
@GetMapping(value = ["{seriesId}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE])
fun getSeriesThumbnail(
@AuthenticationPrincipal principal: KomgaPrincipal,
request: WebRequest,
@PathVariable(name = "seriesId") id: Long
): ResponseEntity<ByteArray> =
seriesRepository.findByIdOrNull(id)?.let { series ->
if (!principal.user.canAccessSeries(series)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)

val thumbnail = series.books.minBy { it.number }?.metadata?.thumbnail
if (thumbnail != null) {
ResponseEntity.ok()
.cacheControl(CacheControl
.maxAge(4, TimeUnit.HOURS)
.cachePrivate())
.body(thumbnail)
} else throw ResponseStatusException(HttpStatus.NOT_FOUND)
series.books.minBy { it.number }?.let { firstBook ->
bookController.getBookThumbnail(principal, request, firstBook.id)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)

@GetMapping("{seriesId}/books")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.MockMvcResultMatchersDsl
import org.springframework.test.web.servlet.get
import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.sql.DataSource

@ExtendWith(SpringExtension::class)
Expand Down Expand Up @@ -340,4 +342,43 @@ class BookControllerTest(
}
}
}

@Nested
inner class HttpCache {
@Test
@WithMockCustomUser
fun `given request with If-Modified-Since headers when getting thumbnail then returns 304 not modified`() {
val series = makeSeries(
name = "series",
books = listOf(makeBook("1.cbr"))
).also { it.library = library }
seriesRepository.save(series)

mockMvc.get("/api/v1/books/${series.books.first().id}/thumbnail") {
headers {
ifModifiedSince = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli()
}
}.andExpect {
status { isNotModified }
}
}

@Test
@WithMockCustomUser
fun `given request with If-Modified-Since headers when getting page then returns 304 not modified`() {
val series = makeSeries(
name = "series",
books = listOf(makeBook("1.cbr"))
).also { it.library = library }
seriesRepository.save(series)

mockMvc.get("/api/v1/books/${series.books.first().id}/pages/1") {
headers {
ifModifiedSince = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli()
}
}.andExpect {
status { isNotModified }
}
}
}
}

0 comments on commit 971467b

Please sign in to comment.