diff --git a/modules/common/src/main/scala/docspell/common/Duration.scala b/modules/common/src/main/scala/docspell/common/Duration.scala index 1c290c95..74081c76 100644 --- a/modules/common/src/main/scala/docspell/common/Duration.scala +++ b/modules/common/src/main/scala/docspell/common/Duration.scala @@ -40,6 +40,7 @@ case class Duration(nanos: Long) { } object Duration { + val zero = Duration(0L) def apply(d: SDur): Duration = Duration(d.toNanos) diff --git a/modules/common/src/main/scala/docspell/common/EnvMode.scala b/modules/common/src/main/scala/docspell/common/EnvMode.scala new file mode 100644 index 00000000..d3bdfcf5 --- /dev/null +++ b/modules/common/src/main/scala/docspell/common/EnvMode.scala @@ -0,0 +1,49 @@ +package docspell.common + +import cats.implicits._ + +sealed trait EnvMode { self: Product => + + val name: String = + productPrefix.toLowerCase + + def isDev: Boolean + def isProd: Boolean +} + +object EnvMode { + private val sysProp = "docspell.env" + private val envName = "DOCSPELL_ENV" + + case object Dev extends EnvMode { + val isDev = true + val isProd = false + } + case object Prod extends EnvMode { + val isDev = false + val isProd = true + } + + def dev: EnvMode = Dev + def prod: EnvMode = Prod + + def fromString(s: String): Either[String, EnvMode] = + s.toLowerCase match { + case s if s.startsWith("dev") => Right(Dev) + case s if s.startsWith("prod") => Right(Prod) + case _ => Left(s"Invalid env mode: $s") + } + + def read: Either[String, Option[EnvMode]] = { + def normalize(str: String): Option[String] = + Option(str).map(_.trim).filter(_.nonEmpty) + + normalize(System.getProperty(sysProp)) + .orElse(normalize(System.getenv(envName))) + .traverse(fromString) + } + + lazy val current: EnvMode = + read.toOption.flatten.getOrElse(prod) + +} diff --git a/modules/joex/src/main/scala/docspell/joex/Main.scala b/modules/joex/src/main/scala/docspell/joex/Main.scala index 589fb3ae..ae5854ab 100644 --- a/modules/joex/src/main/scala/docspell/joex/Main.scala +++ b/modules/joex/src/main/scala/docspell/joex/Main.scala @@ -5,7 +5,7 @@ import java.nio.file.{Files, Paths} import cats.effect._ import cats.implicits._ -import docspell.common.{Banner, Pools, ThreadFactories} +import docspell.common._ import org.log4s._ @@ -50,6 +50,10 @@ object Main extends IOApp { Some(cfg.fullTextSearch.solr.url).filter(_ => cfg.fullTextSearch.enabled) ) logger.info(s"\n${banner.render("***>")}") + if (EnvMode.current.isDev) { + logger.warn(">>>>> Docspell is running in DEV mode! <<<<<") + } + val pools = for { cec <- connectEC bec <- blockingEC diff --git a/modules/restserver/src/main/scala/docspell/restserver/Main.scala b/modules/restserver/src/main/scala/docspell/restserver/Main.scala index 5c089b06..b52f1f27 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/Main.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/Main.scala @@ -5,7 +5,7 @@ import java.nio.file.{Files, Paths} import cats.effect._ import cats.implicits._ -import docspell.common.{Banner, Pools, ThreadFactories} +import docspell.common._ import org.log4s._ @@ -57,6 +57,10 @@ object Main extends IOApp { } yield Pools(cec, bec, blocker, rec) logger.info(s"\n${banner.render("***>")}") + if (EnvMode.current.isDev) { + logger.warn(">>>>> Docspell is running in DEV mode! <<<<<") + } + pools.use(p => RestServer .stream[IO](cfg, p) diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 557bed9e..7fc0cdfb 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -5,7 +5,8 @@ import cats.implicits._ import fs2.Stream import docspell.backend.auth.AuthToken -import docspell.common.Pools +import docspell.common._ +import docspell.restserver.http4s.EnvMiddleware import docspell.restserver.routes._ import docspell.restserver.webapp._ @@ -39,9 +40,9 @@ object RestServer { adminRoutes(cfg, restApp) }, "/api/doc" -> templates.doc, - "/app/assets" -> WebjarRoutes.appRoutes[F](pools.blocker), - "/app" -> templates.app, - "/sw.js" -> templates.serviceWorker, + "/app/assets" -> EnvMiddleware(WebjarRoutes.appRoutes[F](pools.blocker)), + "/app" -> EnvMiddleware(templates.app), + "/sw.js" -> EnvMiddleware(templates.serviceWorker), "/" -> redirectTo("/app") ).orNotFound diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/EnvMiddleware.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/EnvMiddleware.scala new file mode 100644 index 00000000..cc52dc25 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/EnvMiddleware.scala @@ -0,0 +1,13 @@ +package docspell.restserver.http4s + +import cats.Functor + +import docspell.common._ + +import org.http4s._ + +object EnvMiddleware { + + def apply[F[_]: Functor](in: HttpRoutes[F]): HttpRoutes[F] = + NoCacheMiddleware.route(EnvMode.current.isDev)(in) +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/NoCacheMiddleware.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/NoCacheMiddleware.scala new file mode 100644 index 00000000..58360186 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/NoCacheMiddleware.scala @@ -0,0 +1,30 @@ +package docspell.restserver.http4s + +import cats.Functor +import cats.data.Kleisli +import cats.data.NonEmptyList + +import docspell.common._ + +import org.http4s._ +import org.http4s.headers._ + +object NoCacheMiddleware { + private val noCacheHeader: Header = + `Cache-Control`( + NonEmptyList.of( + CacheDirective.`max-age`(Duration.zero.toScala), + CacheDirective.`no-store` + ) + ) + + def apply[F[_]: Functor, G[_], A]( + noCache: Boolean + )(in: Kleisli[F, A, Response[F]]): Kleisli[F, A, Response[F]] = + if (noCache) in.map(_.putHeaders(noCacheHeader)) + else in + + def route[F[_]: Functor](noCache: Boolean)(in: HttpRoutes[F]): HttpRoutes[F] = + if (noCache) in.map(_.putHeaders(noCacheHeader)) + else in +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala b/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala index 3966445e..bcd041d3 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala @@ -18,11 +18,12 @@ case class Flags( fullTextSearchEnabled: Boolean, maxPageSize: Int, maxNoteLength: Int, - showClassificationSettings: Boolean + showClassificationSettings: Boolean, + uiVersion: Int ) object Flags { - def apply(cfg: Config): Flags = + def apply(cfg: Config, uiVersion: Int): Flags = Flags( cfg.appName, getBaseUrl(cfg), @@ -32,7 +33,8 @@ object Flags { cfg.fullTextSearch.enabled, cfg.maxItemPageSize, cfg.maxNoteLength, - cfg.showClassificationSettings + cfg.showClassificationSettings, + uiVersion ) private def getBaseUrl(cfg: Config): String = diff --git a/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala index 35c4298f..84e8ce27 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala @@ -14,6 +14,8 @@ import org.http4s.HttpRoutes import org.http4s._ import org.http4s.dsl.Http4sDsl import org.http4s.headers._ +import org.http4s.util.CaseInsensitiveString +import org.http4s.util.Writer import org.log4s._ import yamusca.implicits._ import yamusca.imports._ @@ -24,6 +26,27 @@ object TemplateRoutes { val `text/html` = new MediaType("text", "html") val `application/javascript` = new MediaType("application", "javascript") + val ui2Header = CaseInsensitiveString("Docspell-Ui2") + + case class UiVersion(version: Int) extends Header.Parsed { + val key = UiVersion + def renderValue(writer: Writer): writer.type = + writer.append(version) + } + object UiVersion extends HeaderKey.Singleton { + type HeaderT = UiVersion + val name = CaseInsensitiveString("Docspell-Ui") + override def parse(s: String): ParseResult[UiVersion] = + Either + .catchNonFatal(s.trim.toInt) + .leftMap(ex => ParseFailure("Invalid int header", ex.getMessage)) + .map(UiVersion.apply) + + override def matchHeader(h: Header): Option[UiVersion] = + if (h.name == name) parse(h.value).toOption + else None + } + trait InnerRoutes[F[_]] { def doc: HttpRoutes[F] def app: HttpRoutes[F] @@ -53,22 +76,24 @@ object TemplateRoutes { } yield resp } def app = - HttpRoutes.of[F] { case GET -> _ => + HttpRoutes.of[F] { case req @ GET -> _ => for { templ <- indexTemplate + uiv = req.headers.get(UiVersion).map(_.version).getOrElse(1) resp <- Ok( - IndexData(cfg).render(templ), + IndexData(cfg, uiv).render(templ), `Content-Type`(`text/html`, Charset.`UTF-8`) ) } yield resp } def serviceWorker = - HttpRoutes.of[F] { case GET -> _ => + HttpRoutes.of[F] { case req @ GET -> _ => for { templ <- swTemplate + uiv = req.headers.get(UiVersion).map(_.version).getOrElse(1) resp <- Ok( - IndexData(cfg).render(templ), + IndexData(cfg, uiv).render(templ), `Content-Type`(`application/javascript`, Charset.`UTF-8`) ) } yield resp @@ -134,22 +159,28 @@ object TemplateRoutes { object IndexData { - def apply(cfg: Config): IndexData = + def apply(cfg: Config, uiVersion: Int): IndexData = IndexData( - Flags(cfg), - Seq( - "/app/assets" + Webjars.fomanticslimdefault + "/semantic.min.css", - s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.css" - ), + Flags(cfg, uiVersion), + chooseUi(uiVersion), Seq( "/app/assets" + Webjars.clipboardjs + "/clipboard.min.js", s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell-app.js" ), s"/app/assets/docspell-webapp/${BuildInfo.version}/favicon", s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.js", - Flags(cfg).asJson.spaces2 + Flags(cfg, uiVersion).asJson.spaces2 ) + private def chooseUi(uiVersion: Int): Seq[String] = + if (uiVersion == 2) + Seq(s"/app/assets/docspell-webapp/${BuildInfo.version}/css/styles.css") + else + Seq( + "/app/assets" + Webjars.fomanticslimdefault + "/semantic.min.css", + s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.css" + ) + implicit def yamuscaValueConverter: ValueConverter[IndexData] = ValueConverter.deriveConverter[IndexData] } diff --git a/modules/restserver/src/main/scala/docspell/restserver/webapp/WebjarRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/webapp/WebjarRoutes.scala index 218855ff..e1c02ea3 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/webapp/WebjarRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/webapp/WebjarRoutes.scala @@ -4,10 +4,9 @@ import cats.data.Kleisli import cats.data.OptionT import cats.effect._ -import org.http4s.HttpRoutes -import org.http4s.Method -import org.http4s.Response -import org.http4s.StaticFile +import docspell.common._ + +import org.http4s._ object WebjarRoutes { @@ -37,12 +36,13 @@ object WebjarRoutes { if (p.contains("..") || !suffixes.exists(p.endsWith(_))) OptionT.pure(Response.notFound[F]) else - StaticFile.fromResource( - s"/META-INF/resources/webjars$p", - blocker, - Some(req), - true - ) + StaticFile + .fromResource( + s"/META-INF/resources/webjars$p", + blocker, + Some(req), + EnvMode.current.isProd + ) case _ => OptionT.none } diff --git a/modules/restserver/src/main/templates/index.html b/modules/restserver/src/main/templates/index.html index bdb1cd42..59c7ef59 100644 --- a/modules/restserver/src/main/templates/index.html +++ b/modules/restserver/src/main/templates/index.html @@ -33,9 +33,8 @@ -
-