mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-21 09:58:26 +00:00
Initial application stub
This commit is contained in:
3
modules/joex/src/main/resources/reference.conf
Normal file
3
modules/joex/src/main/resources/reference.conf
Normal file
@ -0,0 +1,3 @@
|
||||
docspell.joex {
|
||||
|
||||
}
|
18
modules/joex/src/main/scala/docspell/joex/Config.scala
Normal file
18
modules/joex/src/main/scala/docspell/joex/Config.scala
Normal file
@ -0,0 +1,18 @@
|
||||
package docspell.joex
|
||||
|
||||
import docspell.store.JdbcConfig
|
||||
|
||||
case class Config(id: String
|
||||
, bind: Config.Bind
|
||||
, jdbc: JdbcConfig
|
||||
)
|
||||
|
||||
object Config {
|
||||
|
||||
|
||||
val default: Config =
|
||||
Config("testid", Config.Bind("localhost", 7878), JdbcConfig("", "", ""))
|
||||
|
||||
|
||||
case class Bind(address: String, port: Int)
|
||||
}
|
26
modules/joex/src/main/scala/docspell/joex/InfoRoutes.scala
Normal file
26
modules/joex/src/main/scala/docspell/joex/InfoRoutes.scala
Normal file
@ -0,0 +1,26 @@
|
||||
package docspell.joex
|
||||
|
||||
import cats.effect._
|
||||
import org.http4s._
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
|
||||
import docspell.joexapi.model._
|
||||
import docspell.joex.BuildInfo
|
||||
|
||||
object InfoRoutes {
|
||||
|
||||
def apply[F[_]: Sync](cfg: Config): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F]{}
|
||||
import dsl._
|
||||
HttpRoutes.of[F] {
|
||||
case GET -> (Root / "version") =>
|
||||
Ok(VersionInfo(BuildInfo.version
|
||||
, BuildInfo.builtAtMillis
|
||||
, BuildInfo.builtAtString
|
||||
, BuildInfo.gitHeadCommit.getOrElse("")
|
||||
, BuildInfo.gitDescribedVersion.getOrElse("")))
|
||||
}
|
||||
}
|
||||
}
|
6
modules/joex/src/main/scala/docspell/joex/JoexApp.scala
Normal file
6
modules/joex/src/main/scala/docspell/joex/JoexApp.scala
Normal file
@ -0,0 +1,6 @@
|
||||
package docspell.joex
|
||||
|
||||
trait JoexApp[F[_]] {
|
||||
|
||||
def init: F[Unit]
|
||||
}
|
16
modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala
Normal file
16
modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala
Normal file
@ -0,0 +1,16 @@
|
||||
package docspell.joex
|
||||
|
||||
import cats.effect._
|
||||
|
||||
final class JoexAppImpl[F[_]: Sync](cfg: Config) extends JoexApp[F] {
|
||||
|
||||
def init: F[Unit] =
|
||||
Sync[F].pure(())
|
||||
|
||||
}
|
||||
|
||||
object JoexAppImpl {
|
||||
|
||||
def create[F[_]: Sync](cfg: Config): Resource[F, JoexApp[F]] =
|
||||
Resource.liftF(Sync[F].pure(new JoexAppImpl(cfg)))
|
||||
}
|
38
modules/joex/src/main/scala/docspell/joex/JoexServer.scala
Normal file
38
modules/joex/src/main/scala/docspell/joex/JoexServer.scala
Normal file
@ -0,0 +1,38 @@
|
||||
package docspell.joex
|
||||
|
||||
import cats.effect._
|
||||
import org.http4s.server.blaze.BlazeServerBuilder
|
||||
import org.http4s.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import org.http4s.server.middleware.Logger
|
||||
import org.http4s.server.Router
|
||||
|
||||
object JoexServer {
|
||||
|
||||
def stream[F[_]: ConcurrentEffect](cfg: Config)
|
||||
(implicit T: Timer[F]): Stream[F, Nothing] = {
|
||||
|
||||
val app = for {
|
||||
joexApp <- JoexAppImpl.create[F](cfg)
|
||||
_ <- Resource.liftF(joexApp.init)
|
||||
|
||||
httpApp = Router(
|
||||
"/api/info" -> InfoRoutes(cfg)
|
||||
).orNotFound
|
||||
|
||||
// With Middlewares in place
|
||||
finalHttpApp = Logger.httpApp(false, false)(httpApp)
|
||||
|
||||
} yield finalHttpApp
|
||||
|
||||
|
||||
Stream.resource(app).flatMap(httpApp =>
|
||||
BlazeServerBuilder[F]
|
||||
.bindHttp(cfg.bind.port, cfg.bind.address)
|
||||
.withHttpApp(httpApp)
|
||||
.serve
|
||||
)
|
||||
|
||||
}.drain
|
||||
}
|
38
modules/joex/src/main/scala/docspell/joex/Main.scala
Normal file
38
modules/joex/src/main/scala/docspell/joex/Main.scala
Normal file
@ -0,0 +1,38 @@
|
||||
package docspell.joex
|
||||
|
||||
import cats.effect.{ExitCode, IO, IOApp}
|
||||
import cats.implicits._
|
||||
import scala.concurrent.ExecutionContext
|
||||
import java.util.concurrent.Executors
|
||||
import java.nio.file.{Files, Paths}
|
||||
import org.log4s._
|
||||
|
||||
object Main extends IOApp {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
val blockingEc: ExecutionContext = ExecutionContext.fromExecutor(Executors.newCachedThreadPool)
|
||||
|
||||
def run(args: List[String]) = {
|
||||
args match {
|
||||
case file :: Nil =>
|
||||
val path = Paths.get(file).toAbsolutePath.normalize
|
||||
logger.info(s"Using given config file: $path")
|
||||
System.setProperty("config.file", file)
|
||||
case _ =>
|
||||
Option(System.getProperty("config.file")) match {
|
||||
case Some(f) if f.nonEmpty =>
|
||||
val path = Paths.get(f).toAbsolutePath.normalize
|
||||
if (!Files.exists(path)) {
|
||||
logger.info(s"Not using config file '$f' because it doesn't exist")
|
||||
System.clearProperty("config.file")
|
||||
} else {
|
||||
logger.info(s"Using config file from system properties: $f")
|
||||
}
|
||||
case _ =>
|
||||
}
|
||||
}
|
||||
|
||||
val cfg = Config.default
|
||||
JoexServer.stream[IO](cfg).compile.drain.as(ExitCode.Success)
|
||||
}
|
||||
}
|
35
modules/joexapi/src/main/resources/joex-openapi.yml
Normal file
35
modules/joexapi/src/main/resources/joex-openapi.yml
Normal file
@ -0,0 +1,35 @@
|
||||
openapi: 3.0.0
|
||||
|
||||
info:
|
||||
title: Docspell JOEX
|
||||
version: 0.1.0-SNAPSHOT
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
description: Current host
|
||||
|
||||
paths:
|
||||
|
||||
components:
|
||||
schemas:
|
||||
VersionInfo:
|
||||
description: |
|
||||
Information about the software.
|
||||
required:
|
||||
- version
|
||||
- builtAtMillis
|
||||
- builtAtString
|
||||
- gitCommit
|
||||
- gitVersion
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
builtAtMillis:
|
||||
type: integer
|
||||
format: int64
|
||||
builtAtString:
|
||||
type: string
|
||||
gitCommit:
|
||||
type: string
|
||||
gitVersion:
|
||||
type: string
|
127
modules/restapi/src/main/resources/docspell-openapi.yml
Normal file
127
modules/restapi/src/main/resources/docspell-openapi.yml
Normal file
@ -0,0 +1,127 @@
|
||||
openapi: 3.0.0
|
||||
|
||||
info:
|
||||
title: Docspell
|
||||
version: 0.1.0-SNAPSHOT
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
description: Current host
|
||||
|
||||
paths:
|
||||
/open/auth/login:
|
||||
post:
|
||||
summary: Authenticate with account name and password.
|
||||
description: |
|
||||
Authenticate with account name and password. The account name
|
||||
is comprised of the collective id and user id separated by
|
||||
slash, backslash or whitespace.
|
||||
|
||||
If successful, an authentication token is returned that can be
|
||||
used for subsequent calls to protected routes.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserPass"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthResult"
|
||||
/sec/auth/session:
|
||||
post:
|
||||
summary: Authentication with a token
|
||||
description: |
|
||||
Authenticate with a token. This can be used to get a new
|
||||
authentication token based on another valid one.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthResult"
|
||||
/sec/auth/logout:
|
||||
post:
|
||||
summary: Logout.
|
||||
description: |
|
||||
This route informs the server about a logout. This is not
|
||||
strictly necessary.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
|
||||
components:
|
||||
schemas:
|
||||
UserPass:
|
||||
description: |
|
||||
Account name and password.
|
||||
required:
|
||||
- account
|
||||
- password
|
||||
properties:
|
||||
account:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
AuthResult:
|
||||
description: |
|
||||
The response to a authentication request.
|
||||
required:
|
||||
- collective
|
||||
- user
|
||||
- success
|
||||
- message
|
||||
- validMs
|
||||
properties:
|
||||
collective:
|
||||
type: string
|
||||
user:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
token:
|
||||
description: |
|
||||
The authentication token that should be used for
|
||||
subsequent requests to secured endpoints.
|
||||
type: string
|
||||
validMs:
|
||||
description: |
|
||||
How long the token is valid in ms.
|
||||
type: integer
|
||||
format: int64
|
||||
VersionInfo:
|
||||
description: |
|
||||
Information about the software.
|
||||
required:
|
||||
- version
|
||||
- builtAtMillis
|
||||
- builtAtString
|
||||
- gitCommit
|
||||
- gitVersion
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
builtAtMillis:
|
||||
type: integer
|
||||
format: int64
|
||||
builtAtString:
|
||||
type: string
|
||||
gitCommit:
|
||||
type: string
|
||||
gitVersion:
|
||||
type: string
|
||||
securitySchemes:
|
||||
authTokenHeader:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Docspell-Auth
|
14
modules/restserver/src/main/resources/logback.xml
Normal file
14
modules/restserver/src/main/resources/logback.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<withJansi>true</withJansi>
|
||||
|
||||
<encoder>
|
||||
<pattern>[%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="docspell" level="debug" />
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
</configuration>
|
3
modules/restserver/src/main/resources/reference.conf
Normal file
3
modules/restserver/src/main/resources/reference.conf
Normal file
@ -0,0 +1,3 @@
|
||||
docspell.restserver {
|
||||
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package docspell.restserver
|
||||
|
||||
import docspell.store.JdbcConfig
|
||||
|
||||
case class Config(appName: String
|
||||
, baseUrl: String
|
||||
, bind: Config.Bind
|
||||
, jdbc: JdbcConfig
|
||||
)
|
||||
|
||||
object Config {
|
||||
|
||||
|
||||
val default: Config =
|
||||
Config("Docspell", "http://localhost:7880", Config.Bind("localhost", 7880), JdbcConfig("", "", ""))
|
||||
|
||||
|
||||
case class Bind(address: String, port: Int)
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package docspell.restserver
|
||||
|
||||
import cats.effect._
|
||||
import org.http4s._
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.BuildInfo
|
||||
|
||||
object InfoRoutes {
|
||||
|
||||
def apply[F[_]: Sync](cfg: Config): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F]{}
|
||||
import dsl._
|
||||
HttpRoutes.of[F] {
|
||||
case GET -> (Root / "version") =>
|
||||
Ok(VersionInfo(BuildInfo.version
|
||||
, BuildInfo.builtAtMillis
|
||||
, BuildInfo.builtAtString
|
||||
, BuildInfo.gitHeadCommit.getOrElse("")
|
||||
, BuildInfo.gitDescribedVersion.getOrElse("")))
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package docspell.restserver
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import scala.concurrent.ExecutionContext
|
||||
import java.util.concurrent.Executors
|
||||
import java.nio.file.{Files, Paths}
|
||||
import org.log4s._
|
||||
|
||||
object Main extends IOApp {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
val blockingEc: ExecutionContext = ExecutionContext.fromExecutor(Executors.newCachedThreadPool)
|
||||
val blocker = Blocker.liftExecutionContext(blockingEc)
|
||||
|
||||
def run(args: List[String]) = {
|
||||
args match {
|
||||
case file :: Nil =>
|
||||
val path = Paths.get(file).toAbsolutePath.normalize
|
||||
logger.info(s"Using given config file: $path")
|
||||
System.setProperty("config.file", file)
|
||||
case _ =>
|
||||
Option(System.getProperty("config.file")) match {
|
||||
case Some(f) if f.nonEmpty =>
|
||||
val path = Paths.get(f).toAbsolutePath.normalize
|
||||
if (!Files.exists(path)) {
|
||||
logger.info(s"Not using config file '$f' because it doesn't exist")
|
||||
System.clearProperty("config.file")
|
||||
} else {
|
||||
logger.info(s"Using config file from system properties: $f")
|
||||
}
|
||||
case _ =>
|
||||
}
|
||||
}
|
||||
|
||||
val cfg = Config.default
|
||||
RestServer.stream[IO](cfg, blocker).compile.drain.as(ExitCode.Success)
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package docspell.restserver
|
||||
|
||||
trait RestApp[F[_]] {
|
||||
|
||||
def init: F[Unit]
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package docspell.restserver
|
||||
|
||||
import cats.effect._
|
||||
|
||||
final class RestAppImpl[F[_]: Sync](cfg: Config) extends RestApp[F] {
|
||||
|
||||
def init: F[Unit] =
|
||||
Sync[F].pure(())
|
||||
|
||||
}
|
||||
|
||||
object RestAppImpl {
|
||||
|
||||
def create[F[_]: Sync](cfg: Config): Resource[F, RestApp[F]] =
|
||||
Resource.liftF(Sync[F].pure(new RestAppImpl(cfg)))
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
package docspell.restserver
|
||||
|
||||
import cats.effect._
|
||||
import org.http4s.server.blaze.BlazeServerBuilder
|
||||
import org.http4s.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import org.http4s.server.middleware.Logger
|
||||
import org.http4s.server.Router
|
||||
|
||||
import docspell.restserver.webapp._
|
||||
|
||||
object RestServer {
|
||||
|
||||
def stream[F[_]: ConcurrentEffect](cfg: Config, blocker: Blocker)
|
||||
(implicit T: Timer[F], CS: ContextShift[F]): Stream[F, Nothing] = {
|
||||
|
||||
val app = for {
|
||||
restApp <- RestAppImpl.create[F](cfg)
|
||||
_ <- Resource.liftF(restApp.init)
|
||||
|
||||
httpApp = Router(
|
||||
"/api/info" -> InfoRoutes(cfg),
|
||||
"/app/assets" -> WebjarRoutes.appRoutes[F](blocker, cfg),
|
||||
"/app" -> TemplateRoutes[F](blocker, cfg)
|
||||
).orNotFound
|
||||
|
||||
// With Middlewares in place
|
||||
finalHttpApp = Logger.httpApp(false, false)(httpApp)
|
||||
|
||||
} yield finalHttpApp
|
||||
|
||||
|
||||
Stream.resource(app).flatMap(httpApp =>
|
||||
BlazeServerBuilder[F]
|
||||
.bindHttp(cfg.bind.port, cfg.bind.address)
|
||||
.withHttpApp(httpApp)
|
||||
.serve
|
||||
)
|
||||
|
||||
}.drain
|
||||
}
|
@ -0,0 +1,139 @@
|
||||
package docspell.restserver.webapp
|
||||
|
||||
import fs2._
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import org.http4s._
|
||||
import org.http4s.headers._
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.slf4j._
|
||||
import _root_.io.circe._
|
||||
import _root_.io.circe.generic.semiauto._
|
||||
import _root_.io.circe.syntax._
|
||||
import yamusca.imports._
|
||||
import yamusca.implicits._
|
||||
import java.net.URL
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
import docspell.restserver.{BuildInfo, Config}
|
||||
|
||||
object TemplateRoutes {
|
||||
private[this] val logger = LoggerFactory.getLogger(getClass)
|
||||
|
||||
val `text/html` = new MediaType("text", "html")
|
||||
|
||||
def apply[F[_]: Effect](blocker: Blocker, cfg: Config)(implicit C: ContextShift[F]): HttpRoutes[F] = {
|
||||
val indexTemplate = memo(loadResource("/index.html").flatMap(loadTemplate(_, blocker)))
|
||||
val docTemplate = memo(loadResource("/doc.html").flatMap(loadTemplate(_, blocker)))
|
||||
|
||||
val dsl = new Http4sDsl[F]{}
|
||||
import dsl._
|
||||
HttpRoutes.of[F] {
|
||||
case GET -> Root / "index.html" =>
|
||||
for {
|
||||
templ <- indexTemplate
|
||||
resp <- Ok(IndexData(cfg).render(templ), `Content-Type`(`text/html`))
|
||||
} yield resp
|
||||
case GET -> Root / "doc" =>
|
||||
for {
|
||||
templ <- docTemplate
|
||||
resp <- Ok(DocData(cfg).render(templ), `Content-Type`(`text/html`))
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
def loadResource[F[_]: Sync](name: String): F[URL] = {
|
||||
Option(getClass.getResource(name)) match {
|
||||
case None =>
|
||||
Sync[F].raiseError(new Exception("Unknown resource: "+ name))
|
||||
case Some(r) =>
|
||||
r.pure[F]
|
||||
}
|
||||
}
|
||||
|
||||
def loadUrl[F[_]: Sync](url: URL, blocker: Blocker)(implicit C: ContextShift[F]): F[String] =
|
||||
Stream.bracket(Sync[F].delay(url.openStream))(in => Sync[F].delay(in.close)).
|
||||
flatMap(in => io.readInputStream(in.pure[F], 64 * 1024, blocker, false)).
|
||||
through(text.utf8Decode).
|
||||
compile.fold("")(_ + _)
|
||||
|
||||
def parseTemplate[F[_]: Sync](str: String): F[Template] =
|
||||
Sync[F].delay {
|
||||
mustache.parse(str) match {
|
||||
case Right(t) => t
|
||||
case Left((_, err)) => sys.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
def loadTemplate[F[_]: Sync](url: URL, blocker: Blocker)(implicit C: ContextShift[F]): F[Template] = {
|
||||
loadUrl[F](url, blocker).flatMap(s => parseTemplate(s)).
|
||||
map(t => {
|
||||
logger.info(s"Compiled template $url")
|
||||
t
|
||||
})
|
||||
}
|
||||
|
||||
case class DocData(swaggerRoot: String, openapiSpec: String)
|
||||
object DocData {
|
||||
|
||||
def apply(cfg: Config): DocData =
|
||||
DocData("/app/assets" + Webjars.swaggerui, s"/app/assets/${BuildInfo.name}/${BuildInfo.version}/openapi.yml")
|
||||
|
||||
implicit def yamuscaValueConverter: ValueConverter[DocData] =
|
||||
ValueConverter.deriveConverter[DocData]
|
||||
}
|
||||
|
||||
case class Flags(appName: String, baseUrl: String)
|
||||
|
||||
object Flags {
|
||||
def apply(cfg: Config): Flags =
|
||||
Flags(cfg.appName, cfg.baseUrl)
|
||||
|
||||
implicit val jsonEncoder: Encoder[Flags] =
|
||||
deriveEncoder[Flags]
|
||||
implicit def yamuscaValueConverter: ValueConverter[Flags] =
|
||||
ValueConverter.deriveConverter[Flags]
|
||||
}
|
||||
|
||||
case class IndexData(flags: Flags
|
||||
, cssUrls: Seq[String]
|
||||
, jsUrls: Seq[String]
|
||||
, appExtraJs: String
|
||||
, flagsJson: String)
|
||||
|
||||
object IndexData {
|
||||
|
||||
def apply(cfg: Config): IndexData =
|
||||
IndexData(Flags(cfg)
|
||||
, Seq(
|
||||
"/app/assets" + Webjars.semanticui + "/semantic.min.css",
|
||||
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.css"
|
||||
)
|
||||
, Seq(
|
||||
"/app/assets" + Webjars.jquery + "/jquery.min.js",
|
||||
"/app/assets" + Webjars.semanticui + "/semantic.min.js",
|
||||
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell-app.js"
|
||||
)
|
||||
,
|
||||
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.js"
|
||||
, Flags(cfg).asJson.spaces2 )
|
||||
|
||||
implicit def yamuscaValueConverter: ValueConverter[IndexData] =
|
||||
ValueConverter.deriveConverter[IndexData]
|
||||
}
|
||||
|
||||
private def memo[F[_]: Sync, A](fa: => F[A]): F[A] = {
|
||||
val ref = new AtomicReference[A]()
|
||||
Sync[F].suspend {
|
||||
Option(ref.get) match {
|
||||
case Some(a) => a.pure[F]
|
||||
case None =>
|
||||
fa.map(a => {
|
||||
ref.set(a)
|
||||
a
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package docspell.restserver.webapp
|
||||
|
||||
import cats.effect._
|
||||
import org.http4s._
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.server.staticcontent.webjarService
|
||||
import org.http4s.server.staticcontent.NoopCacheStrategy
|
||||
import org.http4s.server.staticcontent.WebjarService.{WebjarAsset, Config => WebjarConfig}
|
||||
|
||||
import docspell.restserver.Config
|
||||
|
||||
object WebjarRoutes {
|
||||
|
||||
def appRoutes[F[_]: Effect](blocker: Blocker, cfg: Config)(implicit C: ContextShift[F]): HttpRoutes[F] = {
|
||||
webjarService(
|
||||
WebjarConfig(
|
||||
filter = assetFilter,
|
||||
blocker = blocker,
|
||||
cacheStrategy = NoopCacheStrategy[F]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def assetFilter(asset: WebjarAsset): Boolean =
|
||||
List(".js", ".css", ".html", ".jpg", ".png", ".eot", ".woff", ".woff2", ".svg", ".otf", ".ttf", ".yml").
|
||||
exists(e => asset.asset.endsWith(e))
|
||||
|
||||
}
|
60
modules/restserver/src/main/templates/doc.html
Normal file
60
modules/restserver/src/main/templates/doc.html
Normal file
@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Swagger UI</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{swaggerRoot}}/swagger-ui.css" >
|
||||
<link rel="icon" type="image/png" href="{{swaggerRoot}}/favicon-32x32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="{{swaggerRoot}}/favicon-16x16.png" sizes="16x16" />
|
||||
<style>
|
||||
html
|
||||
{
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after
|
||||
{
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body
|
||||
{
|
||||
margin:0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="{{swaggerRoot}}/swagger-ui-bundle.js"> </script>
|
||||
<script src="{{swaggerRoot}}/swagger-ui-standalone-preset.js"> </script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "{{openapiSpec}}",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
displayRequestDuration: true
|
||||
})
|
||||
// End Swagger UI call region
|
||||
|
||||
window.ui = ui
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
31
modules/restserver/src/main/templates/index.html
Normal file
31
modules/restserver/src/main/templates/index.html
Normal file
@ -0,0 +1,31 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
|
||||
<title>{{ flags.appName }}</title>
|
||||
{{# cssUrls }}
|
||||
<link rel="stylesheet" href="{{.}}"/>
|
||||
{{/ cssUrls }}
|
||||
{{# jsUrls }}
|
||||
<script type="application/javascript" src="{{.}}"></script>
|
||||
{{/ jsUrls}}
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="docspell-app">
|
||||
</div>
|
||||
|
||||
<script type="application/javascript">
|
||||
var storedAccount = localStorage.getItem('account');
|
||||
var account = storedAccount ? JSON.parse(storedAccount) : null;
|
||||
var elmFlags = {
|
||||
"account": account,
|
||||
"config": {{{flagsJson}}}
|
||||
};
|
||||
</script>
|
||||
<script type="application/javascript" src="{{appExtraJs}}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
36
modules/store/src/main/scala/docspell/store/JdbcConfig.scala
Normal file
36
modules/store/src/main/scala/docspell/store/JdbcConfig.scala
Normal file
@ -0,0 +1,36 @@
|
||||
package docspell.store
|
||||
|
||||
case class JdbcConfig(url: String
|
||||
, user: String
|
||||
, password: String
|
||||
) {
|
||||
|
||||
val dbmsName: Option[String] =
|
||||
JdbcConfig.extractDbmsName(url)
|
||||
|
||||
def driverClass =
|
||||
dbmsName match {
|
||||
case Some("mariadb") =>
|
||||
"org.mariadb.jdbc.Driver"
|
||||
case Some("postgresql") =>
|
||||
"org.postgresql.Driver"
|
||||
case Some("h2") =>
|
||||
"org.h2.Driver"
|
||||
case Some("sqlite") =>
|
||||
"org.sqlite.JDBC"
|
||||
case Some(n) =>
|
||||
sys.error(s"Unknown DBMS: $n")
|
||||
case None =>
|
||||
sys.error("No JDBC url specified")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object JdbcConfig {
|
||||
private[this] val jdbcRegex = "jdbc\\:([^\\:]+)\\:.*".r
|
||||
def extractDbmsName(jdbcUrl: String): Option[String] =
|
||||
jdbcUrl match {
|
||||
case jdbcRegex(n) => Some(n.toLowerCase)
|
||||
case _ => None
|
||||
}
|
||||
}
|
72
modules/webapp/src/main/elm/Api.elm
Normal file
72
modules/webapp/src/main/elm/Api.elm
Normal file
@ -0,0 +1,72 @@
|
||||
module Api exposing (..)
|
||||
|
||||
import Http
|
||||
import Task
|
||||
import Util.Http as Http2
|
||||
import Data.Flags exposing (Flags)
|
||||
import Api.Model.UserPass exposing (UserPass)
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
import Api.Model.VersionInfo exposing (VersionInfo)
|
||||
|
||||
login: Flags -> UserPass -> ((Result Http.Error AuthResult) -> msg) -> Cmd msg
|
||||
login flags up receive =
|
||||
Http.post
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/open/auth/login"
|
||||
, body = Http.jsonBody (Api.Model.UserPass.encode up)
|
||||
, expect = Http.expectJson receive Api.Model.AuthResult.decoder
|
||||
}
|
||||
|
||||
logout: Flags -> ((Result Http.Error ()) -> msg) -> Cmd msg
|
||||
logout flags receive =
|
||||
Http2.authPost
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/auth/logout"
|
||||
, account = getAccount flags
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever receive
|
||||
}
|
||||
|
||||
loginSession: Flags -> ((Result Http.Error AuthResult) -> msg) -> Cmd msg
|
||||
loginSession flags receive =
|
||||
Http2.authPost
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/auth/session"
|
||||
, account = getAccount flags
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson receive Api.Model.AuthResult.decoder
|
||||
}
|
||||
|
||||
versionInfo: Flags -> ((Result Http.Error VersionInfo) -> msg) -> Cmd msg
|
||||
versionInfo flags receive =
|
||||
Http.get
|
||||
{ url = flags.config.baseUrl ++ "/api/info/version"
|
||||
, expect = Http.expectJson receive Api.Model.VersionInfo.decoder
|
||||
}
|
||||
|
||||
refreshSession: Flags -> ((Result Http.Error AuthResult) -> msg) -> Cmd msg
|
||||
refreshSession flags receive =
|
||||
case flags.account of
|
||||
Just acc ->
|
||||
if acc.success && acc.validMs > 30000
|
||||
then
|
||||
let
|
||||
delay = acc.validMs - 30000 |> toFloat
|
||||
in
|
||||
Http2.executeIn delay receive (refreshSessionTask flags)
|
||||
else Cmd.none
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
refreshSessionTask: Flags -> Task.Task Http.Error AuthResult
|
||||
refreshSessionTask flags =
|
||||
Http2.authTask
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/auth/session"
|
||||
, method = "POST"
|
||||
, headers = []
|
||||
, account = getAccount flags
|
||||
, body = Http.emptyBody
|
||||
, resolver = Http2.jsonResolver Api.Model.AuthResult.decoder
|
||||
, timeout = Nothing
|
||||
}
|
||||
|
||||
getAccount: Flags -> AuthResult
|
||||
getAccount flags =
|
||||
Maybe.withDefault Api.Model.AuthResult.empty flags.account
|
45
modules/webapp/src/main/elm/App/Data.elm
Normal file
45
modules/webapp/src/main/elm/App/Data.elm
Normal file
@ -0,0 +1,45 @@
|
||||
module App.Data exposing (..)
|
||||
|
||||
import Browser exposing (UrlRequest)
|
||||
import Browser.Navigation exposing (Key)
|
||||
import Url exposing (Url)
|
||||
import Http
|
||||
import Data.Flags exposing (Flags)
|
||||
import Api.Model.VersionInfo exposing (VersionInfo)
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
import Page exposing (Page(..))
|
||||
import Page.Home.Data
|
||||
import Page.Login.Data
|
||||
|
||||
type alias Model =
|
||||
{ flags: Flags
|
||||
, key: Key
|
||||
, page: Page
|
||||
, version: VersionInfo
|
||||
, homeModel: Page.Home.Data.Model
|
||||
, loginModel: Page.Login.Data.Model
|
||||
}
|
||||
|
||||
init: Key -> Url -> Flags -> Model
|
||||
init key url flags =
|
||||
let
|
||||
page = Page.fromUrl url |> Maybe.withDefault HomePage
|
||||
in
|
||||
{ flags = flags
|
||||
, key = key
|
||||
, page = page
|
||||
, version = Api.Model.VersionInfo.empty
|
||||
, homeModel = Page.Home.Data.emptyModel
|
||||
, loginModel = Page.Login.Data.empty
|
||||
}
|
||||
|
||||
type Msg
|
||||
= NavRequest UrlRequest
|
||||
| NavChange Url
|
||||
| VersionResp (Result Http.Error VersionInfo)
|
||||
| HomeMsg Page.Home.Data.Msg
|
||||
| LoginMsg Page.Login.Data.Msg
|
||||
| Logout
|
||||
| LogoutResp (Result Http.Error ())
|
||||
| SessionCheckResp (Result Http.Error AuthResult)
|
||||
| SetPage Page
|
106
modules/webapp/src/main/elm/App/Update.elm
Normal file
106
modules/webapp/src/main/elm/App/Update.elm
Normal file
@ -0,0 +1,106 @@
|
||||
module App.Update exposing (update, initPage)
|
||||
|
||||
import Api
|
||||
import Ports
|
||||
import Browser exposing (UrlRequest(..))
|
||||
import Browser.Navigation as Nav
|
||||
import Url
|
||||
import Data.Flags
|
||||
import App.Data exposing (..)
|
||||
import Page exposing (Page(..))
|
||||
import Page.Home.Data
|
||||
import Page.Home.Update
|
||||
import Page.Login.Data
|
||||
import Page.Login.Update
|
||||
|
||||
update: Msg -> Model -> (Model, Cmd Msg)
|
||||
update msg model =
|
||||
case msg of
|
||||
HomeMsg lm ->
|
||||
updateHome lm model
|
||||
|
||||
LoginMsg lm ->
|
||||
updateLogin lm model
|
||||
|
||||
SetPage p ->
|
||||
( {model | page = p }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
VersionResp (Ok info) ->
|
||||
({model|version = info}, Cmd.none)
|
||||
|
||||
VersionResp (Err err) ->
|
||||
(model, Cmd.none)
|
||||
|
||||
Logout ->
|
||||
(model, Api.logout model.flags LogoutResp)
|
||||
LogoutResp _ ->
|
||||
({model|loginModel = Page.Login.Data.empty}, Ports.removeAccount (Page.pageToString HomePage))
|
||||
SessionCheckResp res ->
|
||||
case res of
|
||||
Ok lr ->
|
||||
let
|
||||
newFlags = Data.Flags.withAccount model.flags lr
|
||||
refresh = Api.refreshSession newFlags SessionCheckResp
|
||||
in
|
||||
if (lr.success) then ({model|flags = newFlags}, refresh)
|
||||
else (model, Ports.removeAccount (Page.pageToString LoginPage))
|
||||
Err _ -> (model, Ports.removeAccount (Page.pageToString LoginPage))
|
||||
|
||||
NavRequest req ->
|
||||
case req of
|
||||
Internal url ->
|
||||
let
|
||||
isCurrent =
|
||||
Page.fromUrl url |>
|
||||
Maybe.map (\p -> p == model.page) |>
|
||||
Maybe.withDefault True
|
||||
in
|
||||
( model
|
||||
, if isCurrent then Cmd.none else Nav.pushUrl model.key (Url.toString url)
|
||||
)
|
||||
|
||||
External url ->
|
||||
( model
|
||||
, Nav.load url
|
||||
)
|
||||
|
||||
NavChange url ->
|
||||
let
|
||||
page = Page.fromUrl url |> Maybe.withDefault HomePage
|
||||
(m, c) = initPage model page
|
||||
in
|
||||
( { m | page = page }, c )
|
||||
|
||||
|
||||
updateLogin: Page.Login.Data.Msg -> Model -> (Model, Cmd Msg)
|
||||
updateLogin lmsg model =
|
||||
let
|
||||
(lm, lc, ar) = Page.Login.Update.update model.flags lmsg model.loginModel
|
||||
newFlags = Maybe.map (Data.Flags.withAccount model.flags) ar
|
||||
|> Maybe.withDefault model.flags
|
||||
in
|
||||
({model | loginModel = lm, flags = newFlags}
|
||||
,Cmd.map LoginMsg lc
|
||||
)
|
||||
|
||||
updateHome: Page.Home.Data.Msg -> Model -> (Model, Cmd Msg)
|
||||
updateHome lmsg model =
|
||||
let
|
||||
(lm, lc) = Page.Home.Update.update model.flags lmsg model.homeModel
|
||||
in
|
||||
( {model | homeModel = lm }
|
||||
, Cmd.map HomeMsg lc
|
||||
)
|
||||
|
||||
|
||||
initPage: Model -> Page -> (Model, Cmd Msg)
|
||||
initPage model page =
|
||||
case page of
|
||||
HomePage ->
|
||||
(model, Cmd.none)
|
||||
{-- updateHome Page.Home.Data.GetBasicStats model --}
|
||||
|
||||
LoginPage ->
|
||||
(model, Cmd.none)
|
101
modules/webapp/src/main/elm/App/View.elm
Normal file
101
modules/webapp/src/main/elm/App/View.elm
Normal file
@ -0,0 +1,101 @@
|
||||
module App.View exposing (view)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (onClick)
|
||||
|
||||
import App.Data exposing (..)
|
||||
import Page exposing (Page(..))
|
||||
import Page.Home.View
|
||||
import Page.Login.View
|
||||
|
||||
view: Model -> Html Msg
|
||||
view model =
|
||||
case model.page of
|
||||
LoginPage ->
|
||||
loginLayout model
|
||||
_ ->
|
||||
defaultLayout model
|
||||
|
||||
loginLayout: Model -> Html Msg
|
||||
loginLayout model =
|
||||
div [class "login-layout"]
|
||||
[ (viewLogin model)
|
||||
, (footer model)
|
||||
]
|
||||
|
||||
defaultLayout: Model -> Html Msg
|
||||
defaultLayout model =
|
||||
div [class "default-layout"]
|
||||
[ div [class "ui fixed top sticky attached large menu black-bg"]
|
||||
[div [class "ui fluid container"]
|
||||
[ a [class "header item narrow-item"
|
||||
,Page.href HomePage
|
||||
]
|
||||
[i [classList [("lemon outline icon", True)
|
||||
]]
|
||||
[]
|
||||
,text model.flags.config.appName]
|
||||
, (loginInfo model)
|
||||
]
|
||||
]
|
||||
, div [ class "ui fluid container main-content" ]
|
||||
[ (case model.page of
|
||||
HomePage ->
|
||||
viewHome model
|
||||
LoginPage ->
|
||||
viewLogin model
|
||||
)
|
||||
]
|
||||
, (footer model)
|
||||
]
|
||||
|
||||
viewLogin: Model -> Html Msg
|
||||
viewLogin model =
|
||||
Html.map LoginMsg (Page.Login.View.view model.loginModel)
|
||||
|
||||
viewHome: Model -> Html Msg
|
||||
viewHome model =
|
||||
Html.map HomeMsg (Page.Home.View.view model.homeModel)
|
||||
|
||||
|
||||
loginInfo: Model -> Html Msg
|
||||
loginInfo model =
|
||||
div [class "right menu"]
|
||||
(case model.flags.account of
|
||||
Just acc ->
|
||||
[a [class "item"
|
||||
]
|
||||
[text "Profile"
|
||||
]
|
||||
,a [class "item"
|
||||
,Page.href model.page
|
||||
,onClick Logout
|
||||
]
|
||||
[text "Logout "
|
||||
,text (acc.collective ++ "/" ++ acc.user)
|
||||
]
|
||||
]
|
||||
Nothing ->
|
||||
[a [class "item"
|
||||
,Page.href LoginPage
|
||||
]
|
||||
[text "Login"
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
footer: Model -> Html Msg
|
||||
footer model =
|
||||
div [ class "ui footer" ]
|
||||
[ a [href "https://github.com/eikek/docspell"]
|
||||
[ i [class "ui github icon"][]
|
||||
]
|
||||
, span []
|
||||
[ text "Docspell "
|
||||
, text model.version.version
|
||||
, text " (#"
|
||||
, String.left 8 model.version.gitCommit |> text
|
||||
, text ")"
|
||||
]
|
||||
]
|
22
modules/webapp/src/main/elm/Data/Flags.elm
Normal file
22
modules/webapp/src/main/elm/Data/Flags.elm
Normal file
@ -0,0 +1,22 @@
|
||||
module Data.Flags exposing (..)
|
||||
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
|
||||
type alias Config =
|
||||
{ appName: String
|
||||
, baseUrl: String
|
||||
}
|
||||
|
||||
type alias Flags =
|
||||
{ account: Maybe AuthResult
|
||||
, config: Config
|
||||
}
|
||||
|
||||
getToken: Flags -> Maybe String
|
||||
getToken flags =
|
||||
flags.account
|
||||
|> Maybe.andThen (\a -> a.token)
|
||||
|
||||
withAccount: Flags -> AuthResult -> Flags
|
||||
withAccount flags acc =
|
||||
{ flags | account = Just acc }
|
58
modules/webapp/src/main/elm/Main.elm
Normal file
58
modules/webapp/src/main/elm/Main.elm
Normal file
@ -0,0 +1,58 @@
|
||||
module Main exposing (..)
|
||||
|
||||
import Browser exposing (Document)
|
||||
import Browser.Navigation exposing (Key)
|
||||
import Url exposing (Url)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
|
||||
import Api
|
||||
import Ports
|
||||
import Data.Flags exposing (Flags)
|
||||
import App.Data exposing (..)
|
||||
import App.Update exposing (..)
|
||||
import App.View exposing (..)
|
||||
|
||||
|
||||
-- MAIN
|
||||
|
||||
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, view = viewDoc
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, onUrlRequest = NavRequest
|
||||
, onUrlChange = NavChange
|
||||
}
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
||||
init : Flags -> Url -> Key -> (Model, Cmd Msg)
|
||||
init flags url key =
|
||||
let
|
||||
im = App.Data.init key url flags
|
||||
(m, cmd) = App.Update.initPage im im.page
|
||||
sessionCheck =
|
||||
case m.flags.account of
|
||||
Just acc -> Api.loginSession flags SessionCheckResp
|
||||
Nothing -> Cmd.none
|
||||
in
|
||||
(m, Cmd.batch [ cmd, Ports.initElements(), Api.versionInfo flags VersionResp, sessionCheck ])
|
||||
|
||||
viewDoc: Model -> Document Msg
|
||||
viewDoc model =
|
||||
{ title = model.flags.config.appName
|
||||
, body = [ (view model) ]
|
||||
}
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
Sub.none
|
44
modules/webapp/src/main/elm/Page.elm
Normal file
44
modules/webapp/src/main/elm/Page.elm
Normal file
@ -0,0 +1,44 @@
|
||||
module Page exposing ( Page(..)
|
||||
, href
|
||||
, goto
|
||||
, pageToString
|
||||
, fromUrl
|
||||
)
|
||||
|
||||
import Url exposing (Url)
|
||||
import Url.Parser as Parser exposing ((</>), Parser, oneOf, s, string)
|
||||
import Html exposing (Attribute)
|
||||
import Html.Attributes as Attr
|
||||
import Browser.Navigation as Nav
|
||||
|
||||
type Page
|
||||
= HomePage
|
||||
| LoginPage
|
||||
|
||||
|
||||
pageToString: Page -> String
|
||||
pageToString page =
|
||||
case page of
|
||||
HomePage -> "#/home"
|
||||
LoginPage -> "#/login"
|
||||
|
||||
href: Page -> Attribute msg
|
||||
href page =
|
||||
Attr.href (pageToString page)
|
||||
|
||||
goto: Page -> Cmd msg
|
||||
goto page =
|
||||
Nav.load (pageToString page)
|
||||
|
||||
parser: Parser (Page -> a) a
|
||||
parser =
|
||||
oneOf
|
||||
[ Parser.map HomePage Parser.top
|
||||
, Parser.map HomePage (s "home")
|
||||
, Parser.map LoginPage (s "login")
|
||||
]
|
||||
|
||||
fromUrl : Url -> Maybe Page
|
||||
fromUrl url =
|
||||
{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing }
|
||||
|> Parser.parse parser
|
15
modules/webapp/src/main/elm/Page/Home/Data.elm
Normal file
15
modules/webapp/src/main/elm/Page/Home/Data.elm
Normal file
@ -0,0 +1,15 @@
|
||||
module Page.Home.Data exposing (..)
|
||||
|
||||
import Http
|
||||
|
||||
type alias Model =
|
||||
{
|
||||
}
|
||||
|
||||
emptyModel: Model
|
||||
emptyModel =
|
||||
{
|
||||
}
|
||||
|
||||
type Msg
|
||||
= Dummy
|
9
modules/webapp/src/main/elm/Page/Home/Update.elm
Normal file
9
modules/webapp/src/main/elm/Page/Home/Update.elm
Normal file
@ -0,0 +1,9 @@
|
||||
module Page.Home.Update exposing (update)
|
||||
|
||||
import Api
|
||||
import Data.Flags exposing (Flags)
|
||||
import Page.Home.Data exposing (..)
|
||||
|
||||
update: Flags -> Msg -> Model -> (Model, Cmd Msg)
|
||||
update flags msg model =
|
||||
(model, Cmd.none)
|
23
modules/webapp/src/main/elm/Page/Home/View.elm
Normal file
23
modules/webapp/src/main/elm/Page/Home/View.elm
Normal file
@ -0,0 +1,23 @@
|
||||
module Page.Home.View exposing (view)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (onClick)
|
||||
|
||||
import Page exposing (Page(..))
|
||||
import Page.Home.Data exposing (..)
|
||||
import Data.Flags
|
||||
|
||||
view: Model -> Html Msg
|
||||
view model =
|
||||
div [class "home-page ui fluid grid"]
|
||||
[div [class "three wide column"]
|
||||
[h3 [][text "Menu"]
|
||||
]
|
||||
,div [class "seven wide column", style "border-left" "1px solid"]
|
||||
[h3 [][text "List"]
|
||||
]
|
||||
,div [class "six wide column", style "border-left" "1px solid", style "height" "100vh"]
|
||||
[h3 [][text "DocView"]
|
||||
]
|
||||
]
|
23
modules/webapp/src/main/elm/Page/Login/Data.elm
Normal file
23
modules/webapp/src/main/elm/Page/Login/Data.elm
Normal file
@ -0,0 +1,23 @@
|
||||
module Page.Login.Data exposing (..)
|
||||
|
||||
import Http
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
|
||||
type alias Model =
|
||||
{ username: String
|
||||
, password: String
|
||||
, result: Maybe AuthResult
|
||||
}
|
||||
|
||||
empty: Model
|
||||
empty =
|
||||
{ username = ""
|
||||
, password = ""
|
||||
, result = Nothing
|
||||
}
|
||||
|
||||
type Msg
|
||||
= SetUsername String
|
||||
| SetPassword String
|
||||
| Authenticate
|
||||
| AuthResp (Result Http.Error AuthResult)
|
39
modules/webapp/src/main/elm/Page/Login/Update.elm
Normal file
39
modules/webapp/src/main/elm/Page/Login/Update.elm
Normal file
@ -0,0 +1,39 @@
|
||||
module Page.Login.Update exposing (update)
|
||||
|
||||
import Api
|
||||
import Ports
|
||||
import Data.Flags exposing (Flags)
|
||||
import Page exposing (Page(..))
|
||||
import Page.Login.Data exposing (..)
|
||||
import Api.Model.UserPass exposing (UserPass)
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
import Util.Http
|
||||
|
||||
update: Flags -> Msg -> Model -> (Model, Cmd Msg, Maybe AuthResult)
|
||||
update flags msg model =
|
||||
case msg of
|
||||
SetUsername str ->
|
||||
({model | username = str}, Cmd.none, Nothing)
|
||||
SetPassword str ->
|
||||
({model | password = str}, Cmd.none, Nothing)
|
||||
|
||||
Authenticate ->
|
||||
(model, Api.login flags (UserPass model.username model.password) AuthResp, Nothing)
|
||||
|
||||
AuthResp (Ok lr) ->
|
||||
if lr.success
|
||||
then ({model|result = Just lr, password = ""}, setAccount lr, Just lr)
|
||||
else ({model|result = Just lr, password = ""}, Ports.removeAccount "", Just lr)
|
||||
|
||||
AuthResp (Err err) ->
|
||||
let
|
||||
empty = Api.Model.AuthResult.empty
|
||||
lr = {empty|message = Util.Http.errorToString err}
|
||||
in
|
||||
({model|password = "", result = Just lr}, Ports.removeAccount "", Just empty)
|
||||
|
||||
setAccount: AuthResult -> Cmd msg
|
||||
setAccount result =
|
||||
if result.success
|
||||
then Ports.setAccount result
|
||||
else Ports.removeAccount ""
|
59
modules/webapp/src/main/elm/Page/Login/View.elm
Normal file
59
modules/webapp/src/main/elm/Page/Login/View.elm
Normal file
@ -0,0 +1,59 @@
|
||||
module Page.Login.View exposing (view)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (onClick, onInput, onSubmit)
|
||||
|
||||
import Page.Login.Data exposing (..)
|
||||
|
||||
view: Model -> Html Msg
|
||||
view model =
|
||||
div [class "login-page"]
|
||||
[div [class "ui centered grid"]
|
||||
[div [class "row"]
|
||||
[div [class "eight wide column ui segment login-view"]
|
||||
[h1 [class "ui dividing header"][text "Sign in to Docspell"]
|
||||
,Html.form [class "ui large error form", onSubmit Authenticate]
|
||||
[div [class "field"]
|
||||
[label [][text "Username"]
|
||||
,input [type_ "text"
|
||||
,onInput SetUsername
|
||||
,value model.username
|
||||
][]
|
||||
]
|
||||
,div [class "field"]
|
||||
[label [][text "Password"]
|
||||
,input [type_ "password"
|
||||
,onInput SetPassword
|
||||
,value model.password
|
||||
][]
|
||||
]
|
||||
,button [class "ui primary button"
|
||||
,type_ "submit"
|
||||
,onClick Authenticate
|
||||
]
|
||||
[text "Login"
|
||||
]
|
||||
]
|
||||
,(resultMessage model)
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
resultMessage: Model -> Html Msg
|
||||
resultMessage model =
|
||||
case model.result of
|
||||
Just r ->
|
||||
if r.success
|
||||
then
|
||||
div [class "ui success message"]
|
||||
[text "Login successful."
|
||||
]
|
||||
else
|
||||
div [class "ui error message"]
|
||||
[text r.message
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
span [][]
|
8
modules/webapp/src/main/elm/Ports.elm
Normal file
8
modules/webapp/src/main/elm/Ports.elm
Normal file
@ -0,0 +1,8 @@
|
||||
port module Ports exposing (..)
|
||||
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
|
||||
port initElements: () -> Cmd msg
|
||||
|
||||
port setAccount: AuthResult -> Cmd msg
|
||||
port removeAccount: String -> Cmd msg
|
139
modules/webapp/src/main/elm/Util/Http.elm
Normal file
139
modules/webapp/src/main/elm/Util/Http.elm
Normal file
@ -0,0 +1,139 @@
|
||||
module Util.Http exposing (..)
|
||||
|
||||
import Http
|
||||
import Process
|
||||
import Task exposing (Task)
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
import Json.Decode as D
|
||||
|
||||
-- Authenticated Requests
|
||||
|
||||
authReq: {url: String
|
||||
,account: AuthResult
|
||||
,method: String
|
||||
,headers: List Http.Header
|
||||
,body: Http.Body
|
||||
,expect: Http.Expect msg
|
||||
} -> Cmd msg
|
||||
authReq req =
|
||||
Http.request
|
||||
{ url = req.url
|
||||
, method = req.method
|
||||
, headers = (Http.header "X-Docspell-Auth" (Maybe.withDefault "" req.account.token)) :: req.headers
|
||||
, expect = req.expect
|
||||
, body = req.body
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
authPost: {url: String
|
||||
,account: AuthResult
|
||||
,body: Http.Body
|
||||
,expect: Http.Expect msg
|
||||
} -> Cmd msg
|
||||
authPost req =
|
||||
authReq
|
||||
{ url = req.url
|
||||
, account = req.account
|
||||
, body = req.body
|
||||
, expect = req.expect
|
||||
, method = "POST"
|
||||
, headers = []
|
||||
}
|
||||
|
||||
authGet: {url: String
|
||||
,account: AuthResult
|
||||
,expect: Http.Expect msg
|
||||
} -> Cmd msg
|
||||
authGet req =
|
||||
authReq
|
||||
{ url = req.url
|
||||
, account = req.account
|
||||
, body = Http.emptyBody
|
||||
, expect = req.expect
|
||||
, method = "GET"
|
||||
, headers = []
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- Error Utilities
|
||||
|
||||
errorToStringStatus: Http.Error -> (Int -> String) -> String
|
||||
errorToStringStatus error statusString =
|
||||
case error of
|
||||
Http.BadUrl url ->
|
||||
"There is something wrong with this url: " ++ url
|
||||
Http.Timeout ->
|
||||
"There was a network timeout."
|
||||
Http.NetworkError ->
|
||||
"There was a network error."
|
||||
Http.BadStatus status ->
|
||||
statusString status
|
||||
Http.BadBody str ->
|
||||
"There was an error decoding the response: " ++ str
|
||||
|
||||
errorToString: Http.Error -> String
|
||||
errorToString error =
|
||||
let
|
||||
f sc = case sc of
|
||||
404 ->
|
||||
"The requested resource doesn't exist."
|
||||
_ ->
|
||||
"There was an invalid response status: " ++ (String.fromInt sc)
|
||||
in
|
||||
errorToStringStatus error f
|
||||
|
||||
|
||||
-- Http.Task Utilities
|
||||
|
||||
jsonResolver : D.Decoder a -> Http.Resolver Http.Error a
|
||||
jsonResolver decoder =
|
||||
Http.stringResolver <|
|
||||
\response ->
|
||||
case response of
|
||||
Http.BadUrl_ url ->
|
||||
Err (Http.BadUrl url)
|
||||
|
||||
Http.Timeout_ ->
|
||||
Err Http.Timeout
|
||||
|
||||
Http.NetworkError_ ->
|
||||
Err Http.NetworkError
|
||||
|
||||
Http.BadStatus_ metadata body ->
|
||||
Err (Http.BadStatus metadata.statusCode)
|
||||
|
||||
Http.GoodStatus_ metadata body ->
|
||||
case D.decodeString decoder body of
|
||||
Ok value ->
|
||||
Ok value
|
||||
|
||||
Err err ->
|
||||
Err (Http.BadBody (D.errorToString err))
|
||||
|
||||
executeIn: Float -> ((Result Http.Error a) -> msg) -> Task Http.Error a -> Cmd msg
|
||||
executeIn delay receive task =
|
||||
Process.sleep delay
|
||||
|> Task.andThen (\_ -> task)
|
||||
|> Task.attempt receive
|
||||
|
||||
authTask:
|
||||
{ method : String
|
||||
, headers : List Http.Header
|
||||
, account: AuthResult
|
||||
, url : String
|
||||
, body : Http.Body
|
||||
, resolver : Http.Resolver x a
|
||||
, timeout : Maybe Float
|
||||
}
|
||||
-> Task x a
|
||||
authTask req =
|
||||
Http.task
|
||||
{ method = req.method
|
||||
, headers = (Http.header "X-Docspell-Auth" (Maybe.withDefault "" req.account.token)) :: req.headers
|
||||
, url = req.url
|
||||
, body = req.body
|
||||
, resolver = req.resolver
|
||||
, timeout = req.timeout
|
||||
}
|
36
modules/webapp/src/main/webjar/docspell.css
Normal file
36
modules/webapp/src/main/webjar/docspell.css
Normal file
@ -0,0 +1,36 @@
|
||||
/* Docspell CSS */
|
||||
|
||||
.default-layout {
|
||||
background: #fff;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.default-layout .main-content {
|
||||
margin-top: 45px;
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
background: #aaa;
|
||||
height: 101vh;
|
||||
}
|
||||
|
||||
.login-layout .login-view {
|
||||
background: #fff;
|
||||
position: relative;
|
||||
top: 20vh;
|
||||
}
|
||||
|
||||
|
||||
.invisible {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (min-height: 320px) {
|
||||
.ui.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: x-small;
|
||||
}
|
||||
}
|
13
modules/webapp/src/main/webjar/docspell.js
Normal file
13
modules/webapp/src/main/webjar/docspell.js
Normal file
@ -0,0 +1,13 @@
|
||||
/* Docspell JS */
|
||||
|
||||
var elmApp = Elm.Main.init({
|
||||
node: document.getElementById("docspell-app"),
|
||||
flags: elmFlags
|
||||
});
|
||||
|
||||
elmApp.ports.initElements.subscribe(function() {
|
||||
console.log("Initialsing elements …");
|
||||
$('.ui.dropdown').dropdown();
|
||||
$('.ui.checkbox').checkbox();
|
||||
$('.ui.accordion').accordion();
|
||||
});
|
Reference in New Issue
Block a user