added permanent artifact links for latest green builds: /branches/<branch-key>/... serves the latest green build's artifacts (green-only, resolved per request), keepLatestGreen retention protection, permanent artifact-index page, and latestGreenUrl links in the branches view
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
83e70e73c3
commit
bb06c8b731
@@ -1,15 +1,19 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import de.hoennig.gittally.build.ArtifactStore
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.springframework.core.io.FileSystemResource
|
||||
import org.springframework.core.io.Resource
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.MediaTypeFactory
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption
|
||||
import java.nio.file.Path
|
||||
@@ -21,6 +25,7 @@ import java.nio.file.Path
|
||||
@RestController
|
||||
class ArtifactFileController(
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val branchPermalinks: BranchPermalinks,
|
||||
) {
|
||||
@GetMapping("/artifacts/{artifactKey}/{*path}")
|
||||
fun serve(
|
||||
@@ -30,17 +35,77 @@ class ArtifactFileController(
|
||||
val artifactDir =
|
||||
artifactStore.artifactDir(artifactKey)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
val relativePath = path.removePrefix("/")
|
||||
val file =
|
||||
resolveFile(artifactDir, path.removePrefix("/"))
|
||||
?: return ResponseEntity.notFound().build()
|
||||
return respond(file, noStore = file.extension() in NO_CACHE_EXTENSIONS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanent artifact URLs: serves the file from the branch's latest green build,
|
||||
* so the URL outlives artifact pruning as long as the branch stays green-buildable.
|
||||
* Directory paths serve their `index.html` (after a redirect adding the trailing
|
||||
* slash, so relative links inside reports resolve correctly), and everything is
|
||||
* `no-store` because the content behind a URL changes with every new green build.
|
||||
*/
|
||||
@GetMapping("/branches/{branchKey}/{*path}")
|
||||
fun serveLatestGreen(
|
||||
@PathVariable branchKey: String,
|
||||
@PathVariable path: String,
|
||||
request: HttpServletRequest,
|
||||
): ResponseEntity<Resource> {
|
||||
val build = branchPermalinks.latestGreenBuild(branchKey)
|
||||
val artifactDir =
|
||||
artifactStore.artifactDir(build.artifactKey)
|
||||
?: throw ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND,
|
||||
"the artifacts of build '${build.artifactKey}' are not stored anymore",
|
||||
)
|
||||
val relativePath = path.removePrefix("/").removeSuffix("/")
|
||||
if (relativePath.isBlank()) {
|
||||
return ResponseEntity.notFound().build()
|
||||
// the bare permanent URL is the artifact-index page rendered by the UI controller
|
||||
return redirect(request.requestURI.trimEnd('/'))
|
||||
}
|
||||
val target = artifactDir.resolve(relativePath).normalize()
|
||||
if (target.startsWith(artifactDir) &&
|
||||
Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS) &&
|
||||
Files.isRegularFile(target.resolve(INDEX_FILE), LinkOption.NOFOLLOW_LINKS)
|
||||
) {
|
||||
// relative links inside a report only resolve correctly under a trailing-slash URL
|
||||
return if (request.requestURI.endsWith("/")) {
|
||||
respond(target.resolve(INDEX_FILE), noStore = true)
|
||||
} else {
|
||||
redirect(request.requestURI + "/")
|
||||
}
|
||||
}
|
||||
val file =
|
||||
resolveFile(artifactDir, relativePath)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
return respond(file, noStore = true)
|
||||
}
|
||||
|
||||
/** Resolves [relativePath] inside [artifactDir]; null when it escapes the directory or is no regular file. */
|
||||
private fun resolveFile(
|
||||
artifactDir: Path,
|
||||
relativePath: String,
|
||||
): Path? {
|
||||
if (relativePath.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val file = artifactDir.resolve(relativePath).normalize()
|
||||
if (!file.startsWith(artifactDir) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return ResponseEntity.notFound().build()
|
||||
return null
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun respond(
|
||||
file: Path,
|
||||
noStore: Boolean,
|
||||
): ResponseEntity<Resource> {
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = mediaType(file)
|
||||
if (file.extension() in NO_CACHE_EXTENSIONS) {
|
||||
if (noStore) {
|
||||
headers.cacheControl = "no-store, max-age=0"
|
||||
headers.pragma = "no-cache"
|
||||
headers.expires = 0
|
||||
@@ -48,6 +113,14 @@ class ArtifactFileController(
|
||||
return ResponseEntity.ok().headers(headers).body(FileSystemResource(file))
|
||||
}
|
||||
|
||||
/** A permanent-URL redirect must never be cached — its target changes with the next green build. */
|
||||
private fun redirect(encodedLocation: String): ResponseEntity<Resource> {
|
||||
val headers = HttpHeaders()
|
||||
headers.location = URI.create(encodedLocation)
|
||||
headers.cacheControl = "no-store, max-age=0"
|
||||
return ResponseEntity.status(HttpStatus.FOUND).headers(headers).build()
|
||||
}
|
||||
|
||||
private fun mediaType(file: Path): MediaType =
|
||||
when (file.extension()) {
|
||||
"log" -> MediaType(MediaType.TEXT_PLAIN, Charsets.UTF_8)
|
||||
@@ -61,5 +134,6 @@ class ArtifactFileController(
|
||||
|
||||
companion object {
|
||||
private val NO_CACHE_EXTENSIONS = setOf("html", "json", "log")
|
||||
private const val INDEX_FILE = "index.html"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user