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