app/controllers/Export.scala (143 lines of code) (raw):

package controllers import auth.PanDomainAuthActions import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.dataformat.yaml.{YAMLGenerator, YAMLMapper} import com.gu.pandomainauth.PanDomainAuthSettingsRefresher import com.gu.permissions.PermissionsProvider import config.AppConfig import helpers.Loggable import logic.SnapshotApi import models.{FlexibleStack, SnapshotId} import org.apache.commons.io.FileUtils import org.eclipse.jgit.api.Git import org.eclipse.jgit.lib.PersonIdent import org.jsoup.Jsoup import play.api.libs.ws.WSClient import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents} import ujson.StringRenderer import java.io.{FileOutputStream, StringWriter} import java.nio.charset.StandardCharsets import java.nio.file.attribute.BasicFileAttributes import java.nio.file.{FileVisitResult, Files, Path, SimpleFileVisitor} import java.time.Instant import java.util.Date import java.util.zip.{ZipEntry, ZipOutputStream} import scala.concurrent.{Await, ExecutionContext} import scala.concurrent.duration._ import scala.util.Try class Export( override val controllerComponents: ControllerComponents, snapshotApi: SnapshotApi, override val config: AppConfig, override val wsClient: WSClient, override val permissions: PermissionsProvider, override val panDomainSettings: PanDomainAuthSettingsRefresher ) extends BaseController with PanDomainAuthActions with Loggable { private val timeout = 30.seconds private implicit val executionContext: ExecutionContext = controllerComponents.executionContext def exportAsGitRepo(contentId: String): Action[AnyContent] = AuthAction { val snapshotIds = config.sourceStacks.flatMap { stack => snapshotApi.listForId(stack.snapshotBucket, contentId).map(stack -> _) } if(snapshotIds.isEmpty) { NotFound(s"$contentId does not have any snapshots") } else { val dir = Files.createTempDirectory(s"export-$contentId") val repo = Git.init().setDirectory(dir.toFile).call() snapshotIds.foreach { case(stack, id @ SnapshotId(_, timestamp)) => val snapshot = getSnapshot(stack, id) val commitTime = Date.from(snapshot.lastModifiedTime) val author = new PersonIdent(new PersonIdent(snapshot.lastModifiedName, snapshot.lastModifiedEmail), commitTime) write(dir, repo, filename(stack, "metadata"), snapshot.metadata) write(dir, repo, filename(stack, "live"), snapshot.live) write(dir, repo, filename(stack, "preview"), snapshot.preview) repo.commit() .setMessage(s"Snapshot update from $timestamp") .setAuthor(author) .call() } val zip = zipFolder(contentId, dir) Ok.sendPath(zip, onClose = () => { FileUtils.deleteDirectory(dir.toFile) Files.delete(zip) }) } } private def getSnapshot(stack: FlexibleStack, id: SnapshotId): FormattedSnapshot = { Await.result(snapshotApi.getRawSnapshot(stack.snapshotBucket, id).asFuture(controllerComponents.executionContext), timeout) match { case Left(err) => throw new IllegalStateException(err.toString) case Right(None) => throw new IllegalStateException(s"Missing snapshot for $stack $id") case Right(Some(snapshot)) => FormattedSnapshot(snapshot) } } private def zipFolder(contentId: String, folder: Path): Path = { val zipPath = Files.createTempFile(s"export-zip-$contentId", ".zip") val fileOut = new FileOutputStream(zipPath.toFile) val zipOut = new ZipOutputStream(fileOut) Files.walkFileTree(folder, new SimpleFileVisitor[Path] { override def visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult = { zipOut.putNextEntry(new ZipEntry(folder.relativize(file).toString)) Files.copy(file, zipOut) zipOut.closeEntry() FileVisitResult.CONTINUE } override def preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult = { zipOut.putNextEntry(new ZipEntry(s"${folder.relativize(dir)}/")) zipOut.closeEntry() FileVisitResult.CONTINUE } }) zipOut.close() fileOut.close() zipPath } private def write(dir: Path, repo: Git, filename: String, contents: String) = { Files.write(dir.resolve(filename), contents.getBytes(StandardCharsets.UTF_8)) repo.add().addFilepattern(filename).call() } private def filename(stack: FlexibleStack, part: String) = { s"${stack.stage}:${stack.stack}.$part.yaml" } } case class FormattedSnapshot(lastModifiedTime: Instant, lastModifiedEmail: String, lastModifiedName: String, metadata: String, preview: String, live: String) object FormattedSnapshot { private val jsonMapper = new ObjectMapper() private val yamlMapper = new YAMLMapper() .configure(YAMLGenerator.Feature.MINIMIZE_QUOTES, true) def apply(rawSnapshot: String): FormattedSnapshot = { val json = ujson.read(rawSnapshot) val preview = json("preview") val live = json("live") json.obj.remove("preview") json.obj.remove("live") val formattedPreview = format(preview) val formattedLive = format(live) val formattedMeta = format(json) val contentChangeDetails = json("contentChangeDetails")("lastModified") val lastModifiedTime = Instant.ofEpochMilli(contentChangeDetails("date").num.toLong) val email = Try(contentChangeDetails("user")("email").str).getOrElse("unknown") val name = Try(s"${contentChangeDetails("user")("firstName").str} ${contentChangeDetails("user")("lastName").str}").getOrElse("unknown") FormattedSnapshot(lastModifiedTime, email, name, formattedMeta, formattedPreview, formattedLive) } private def format(obj: ujson.Js.Value): String = { val formatted = ujson.transform(obj, new FormattedHTMLRenderer()).toString // YAML doesn't like spaces before newlines. Two backslashes because .replace takes a regex which is not suprising AT ALL val oddYamlHacks = formatted.replace(" \\n", "\\n") yamlMapper.writeValueAsString(jsonMapper.readTree(oddYamlHacks)) } } class FormattedHTMLRenderer extends StringRenderer { override def visitString(s: CharSequence, index: Int): StringWriter = { if(s.length() > 0 && s.charAt(0) == '<') { val doc = Jsoup.parse(s.toString) doc.outputSettings().prettyPrint(true) val formatted = doc.body().html() super.visitString(formatted, index) } else { super.visitString(s, index) } } }