Initial application stub

This commit is contained in:
Eike Kettner 2019-07-17 22:03:10 +02:00
commit 6154e6a387
54 changed files with 2447 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
target/
dev.conf
elm-stuff

1
README.md Normal file
View File

@ -0,0 +1 @@
# Docspell

257
build.sbt Normal file
View File

@ -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 <eike.kettner@posteo.de>",
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)
}

View File

@ -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
<https://github.com/joelparkerhenderson/architecture_decision_record>
* 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.

View File

@ -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.

View File

@ -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

72
doc/adr/template.md Normal file
View File

@ -0,0 +1,72 @@
# [short title of solved problem and solution]
* Status: [proposed | rejected | accepted | deprecated | … | superseded by [ADR-0005](0005-example.md)] <!-- optional -->
* Deciders: [list everyone involved in the decision] <!-- optional -->
* Date: [YYYY-MM-DD when the decision was last updated] <!-- optional -->
Technical Story: [description | ticket/issue URL] <!-- optional -->
## 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 <!-- optional -->
* [driver 1, e.g., a force, facing concern, …]
* [driver 2, e.g., a force, facing concern, …]
* … <!-- numbers of drivers can vary -->
## Considered Options
* [option 1]
* [option 2]
* [option 3]
* … <!-- numbers of options can vary -->
## 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 <!-- optional -->
* [e.g., improvement of quality attribute satisfaction, follow-up decisions required, …]
* …
### Negative Consequences <!-- optional -->
* [e.g., compromising quality attribute, follow-up decisions required, …]
* …
## Pros and Cons of the Options <!-- optional -->
### [option 1]
[example | description | pointer to more information | …] <!-- optional -->
* Good, because [argument a]
* Good, because [argument b]
* Bad, because [argument c]
* … <!-- numbers of pros and cons can vary -->
### [option 2]
[example | description | pointer to more information | …] <!-- optional -->
* Good, because [argument a]
* Good, because [argument b]
* Bad, because [argument c]
* … <!-- numbers of pros and cons can vary -->
### [option 3]
[example | description | pointer to more information | …] <!-- optional -->
* Good, because [argument a]
* Good, because [argument b]
* Bad, because [argument c]
* … <!-- numbers of pros and cons can vary -->
## Links <!-- optional -->
* [Link type] [Link to ADR] <!-- example: Refined by [ADR-0005](0005-example.md) -->
* … <!-- numbers of links can vary -->

110
doc/dev.md Normal file
View File

@ -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

1
doc/install.md Normal file
View File

@ -0,0 +1 @@
# Installation and Setup

69
doc/user.md Normal file
View File

@ -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

29
elm.json Normal file
View File

@ -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": {}
}
}

View File

@ -0,0 +1,3 @@
docspell.joex {
}

View 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)
}

View 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("")))
}
}
}

View File

@ -0,0 +1,6 @@
package docspell.joex
trait JoexApp[F[_]] {
def init: F[Unit]
}

View 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)))
}

View 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
}

View 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)
}
}

View 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

View 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

View 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>

View File

@ -0,0 +1,3 @@
docspell.restserver {
}

View File

@ -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)
}

View File

@ -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("")))
}
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,6 @@
package docspell.restserver
trait RestApp[F[_]] {
def init: F[Unit]
}

View File

@ -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)))
}

View File

@ -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
}

View File

@ -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
})
}
}
}
}

View File

@ -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))
}

View 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>

View 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>

View 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
}
}

View 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

View 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

View 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)

View 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 ")"
]
]

View 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 }

View 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

View 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

View File

@ -0,0 +1,15 @@
module Page.Home.Data exposing (..)
import Http
type alias Model =
{
}
emptyModel: Model
emptyModel =
{
}
type Msg
= Dummy

View 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)

View 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"]
]
]

View 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)

View 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 ""

View 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 [][]

View 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

View 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
}

View 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;
}
}

View 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();
});

121
project/Dependencies.scala Normal file
View File

@ -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 })
}

1
project/build.properties Normal file
View File

@ -0,0 +1 @@
sbt.version=1.2.8

8
project/plugins.sbt Normal file
View File

@ -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")

1
version.sbt Normal file
View File

@ -0,0 +1 @@
version in ThisBuild := "0.1.0-SNAPSHOT"