From 6154e6a387a152762e0c82df01efe5a084819d41 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Wed, 17 Jul 2019 22:03:10 +0200 Subject: [PATCH] Initial application stub --- .gitignore | 3 + README.md | 1 + build.sbt | 257 ++++++++++++++++++ ...markdown_architectural_decision_records.md | 33 +++ doc/adr/0001_components.md | 76 ++++++ doc/adr/0002_component_interaction.md | 87 ++++++ doc/adr/template.md | 72 +++++ doc/dev.md | 110 ++++++++ doc/install.md | 1 + doc/user.md | 69 +++++ elm.json | 29 ++ .../joex/src/main/resources/reference.conf | 3 + .../src/main/scala/docspell/joex/Config.scala | 18 ++ .../main/scala/docspell/joex/InfoRoutes.scala | 26 ++ .../main/scala/docspell/joex/JoexApp.scala | 6 + .../scala/docspell/joex/JoexAppImpl.scala | 16 ++ .../main/scala/docspell/joex/JoexServer.scala | 38 +++ .../src/main/scala/docspell/joex/Main.scala | 38 +++ .../src/main/resources/joex-openapi.yml | 35 +++ .../src/main/resources/docspell-openapi.yml | 127 +++++++++ .../restserver/src/main/resources/logback.xml | 14 + .../src/main/resources/reference.conf | 3 + .../scala/docspell/restserver/Config.scala | 19 ++ .../docspell/restserver/InfoRoutes.scala | 26 ++ .../main/scala/docspell/restserver/Main.scala | 39 +++ .../scala/docspell/restserver/RestApp.scala | 6 + .../docspell/restserver/RestAppImpl.scala | 16 ++ .../docspell/restserver/RestServer.scala | 42 +++ .../restserver/webapp/TemplateRoutes.scala | 139 ++++++++++ .../restserver/webapp/WebjarRoutes.scala | 28 ++ .../restserver/src/main/templates/doc.html | 60 ++++ .../restserver/src/main/templates/index.html | 31 +++ .../scala/docspell/store/JdbcConfig.scala | 36 +++ modules/webapp/src/main/elm/Api.elm | 72 +++++ modules/webapp/src/main/elm/App/Data.elm | 45 +++ modules/webapp/src/main/elm/App/Update.elm | 106 ++++++++ modules/webapp/src/main/elm/App/View.elm | 101 +++++++ modules/webapp/src/main/elm/Data/Flags.elm | 22 ++ modules/webapp/src/main/elm/Main.elm | 58 ++++ modules/webapp/src/main/elm/Page.elm | 44 +++ .../webapp/src/main/elm/Page/Home/Data.elm | 15 + .../webapp/src/main/elm/Page/Home/Update.elm | 9 + .../webapp/src/main/elm/Page/Home/View.elm | 23 ++ .../webapp/src/main/elm/Page/Login/Data.elm | 23 ++ .../webapp/src/main/elm/Page/Login/Update.elm | 39 +++ .../webapp/src/main/elm/Page/Login/View.elm | 59 ++++ modules/webapp/src/main/elm/Ports.elm | 8 + modules/webapp/src/main/elm/Util/Http.elm | 139 ++++++++++ modules/webapp/src/main/webjar/docspell.css | 36 +++ modules/webapp/src/main/webjar/docspell.js | 13 + project/Dependencies.scala | 121 +++++++++ project/build.properties | 1 + project/plugins.sbt | 8 + version.sbt | 1 + 54 files changed, 2447 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.sbt create mode 100644 doc/adr/0000_use_markdown_architectural_decision_records.md create mode 100644 doc/adr/0001_components.md create mode 100644 doc/adr/0002_component_interaction.md create mode 100644 doc/adr/template.md create mode 100644 doc/dev.md create mode 100644 doc/install.md create mode 100644 doc/user.md create mode 100644 elm.json create mode 100644 modules/joex/src/main/resources/reference.conf create mode 100644 modules/joex/src/main/scala/docspell/joex/Config.scala create mode 100644 modules/joex/src/main/scala/docspell/joex/InfoRoutes.scala create mode 100644 modules/joex/src/main/scala/docspell/joex/JoexApp.scala create mode 100644 modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala create mode 100644 modules/joex/src/main/scala/docspell/joex/JoexServer.scala create mode 100644 modules/joex/src/main/scala/docspell/joex/Main.scala create mode 100644 modules/joexapi/src/main/resources/joex-openapi.yml create mode 100644 modules/restapi/src/main/resources/docspell-openapi.yml create mode 100644 modules/restserver/src/main/resources/logback.xml create mode 100644 modules/restserver/src/main/resources/reference.conf create mode 100644 modules/restserver/src/main/scala/docspell/restserver/Config.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/InfoRoutes.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/Main.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/RestApp.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/RestServer.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/webapp/WebjarRoutes.scala create mode 100644 modules/restserver/src/main/templates/doc.html create mode 100644 modules/restserver/src/main/templates/index.html create mode 100644 modules/store/src/main/scala/docspell/store/JdbcConfig.scala create mode 100644 modules/webapp/src/main/elm/Api.elm create mode 100644 modules/webapp/src/main/elm/App/Data.elm create mode 100644 modules/webapp/src/main/elm/App/Update.elm create mode 100644 modules/webapp/src/main/elm/App/View.elm create mode 100644 modules/webapp/src/main/elm/Data/Flags.elm create mode 100644 modules/webapp/src/main/elm/Main.elm create mode 100644 modules/webapp/src/main/elm/Page.elm create mode 100644 modules/webapp/src/main/elm/Page/Home/Data.elm create mode 100644 modules/webapp/src/main/elm/Page/Home/Update.elm create mode 100644 modules/webapp/src/main/elm/Page/Home/View.elm create mode 100644 modules/webapp/src/main/elm/Page/Login/Data.elm create mode 100644 modules/webapp/src/main/elm/Page/Login/Update.elm create mode 100644 modules/webapp/src/main/elm/Page/Login/View.elm create mode 100644 modules/webapp/src/main/elm/Ports.elm create mode 100644 modules/webapp/src/main/elm/Util/Http.elm create mode 100644 modules/webapp/src/main/webjar/docspell.css create mode 100644 modules/webapp/src/main/webjar/docspell.js create mode 100644 project/Dependencies.scala create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 version.sbt diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..773ab69e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +dev.conf +elm-stuff \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..e0fbad6f --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Docspell diff --git a/build.sbt b/build.sbt new file mode 100644 index 00000000..603e38c9 --- /dev/null +++ b/build.sbt @@ -0,0 +1,257 @@ +import com.github.eikek.sbt.openapi._ +import scala.sys.process._ +import com.typesafe.sbt.SbtGit.GitKeys._ + +val sharedSettings = Seq( + organization := "com.github.eikek", + scalaVersion := "2.13.0", + scalacOptions ++= Seq( + "-deprecation", + "-encoding", "UTF-8", + "-language:higherKinds", + "-language:postfixOps", + "-feature", + "-Xfatal-warnings", // fail when there are warnings + "-unchecked", + "-Xlint", + "-Ywarn-dead-code", + "-Ywarn-numeric-widen", + "-Ywarn-value-discard" + ), + scalacOptions in (Compile, console) := Seq() +) + +val testSettings = Seq( + testFrameworks += new TestFramework("minitest.runner.Framework"), + libraryDependencies ++= Dependencies.miniTest +) + +val elmSettings = Seq( + Compile/resourceGenerators += (Def.task { + compileElm(streams.value.log + , (Compile/baseDirectory).value + , (Compile/resourceManaged).value + , name.value + , version.value) + }).taskValue, + watchSources += Watched.WatchSource( + (Compile/sourceDirectory).value/"elm" + , FileFilter.globFilter("*.elm") + , HiddenFileFilter + ) +) + +val webjarSettings = Seq( + Compile/resourceGenerators += (Def.task { + copyWebjarResources(Seq((sourceDirectory in Compile).value/"webjar") + , (Compile/resourceManaged).value + , name.value + , version.value + , streams.value.log + ) + }).taskValue, + watchSources += Watched.WatchSource( + (Compile / sourceDirectory).value/"webjar" + , FileFilter.globFilter("*.js") || FileFilter.globFilter("*.css") + , HiddenFileFilter + ) +) + +val debianSettings = Seq( + maintainer := "Eike Kettner ", + packageSummary := description.value, + packageDescription := description.value, + mappings in Universal += { + val conf = (Compile / resourceDirectory).value / "reference.conf" + if (!conf.exists) { + sys.error(s"File $conf not found") + } + conf -> "conf/docspell.conf" + }, + bashScriptExtraDefines += """addJava "-Dconfig.file=${app_home}/../conf/docspell.conf"""" +) + +val buildInfoSettings = Seq( + buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion, gitHeadCommit, gitHeadCommitDate, gitUncommittedChanges, gitDescribedVersion), + buildInfoOptions += BuildInfoOption.ToJson, + buildInfoOptions += BuildInfoOption.BuildTime +) + + + +val common = project.in(file("modules/common")). + settings(sharedSettings). + settings(testSettings). + settings( + name := "docspell-common", + libraryDependencies ++= + Dependencies.fs2 + ) + +val store = project.in(file("modules/store")). + settings(sharedSettings). + settings(testSettings). + settings( + name := "docspell-store", + libraryDependencies ++= + Dependencies.doobie ++ + Dependencies.bitpeace ++ + Dependencies.fs2 ++ + Dependencies.databases ++ + Dependencies.flyway ++ + Dependencies.loggingApi + ) + +val restapi = project.in(file("modules/restapi")). + enablePlugins(OpenApiSchema). + settings(sharedSettings). + settings(testSettings). + settings( + name := "docspell-restapi", + libraryDependencies ++= + Dependencies.circe, + openapiTargetLanguage := Language.Scala, + openapiPackage := Pkg("docspell.restapi.model"), + openapiSpec := (Compile/resourceDirectory).value/"docspell-openapi.yml", + openapiScalaConfig := ScalaConfig().withJson(ScalaJson.circeSemiauto) + ) + +val joexapi = project.in(file("modules/joexapi")). + enablePlugins(OpenApiSchema). + settings(sharedSettings). + settings(testSettings). + settings( + name := "docspell-joexapi", + libraryDependencies ++= + Dependencies.circe, + openapiTargetLanguage := Language.Scala, + openapiPackage := Pkg("docspell.joexapi.model"), + openapiSpec := (Compile/resourceDirectory).value/"joex-openapi.yml", + openapiScalaConfig := ScalaConfig().withJson(ScalaJson.circeSemiauto) + ) + +val joex = project.in(file("modules/joex")). + enablePlugins(BuildInfoPlugin + , JavaServerAppPackaging + , DebianPlugin + , SystemdPlugin). + settings(sharedSettings). + settings(testSettings). + settings(debianSettings). + settings(buildInfoSettings). + settings( + name := "docspell-joex", + libraryDependencies ++= + Dependencies.fs2 ++ + Dependencies.http4s ++ + Dependencies.circe ++ + Dependencies.pureconfig ++ + Dependencies.loggingApi ++ + Dependencies.logging, + addCompilerPlugin(Dependencies.kindProjectorPlugin), + addCompilerPlugin(Dependencies.betterMonadicFor), + buildInfoPackage := "docspell.joex" + ).dependsOn(store, joexapi, restapi) + +val backend = project.in(file("modules/backend")). + settings(sharedSettings). + settings(testSettings). + settings( + name := "docspell-backend", + libraryDependencies ++= + Dependencies.loggingApi ++ + Dependencies.fs2 + ).dependsOn(store) + +val webapp = project.in(file("modules/webapp")). + enablePlugins(OpenApiSchema). + settings(sharedSettings). + settings(elmSettings). + settings(webjarSettings). + settings( + name := "docspell-webapp", + openapiTargetLanguage := Language.Elm, + openapiPackage := Pkg("Api.Model"), + openapiSpec := (restapi/Compile/resourceDirectory).value/"docspell-openapi.yml", + openapiElmConfig := ElmConfig().withJson(ElmJson.decodePipeline) + ) + +val restserver = project.in(file("modules/restserver")). + enablePlugins(BuildInfoPlugin + , JavaServerAppPackaging + , DebianPlugin + , SystemdPlugin). + settings(sharedSettings). + settings(testSettings). + settings(debianSettings). + settings(buildInfoSettings). + settings( + name := "docspell-restserver", + libraryDependencies ++= + Dependencies.http4s ++ + Dependencies.circe ++ + Dependencies.pureconfig ++ + Dependencies.yamusca ++ + Dependencies.webjars ++ + Dependencies.loggingApi ++ + Dependencies.logging, + addCompilerPlugin(Dependencies.kindProjectorPlugin), + addCompilerPlugin(Dependencies.betterMonadicFor), + buildInfoPackage := "docspell.restserver", + Compile/sourceGenerators += (Def.task { + createWebjarSource(Dependencies.webjars, (Compile/sourceManaged).value) + }).taskValue, + Compile/unmanagedResourceDirectories ++= Seq((Compile/resourceDirectory).value.getParentFile/"templates") + ).dependsOn(restapi, joexapi, backend, webapp) + +val root = project.in(file(".")). + settings(sharedSettings). + settings( + name := "docspell-root" + ). + aggregate(common, store, joexapi, joex, backend, webapp, restapi, restserver) + + +def copyWebjarResources(src: Seq[File], base: File, artifact: String, version: String, logger: Logger): Seq[File] = { + val targetDir = base/"META-INF"/"resources"/"webjars"/artifact/version + src.flatMap { dir => + if (dir.isDirectory) { + val files = (dir ** "*").filter(_.isFile).get pair Path.relativeTo(dir) + files.map { case (f, name) => + val target = targetDir/name + logger.info(s"Copy $f -> $target") + IO.createDirectories(Seq(target.getParentFile)) + IO.copy(Seq(f -> target)) + target + } + } else { + val target = targetDir/dir.name + logger.info(s"Copy $dir -> $target") + IO.createDirectories(Seq(target.getParentFile)) + IO.copy(Seq(dir -> target)) + Seq(target) + } + } +} + +def compileElm(logger: Logger, wd: File, outBase: File, artifact: String, version: String): Seq[File] = { + logger.info("Compile elm files ...") + val target = outBase/"META-INF"/"resources"/"webjars"/artifact/version/"docspell-app.js" + val proc = Process(Seq("elm", "make", "--output", target.toString) ++ Seq(wd/"src"/"main"/"elm"/"Main.elm").map(_.toString), Some(wd)) + val out = proc.!! + logger.info(out) + Seq(target) +} + +def createWebjarSource(wj: Seq[ModuleID], out: File): Seq[File] = { + val target = out/"Webjars.scala" + val fields = wj.map(m => s"""val ${m.name.toLowerCase.filter(_ != '-')} = "/${m.name}/${m.revision}" """).mkString("\n\n") + val content = s"""package docspell.restserver.webapp + |object Webjars { + |$fields + |} + |""".stripMargin + + IO.write(target, content) + Seq(target) +} diff --git a/doc/adr/0000_use_markdown_architectural_decision_records.md b/doc/adr/0000_use_markdown_architectural_decision_records.md new file mode 100644 index 00000000..67186bbc --- /dev/null +++ b/doc/adr/0000_use_markdown_architectural_decision_records.md @@ -0,0 +1,33 @@ +# Use Markdown Architectural Decision Records + +## Context and Problem Statement + +We want to [record architectural decisions](https://adr.github.io/) +made in this project. Which format and structure should these records +follow? + +## Considered Options + +* [MADR](https://adr.github.io/madr/) 2.1.0 - The Markdown Architectural Decision Records +* [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) - The first incarnation of the term "ADR" +* [Sustainable Architectural + Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) - + The Y-Statements +* Other templates listed at + +* Formless - No conventions for file format and structure + +## Decision Outcome + +Chosen option: "MADR 2.1.0", because + +* Implicit assumptions should be made explicit. Design documentation + is important to enable people understanding the decisions later on. + See also [A rational design process: How and why to fake + it](https://doi.org/10.1109/TSE.1986.6312940). +* The MADR format is lean and fits our development style. +* The MADR structure is comprehensible and facilitates usage & + maintenance. +* The MADR project is vivid. +* Version 2.1.0 is the latest one available when starting to document + ADRs. diff --git a/doc/adr/0001_components.md b/doc/adr/0001_components.md new file mode 100644 index 00000000..11da91eb --- /dev/null +++ b/doc/adr/0001_components.md @@ -0,0 +1,76 @@ +# Components + +## Context and Problem Statement + +How should the application be structured into its main components? The +goal is to be able to have multiple rest servers/webapps and multiple +document processor components working togehter. + + +## Considered Options + + +## Decision Outcome + +The following are the "main" modules. There may be more helper modules +and libraries that support implementing a feature. + +### store + +The code related to database access. It also provides the job +queue. It is designed as a library. + +### joex + +Joex stands for "job executor". + +An application that executes jobs from the queue and therefore depends +on the `store` module. It provides the code for all tasks that can be +submitted as jobs. If no jobs are in the queue, the joex "sleeps" +and must be waked via an external request. + +The main reason for this module is to provide the document processing +code. + +It provides a http rest server to get insight into the joex state +and also to be notified for new jobs. + +### backend + +This is the heart of the application. It provides all the logic, +except document processing, as a set of "operations". An operation can +be directly mapped to a rest endpoint. An operation is roughly this: + +``` +A -> F[Either[E, B]] +``` + +First, it can fail and so there is some sort of either type to encode +failure. It also is inside a `F` context, since it may run impure +code, e.g. database calls. The input value `A` can be obtained by +amending the user input from a rest call with additional data from the +database corresponding to the current user (for example the public key +or some preference setting). + +It is designed as a library. + +### rest spec + +This module contains the specification for the rest server as an +`openapi.yml` file. It is packaged as a scala library that also +provides types and conversions to/from json. + +The idea is that the `rest server` module can depend on it as well as +rest clients. + +### rest server + +This is the main application. It directly depends on the `backend` +module, and each rest endpoint maps to a "backend operation". It is +also responsible for converting the json data inside http requests +to/from types recognized by the `backend` module. + + +### webapp + +This module provides the user interface as a web application. diff --git a/doc/adr/0002_component_interaction.md b/doc/adr/0002_component_interaction.md new file mode 100644 index 00000000..ecd54f92 --- /dev/null +++ b/doc/adr/0002_component_interaction.md @@ -0,0 +1,87 @@ +# Component Interaction + +## Context and Problem Statement + +There are multiple web applications with their rest servers and there +are multiple document processors. These processes must communicate: + +- once a new job is added to the queue the rest server must somehow + notify processors to wake up +- once a processor takes a job, it must propagate the progress and + outcome to all rest servers only that the rest server can notify the + user that is currently logged in. Since it's not known which + rest-server the user is using right now, all must be notified. + +## Considered Options + +1. JMS: Message Broker as another active component +2. Akka: using a cluster +3. DB: Register with "call back urls" + +## Decision Outcome + +Choosing option 3: DB as central synchronisation point. + +The reason is that this is the simplest solution and doesn't require +external libraries or more processes. The other options seem too big +of a weapon for the task at hand. + +It works roughly like this: + +- rest servers and processors register at the database on startup each + with a unique call-back url +- and deregister on shutdown +- each component has db access +- rest servers can list all processors and vice versa + +### Positive Consequences + +- complexity of the whole application is not touched +- since a lot of data must be transferred to the document processors, + this is solved by simply accessing the db. So the protocol for data + exchange is set. There is no need for other protocols that handle + large data (http chunking etc) +- uses the already exsting db as synchronisation point +- no additional knowledge required +- simple to understand and so not hard to debug + +### Negative Consequences + +- all components must have db access. this also is a negative point, + because if one of those processes is hacked, db access is + possible. and it simply is another dependency that may not be + required for the document processors +- the document processors cannot be in a untrusted environment + (untrusted from the db's point of view). it would be for example + possible to create personal processors that only receive your own + jobs… +- in order to know if a component is really active, one must run a + ping against the call-back url + +## Pros and Cons of the Options + +### JMS Message Broker + +- pro: offers publish-subscribe out of the box +- con: another central point of failure +- con: requires setup and maintenance +- con: complexity of whole app is strongly increased, there are now at + least 3 processes + +### Akka Cluster + +- pro: publish subscribe +- pro: no central component or separate process +- con: only works reliably in a "real cluster", where 3 nodes is a + minimum. Thus it wouldn't allow a light-weight setup of the + application +- con: introduces a new technology that is not easy to understand and + maintain (the cluster, gossip protocol etc) requires to be "good at + akka" + +### DB Sync + +- pro: simple and intuitive +- pro: no one more central point of failure +- pro: requires no additional knowledge or setup +- cons: all components require db access diff --git a/doc/adr/template.md b/doc/adr/template.md new file mode 100644 index 00000000..25696bbe --- /dev/null +++ b/doc/adr/template.md @@ -0,0 +1,72 @@ +# [short title of solved problem and solution] + +* Status: [proposed | rejected | accepted | deprecated | … | superseded by [ADR-0005](0005-example.md)] +* Deciders: [list everyone involved in the decision] +* Date: [YYYY-MM-DD when the decision was last updated] + +Technical Story: [description | ticket/issue URL] + +## Context and Problem Statement + +[Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.] + +## Decision Drivers + +* [driver 1, e.g., a force, facing concern, …] +* [driver 2, e.g., a force, facing concern, …] +* … + +## Considered Options + +* [option 1] +* [option 2] +* [option 3] +* … + +## Decision Outcome + +Chosen option: "[option 1]", because [justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force force | … | comes out best (see below)]. + +### Positive Consequences + +* [e.g., improvement of quality attribute satisfaction, follow-up decisions required, …] +* … + +### Negative Consequences + +* [e.g., compromising quality attribute, follow-up decisions required, …] +* … + +## Pros and Cons of the Options + +### [option 1] + +[example | description | pointer to more information | …] + +* Good, because [argument a] +* Good, because [argument b] +* Bad, because [argument c] +* … + +### [option 2] + +[example | description | pointer to more information | …] + +* Good, because [argument a] +* Good, because [argument b] +* Bad, because [argument c] +* … + +### [option 3] + +[example | description | pointer to more information | …] + +* Good, because [argument a] +* Good, because [argument b] +* Bad, because [argument c] +* … + +## Links + +* [Link type] [Link to ADR] +* … diff --git a/doc/dev.md b/doc/dev.md new file mode 100644 index 00000000..595797b8 --- /dev/null +++ b/doc/dev.md @@ -0,0 +1,110 @@ +# Development Documentation + + +## initial thoughts + +* First there is a web app, where user can login, look at their + documents etc +* User can do queries and edit document meta data +* User can manage upload endpoints + +Upload endpoints allow to receive "items". There are the following +different options: + +1. Upload a single item by uploading one file. +2. Upload a single item by uploading a zip file. +3. Upload multiple items by uploading a zip file (one entry = one + item) + +Files are received and stored in the database, always. Only if a size +constraint is not fulfilled the response is an error. Files are marked +as `RECEIVED`. Idea is that most files are valid, so are saved +anyways. + +Then a job for a new item is inserted into the processing queue and +processing begins eventually. + +External processes access the queue on the same database and take jobs +for processing. + +Processing: + +1. check mimetype and error if not supported + - want to use the servers mimetype instead of advertised one from + the client +2. extract text and other meta data +3. do some analysis +4. tag item/set meta data +5. encrypt files + text, if configured + +If an error occurs, it can be inspected in the "queue screen". The web +app shows notifications in this case. User can download the file and +remove it. Otherwise, files will be deleted after some period. Errors +are also counted per source, so one can decide whether to block a +source. + +Once processing is done, the item is put in the INBOX. + +## Modules + +### processor + +### backend + +### store + +### backend server + +### webapp + +## Flow + + +1. webapp: calls rest route +2. server: + 1. convert json -> data + 2. choose backend operation +3. backend: execute logic + 1. store: load or save from/to db +4. server: + 1. convert data -> json + + +backend: +- need better name +- contains all logic encoded as operations +- operation: A -> Either[E, B] +- middleware translates userId -> required data + - e.g. userId -> public key +- operations can fail + - common error class is used + - can be converted to json easily + + +New Items: + +1. upload endpoint +2. server: + 1. convert json->data +3. store: add job to queue +4. processor: + 1. eventually takes the job + 2. execute job + 3. notify about result + + +Processors + +- multiple processors possible +- multiple backend servers possible +- separate processes +- register on database + - unique id + - url + - servers and processors +- once a job is added to the queue notify all processors + - take all registered urls from db + - call them, skip failing ones +- processors wake up and take next job based on their config +- first free processor gets a new job +- once done, notify registered backend server diff --git a/doc/install.md b/doc/install.md new file mode 100644 index 00000000..739ee74a --- /dev/null +++ b/doc/install.md @@ -0,0 +1 @@ +# Installation and Setup diff --git a/doc/user.md b/doc/user.md new file mode 100644 index 00000000..8e6b09eb --- /dev/null +++ b/doc/user.md @@ -0,0 +1,69 @@ +# User Documentation + +## Concepts + + +## UI Screens + +The web application is the provided user interface. THere are the following screens + + +### Login + +### Change Password + +### Document Overview + +- search menu on the left, 25% +- listing in the middle, 25% + - choose to list all documents + - or grouped by date, corresp. etc +- right: document preview + details, 50% + +### Document Edit + +- search menu + listing is replaced by edit screen, 50% +- document preview 50% + +### Manage Additional Data + +CRUD for + +- Organisation +- Person +- Equipment +- Sources, which are the possible upload endpoints. + +### Collective Settings + +- keystore +- collective data +- manage users + +### User Settings + +- preferences (language, ...) +- smtp servers + +### Super Settings + +- admin only +- enable/disable registration +- settings for the app and collectives + - e.g. can block collectives or users +- CRUD for all entities + +### Collective Processing Queue + +- user can inspect current processing +- see errors and progress +- see jobs in queue +- cancel jobs +- see some stats about executors, so one can make an educated guess as + to when the next job is executed + +### Admin Processing Queue + +- see which external processing workers are registered +- cancel/pause jobs +- some stats diff --git a/elm.json b/elm.json new file mode 100644 index 00000000..e974e727 --- /dev/null +++ b/elm.json @@ -0,0 +1,29 @@ +{ + "type": "application", + "source-directories": [ + "modules/webapp/target/elm-src", + "modules/webapp/src/main/elm" + ], + "elm-version": "0.19.0", + "dependencies": { + "direct": { + "NoRedInk/elm-json-decode-pipeline": "1.0.0", + "elm/browser": "1.0.1", + "elm/core": "1.0.2", + "elm/html": "1.0.0", + "elm/http": "2.0.0", + "elm/json": "1.1.3", + "elm/url": "1.0.0" + }, + "indirect": { + "elm/bytes": "1.0.8", + "elm/file": "1.0.5", + "elm/time": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +} diff --git a/modules/joex/src/main/resources/reference.conf b/modules/joex/src/main/resources/reference.conf new file mode 100644 index 00000000..adf3979d --- /dev/null +++ b/modules/joex/src/main/resources/reference.conf @@ -0,0 +1,3 @@ +docspell.joex { + +} \ No newline at end of file diff --git a/modules/joex/src/main/scala/docspell/joex/Config.scala b/modules/joex/src/main/scala/docspell/joex/Config.scala new file mode 100644 index 00000000..022c5d72 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/Config.scala @@ -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) +} diff --git a/modules/joex/src/main/scala/docspell/joex/InfoRoutes.scala b/modules/joex/src/main/scala/docspell/joex/InfoRoutes.scala new file mode 100644 index 00000000..f0060ce8 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/InfoRoutes.scala @@ -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(""))) + } + } +} diff --git a/modules/joex/src/main/scala/docspell/joex/JoexApp.scala b/modules/joex/src/main/scala/docspell/joex/JoexApp.scala new file mode 100644 index 00000000..246dc5d2 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/JoexApp.scala @@ -0,0 +1,6 @@ +package docspell.joex + +trait JoexApp[F[_]] { + + def init: F[Unit] +} diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala new file mode 100644 index 00000000..a4ea36f4 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala @@ -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))) +} diff --git a/modules/joex/src/main/scala/docspell/joex/JoexServer.scala b/modules/joex/src/main/scala/docspell/joex/JoexServer.scala new file mode 100644 index 00000000..4219d15d --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/JoexServer.scala @@ -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 +} diff --git a/modules/joex/src/main/scala/docspell/joex/Main.scala b/modules/joex/src/main/scala/docspell/joex/Main.scala new file mode 100644 index 00000000..846c9a6c --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/Main.scala @@ -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) + } +} diff --git a/modules/joexapi/src/main/resources/joex-openapi.yml b/modules/joexapi/src/main/resources/joex-openapi.yml new file mode 100644 index 00000000..75a113d9 --- /dev/null +++ b/modules/joexapi/src/main/resources/joex-openapi.yml @@ -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 diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml new file mode 100644 index 00000000..995df6c0 --- /dev/null +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -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 diff --git a/modules/restserver/src/main/resources/logback.xml b/modules/restserver/src/main/resources/logback.xml new file mode 100644 index 00000000..c33ec1f7 --- /dev/null +++ b/modules/restserver/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + true + + + [%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n + + + + + + + + diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf new file mode 100644 index 00000000..78391234 --- /dev/null +++ b/modules/restserver/src/main/resources/reference.conf @@ -0,0 +1,3 @@ +docspell.restserver { + +} \ No newline at end of file diff --git a/modules/restserver/src/main/scala/docspell/restserver/Config.scala b/modules/restserver/src/main/scala/docspell/restserver/Config.scala new file mode 100644 index 00000000..41b86264 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/Config.scala @@ -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) +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/InfoRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/InfoRoutes.scala new file mode 100644 index 00000000..cde78464 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/InfoRoutes.scala @@ -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(""))) + } + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/Main.scala b/modules/restserver/src/main/scala/docspell/restserver/Main.scala new file mode 100644 index 00000000..ca76ca40 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/Main.scala @@ -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) + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestApp.scala b/modules/restserver/src/main/scala/docspell/restserver/RestApp.scala new file mode 100644 index 00000000..dc9b3987 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/RestApp.scala @@ -0,0 +1,6 @@ +package docspell.restserver + +trait RestApp[F[_]] { + + def init: F[Unit] +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala b/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala new file mode 100644 index 00000000..2a9e50ef --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala @@ -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))) +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala new file mode 100644 index 00000000..e16a2270 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -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 +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala new file mode 100644 index 00000000..09d4935c --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala @@ -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 + }) + } + } + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/webapp/WebjarRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/webapp/WebjarRoutes.scala new file mode 100644 index 00000000..b7c5ee6d --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/webapp/WebjarRoutes.scala @@ -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)) + +} diff --git a/modules/restserver/src/main/templates/doc.html b/modules/restserver/src/main/templates/doc.html new file mode 100644 index 00000000..72820155 --- /dev/null +++ b/modules/restserver/src/main/templates/doc.html @@ -0,0 +1,60 @@ + + + + + Swagger UI + + + + + + + +
+ + + + + + diff --git a/modules/restserver/src/main/templates/index.html b/modules/restserver/src/main/templates/index.html new file mode 100644 index 00000000..2ff3ee4f --- /dev/null +++ b/modules/restserver/src/main/templates/index.html @@ -0,0 +1,31 @@ + + + + + + {{ flags.appName }} + {{# cssUrls }} + + {{/ cssUrls }} + {{# jsUrls }} + + {{/ jsUrls}} + + + + +
+
+ + + + + + diff --git a/modules/store/src/main/scala/docspell/store/JdbcConfig.scala b/modules/store/src/main/scala/docspell/store/JdbcConfig.scala new file mode 100644 index 00000000..e5afc6b3 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/JdbcConfig.scala @@ -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 + } +} diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm new file mode 100644 index 00000000..1eac72ce --- /dev/null +++ b/modules/webapp/src/main/elm/Api.elm @@ -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 diff --git a/modules/webapp/src/main/elm/App/Data.elm b/modules/webapp/src/main/elm/App/Data.elm new file mode 100644 index 00000000..c3dc99d3 --- /dev/null +++ b/modules/webapp/src/main/elm/App/Data.elm @@ -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 diff --git a/modules/webapp/src/main/elm/App/Update.elm b/modules/webapp/src/main/elm/App/Update.elm new file mode 100644 index 00000000..094b71d0 --- /dev/null +++ b/modules/webapp/src/main/elm/App/Update.elm @@ -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) diff --git a/modules/webapp/src/main/elm/App/View.elm b/modules/webapp/src/main/elm/App/View.elm new file mode 100644 index 00000000..81254579 --- /dev/null +++ b/modules/webapp/src/main/elm/App/View.elm @@ -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 ")" + ] + ] diff --git a/modules/webapp/src/main/elm/Data/Flags.elm b/modules/webapp/src/main/elm/Data/Flags.elm new file mode 100644 index 00000000..44ecb038 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/Flags.elm @@ -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 } diff --git a/modules/webapp/src/main/elm/Main.elm b/modules/webapp/src/main/elm/Main.elm new file mode 100644 index 00000000..72f5c58c --- /dev/null +++ b/modules/webapp/src/main/elm/Main.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Page.elm b/modules/webapp/src/main/elm/Page.elm new file mode 100644 index 00000000..b1e7bbd3 --- /dev/null +++ b/modules/webapp/src/main/elm/Page.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm new file mode 100644 index 00000000..c55df396 --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -0,0 +1,15 @@ +module Page.Home.Data exposing (..) + +import Http + +type alias Model = + { + } + +emptyModel: Model +emptyModel = + { + } + +type Msg + = Dummy diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm new file mode 100644 index 00000000..6d6c0d87 --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -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) diff --git a/modules/webapp/src/main/elm/Page/Home/View.elm b/modules/webapp/src/main/elm/Page/Home/View.elm new file mode 100644 index 00000000..35cff7ff --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Home/View.elm @@ -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"] + ] + ] diff --git a/modules/webapp/src/main/elm/Page/Login/Data.elm b/modules/webapp/src/main/elm/Page/Login/Data.elm new file mode 100644 index 00000000..f95ad50f --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Login/Data.elm @@ -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) diff --git a/modules/webapp/src/main/elm/Page/Login/Update.elm b/modules/webapp/src/main/elm/Page/Login/Update.elm new file mode 100644 index 00000000..41adf63d --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Login/Update.elm @@ -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 "" diff --git a/modules/webapp/src/main/elm/Page/Login/View.elm b/modules/webapp/src/main/elm/Page/Login/View.elm new file mode 100644 index 00000000..2c9efae4 --- /dev/null +++ b/modules/webapp/src/main/elm/Page/Login/View.elm @@ -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 [][] diff --git a/modules/webapp/src/main/elm/Ports.elm b/modules/webapp/src/main/elm/Ports.elm new file mode 100644 index 00000000..edfe2b1e --- /dev/null +++ b/modules/webapp/src/main/elm/Ports.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Util/Http.elm b/modules/webapp/src/main/elm/Util/Http.elm new file mode 100644 index 00000000..ec6be5ca --- /dev/null +++ b/modules/webapp/src/main/elm/Util/Http.elm @@ -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 + } diff --git a/modules/webapp/src/main/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css new file mode 100644 index 00000000..cf23206f --- /dev/null +++ b/modules/webapp/src/main/webjar/docspell.css @@ -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; + } +} diff --git a/modules/webapp/src/main/webjar/docspell.js b/modules/webapp/src/main/webjar/docspell.js new file mode 100644 index 00000000..0f3ce5b9 --- /dev/null +++ b/modules/webapp/src/main/webjar/docspell.js @@ -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(); +}); diff --git a/project/Dependencies.scala b/project/Dependencies.scala new file mode 100644 index 00000000..2e19e3f7 --- /dev/null +++ b/project/Dependencies.scala @@ -0,0 +1,121 @@ +import sbt._ + +object Dependencies { + + val BetterMonadicForVersion = "0.3.0" + val BitpeaceVersion = "0.4.0-M2" + val CirceVersion = "0.12.0-M4" + val DoobieVersion = "0.8.0-M1" + val FastparseVersion = "2.1.3" + val FlywayVersion = "6.0.0-beta2" + val Fs2Version = "1.1.0-M1" + val H2Version = "1.4.199" + val Http4sVersion = "0.21.0-M2" + val KindProjectorVersion = "0.10.3" + val Log4sVersion = "1.8.2" + val LogbackVersion = "1.2.3" + val MariaDbVersion = "2.4.2" + val MiniTestVersion = "2.5.0" + val PostgresVersion = "42.2.6" + val PureConfigVersion = "0.11.1" + val SqliteVersion = "3.28.0" + val TikaVersion = "1.20" + val javaxMailVersion = "1.6.2" + val dnsJavaVersion = "2.1.9" + val YamuscaVersion = "0.6.0-M2" + + + val fs2 = Seq( + "co.fs2" %% "fs2-core" % Fs2Version + ) + + val http4s = Seq( + "org.http4s" %% "http4s-blaze-server" % Http4sVersion, + "org.http4s" %% "http4s-circe" % Http4sVersion, + "org.http4s" %% "http4s-dsl" % Http4sVersion, + ) + + val circe = Seq( + "io.circe" %% "circe-generic" % CirceVersion, + "io.circe" %% "circe-parser" % CirceVersion + ) + + // https://github.com/Log4s/log4s;ASL 2.0 + val loggingApi = Seq( + "org.log4s" %% "log4s" % Log4sVersion + ) + + val logging = Seq( + "ch.qos.logback" % "logback-classic" % LogbackVersion % Runtime + ) + + // https://github.com/melrief/pureconfig + // MPL 2.0 + val pureconfig = Seq( + "com.github.pureconfig" %% "pureconfig" % PureConfigVersion + ) + + val fastparse = Seq( + "com.lihaoyi" %% "fastparse" % FastparseVersion + ) + + // https://github.com/h2database/h2database + // MPL 2.0 or EPL 1.0 + val h2 = Seq( + "com.h2database" % "h2" % H2Version + ) + val mariadb = Seq( + "org.mariadb.jdbc" % "mariadb-java-client" % MariaDbVersion //flyway doesn't work with newer mariadb + ) + val postgres = Seq( + "org.postgresql" % "postgresql" % PostgresVersion + ) + val sqlite = Seq( + "org.xerial" % "sqlite-jdbc" % SqliteVersion + ) + val databases = h2 ++ mariadb ++ postgres ++ sqlite + + // https://github.com/tpolecat/doobie + // MIT + val doobie = Seq( + "org.tpolecat" %% "doobie-core" % DoobieVersion, + "org.tpolecat" %% "doobie-hikari" % DoobieVersion + ) + + val bitpeace = Seq( + "com.github.eikek" %% "bitpeace-core" % BitpeaceVersion + ) + + // https://github.com/flyway/flyway + // ASL 2.0 + val flyway = Seq( + "org.flywaydb" % "flyway-core" % FlywayVersion + ) + + val javaxMail = Seq( + "javax.mail" % "javax.mail-api" % javaxMailVersion, + "com.sun.mail" % "javax.mail" % javaxMailVersion, + "dnsjava" % "dnsjava" % dnsJavaVersion intransitive() + ) + + val yamusca = Seq( + "com.github.eikek" %% "yamusca-core" % YamuscaVersion + ) + + val miniTest = Seq( + // https://github.com/monix/minitest + // Apache 2.0 + "io.monix" %% "minitest" % MiniTestVersion, + "io.monix" %% "minitest-laws" % MiniTestVersion + ).map(_ % Test) + + val kindProjectorPlugin = "org.typelevel" %% "kind-projector" % KindProjectorVersion + val betterMonadicFor = "com.olegpy" %% "better-monadic-for" % BetterMonadicForVersion + + val webjars = Seq( + "swagger-ui" -> "3.22.2", + "Semantic-UI" -> "2.4.1", + "jquery" -> "3.4.1" + ).map({case (a, v) => "org.webjars" % a % v }) + +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 00000000..c0bab049 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.2.8 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 00000000..723b2d44 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,8 @@ +addSbtPlugin("io.get-coursier" % "sbt-coursier" % "2.0.0-RC2") +addSbtPlugin("com.github.eikek" % "sbt-openapi-schema" % "0.5.0-SNAPSHOT") +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0") +addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.0") +addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.25") +addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.0-M2") +addSbtPlugin("com.47deg" % "sbt-microsites" % "0.9.2") diff --git a/version.sbt b/version.sbt new file mode 100644 index 00000000..57b0bcbc --- /dev/null +++ b/version.sbt @@ -0,0 +1 @@ +version in ThisBuild := "0.1.0-SNAPSHOT"