mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-04-04 10:29:34 +00:00
Merge pull request #129 from eikek/integration-endpoint
Integration endpoint
This commit is contained in:
commit
e2fc13673e
@ -6,6 +6,8 @@
|
||||
|
||||
- New feature "Scan Mailboxes". Docspell can now read mailboxes
|
||||
periodically to import your mails.
|
||||
- New feature "Integration Endpoint". Allows an admin to upload files
|
||||
to any collective using a separate endpoint.
|
||||
- Fix the `find-by-checksum` route that, given a sha256 checksum,
|
||||
returns whether there is such a file in docspell. It falsely
|
||||
returned `false` although documents existed.
|
||||
@ -21,7 +23,7 @@
|
||||
|
||||
### Configuration Changes
|
||||
|
||||
The joex component has new config sections:
|
||||
The joex and rest-server component have new config sections:
|
||||
|
||||
- Add `docspell.joex.mail-debug` flag to enable debugging e-mail
|
||||
related code. This is only useful if you encounter problems
|
||||
@ -30,6 +32,8 @@ The joex component has new config sections:
|
||||
configure the new scan-mailbox user task.
|
||||
- Add `docspell.joex.files` section that is the same as the
|
||||
corresponding section in the rest server config.
|
||||
- Add `docspell.rest-server.integration-endpoint` with sub-sections to
|
||||
configure an endpoint for uploading files for admin users.
|
||||
|
||||
### REST Api Changes
|
||||
|
||||
@ -37,6 +41,9 @@ The joex component has new config sections:
|
||||
- Add `/sec/email/settings/imap`
|
||||
- Add `/sec/usertask/scanmailbox` routes to configure one or more
|
||||
scan-mailbox tasks
|
||||
- The data used in `/sec/collective/settings` was extended with a
|
||||
boolean value to enable/disable the "integration endpoint" for a
|
||||
collective.
|
||||
|
||||
|
||||
## v0.5.0
|
||||
|
@ -14,7 +14,7 @@ trait OCollective[F[_]] {
|
||||
|
||||
def find(name: Ident): F[Option[RCollective]]
|
||||
|
||||
def updateLanguage(collective: Ident, lang: Language): F[AddResult]
|
||||
def updateSettings(collective: Ident, lang: OCollective.Settings): F[AddResult]
|
||||
|
||||
def listUser(collective: Ident): F[Vector[RUser]]
|
||||
|
||||
@ -45,6 +45,9 @@ object OCollective {
|
||||
type InsightData = QCollective.InsightData
|
||||
val insightData = QCollective.InsightData
|
||||
|
||||
type Settings = RCollective.Settings
|
||||
val Settings = RCollective.Settings
|
||||
|
||||
sealed trait PassChangeResult
|
||||
object PassChangeResult {
|
||||
case object UserNotFound extends PassChangeResult
|
||||
@ -85,9 +88,9 @@ object OCollective {
|
||||
def find(name: Ident): F[Option[RCollective]] =
|
||||
store.transact(RCollective.findById(name))
|
||||
|
||||
def updateLanguage(collective: Ident, lang: Language): F[AddResult] =
|
||||
def updateSettings(collective: Ident, sett: Settings): F[AddResult] =
|
||||
store
|
||||
.transact(RCollective.updateLanguage(collective, lang))
|
||||
.transact(RCollective.updateSettings(collective, sett))
|
||||
.attempt
|
||||
.map(AddResult.fromUpdate)
|
||||
|
||||
|
@ -88,7 +88,13 @@ object OSignup {
|
||||
for {
|
||||
id2 <- Ident.randomId[F]
|
||||
now <- Timestamp.current[F]
|
||||
c = RCollective(data.collName, CollectiveState.Active, Language.German, now)
|
||||
c = RCollective(
|
||||
data.collName,
|
||||
CollectiveState.Active,
|
||||
Language.German,
|
||||
true,
|
||||
now
|
||||
)
|
||||
u = RUser(
|
||||
id2,
|
||||
data.login,
|
||||
|
@ -40,6 +40,9 @@ object Implicits {
|
||||
implicit val caleventReader: ConfigReader[CalEvent] =
|
||||
ConfigReader[String].emap(reason(CalEvent.parse))
|
||||
|
||||
implicit val priorityReader: ConfigReader[Priority] =
|
||||
ConfigReader[String].emap(reason(Priority.fromString))
|
||||
|
||||
def reason[A: ClassTag](
|
||||
f: String => Either[String, A]
|
||||
): String => Either[FailureReason, A] =
|
||||
|
@ -85,6 +85,31 @@ docspell count the files uploaded through the web interface, just
|
||||
create a source (can be inactive) with that name (`webapp`).
|
||||
|
||||
|
||||
## Integration Endpoint
|
||||
|
||||
Another option for uploading files is the special *integration
|
||||
endpoint*. This endpoint allows an admin to upload files to any
|
||||
collective, that is known by name.
|
||||
|
||||
```
|
||||
/api/v1/open/integration/item/[collective-name]
|
||||
```
|
||||
|
||||
The endpoint is behind `/api/v1/open`, so this route is not protected
|
||||
by an authentication token (see [REST Api](../api) for more
|
||||
information). However, it can be protected via settings in the
|
||||
configuration file. The idea is that this endpoint is controlled by an
|
||||
administrator and not the user of the application. The admin can
|
||||
enable this endpoint and choose between some methods to protect it.
|
||||
Then the administrator can upload files to any collective. This might
|
||||
be useful to connect other trusted applications to docspell (that run
|
||||
on the same host or network).
|
||||
|
||||
The endpoint is disabled by default, an admin must change the
|
||||
`docspell.restserver.integration-endpoint.enabled` flag to `true` in
|
||||
the [configuration file](configure#rest-server).
|
||||
|
||||
|
||||
## The Request
|
||||
|
||||
This gives more details about the request for uploads. It is a http
|
||||
|
@ -618,10 +618,9 @@ paths:
|
||||
$ref: "#/components/schemas/CollectiveSettings"
|
||||
post:
|
||||
tags: [ Collective ]
|
||||
summary: Set document language of the collective
|
||||
summary: Update settings for a collective
|
||||
description: |
|
||||
Updates settings for a collective, which currently is just the
|
||||
document language.
|
||||
Updates settings for a collective.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
requestBody:
|
||||
@ -2692,10 +2691,16 @@ components:
|
||||
Settings for a collective.
|
||||
required:
|
||||
- language
|
||||
- integrationEnabled
|
||||
properties:
|
||||
language:
|
||||
type: string
|
||||
format: language
|
||||
integrationEnabled:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether the collective has the integration endpoint
|
||||
enabled.
|
||||
SourceList:
|
||||
description: |
|
||||
A list of sources.
|
||||
|
@ -31,6 +31,52 @@ docspell.server {
|
||||
session-valid = "5 minutes"
|
||||
}
|
||||
|
||||
# This endpoint allows to upload files to any collective. The
|
||||
# intention is that local software integrates with docspell more
|
||||
# easily. Therefore the endpoint is not protected by the usual
|
||||
# means.
|
||||
#
|
||||
# For security reasons, this endpoint is disabled by default. If
|
||||
# enabled, you can choose from some ways to protect it. It may be a
|
||||
# good idea to further protect this endpoint using a firewall, such
|
||||
# that outside traffic is not routed.
|
||||
#
|
||||
# NOTE: If all protection methods are disabled, the endpoint is not
|
||||
# protected at all!
|
||||
integration-endpoint {
|
||||
enabled = false
|
||||
|
||||
# The priority to use when submitting files through this endpoint.
|
||||
priority = "low"
|
||||
|
||||
# IPv4 addresses to allow access. An empty list, if enabled,
|
||||
# prohibits all requests. IP addresses may be specified as simple
|
||||
# globs: a part marked as `*' matches any octet, like in
|
||||
# `192.168.*.*`. The `127.0.0.1' (the default) matches the
|
||||
# loopback address.
|
||||
allowed-ips {
|
||||
enabled = true
|
||||
ips = [ "127.0.0.1" ]
|
||||
}
|
||||
|
||||
# Requests are expected to use http basic auth when uploading
|
||||
# files.
|
||||
http-basic {
|
||||
enabled = false
|
||||
realm = "Docspell Integration"
|
||||
user = "docspell-int"
|
||||
password = "docspell-int"
|
||||
}
|
||||
|
||||
# Requests are expected to supply some specific header when
|
||||
# uploading files.
|
||||
http-header {
|
||||
enabled = false
|
||||
header-name = "Docspell-Integration"
|
||||
header-value = "some-secret"
|
||||
}
|
||||
}
|
||||
|
||||
# Configuration for the backend.
|
||||
backend {
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
package docspell.restserver
|
||||
|
||||
import java.net.InetAddress
|
||||
import docspell.backend.auth.Login
|
||||
import docspell.backend.{Config => BackendConfig}
|
||||
import docspell.common._
|
||||
@ -10,10 +11,42 @@ case class Config(
|
||||
baseUrl: LenientUri,
|
||||
bind: Config.Bind,
|
||||
backend: BackendConfig,
|
||||
auth: Login.Config
|
||||
auth: Login.Config,
|
||||
integrationEndpoint: Config.IntegrationEndpoint
|
||||
)
|
||||
|
||||
object Config {
|
||||
|
||||
case class Bind(address: String, port: Int)
|
||||
|
||||
case class IntegrationEndpoint(
|
||||
enabled: Boolean,
|
||||
priority: Priority,
|
||||
allowedIps: IntegrationEndpoint.AllowedIps,
|
||||
httpBasic: IntegrationEndpoint.HttpBasic,
|
||||
httpHeader: IntegrationEndpoint.HttpHeader
|
||||
)
|
||||
|
||||
object IntegrationEndpoint {
|
||||
case class HttpBasic(enabled: Boolean, realm: String, user: String, password: String)
|
||||
case class HttpHeader(enabled: Boolean, headerName: String, headerValue: String)
|
||||
case class AllowedIps(enabled: Boolean, ips: Set[String]) {
|
||||
|
||||
def containsAddress(inet: InetAddress): Boolean = {
|
||||
val ip = inet.getHostAddress
|
||||
lazy val ipParts = ip.split('.')
|
||||
|
||||
def checkSingle(pattern: String): Boolean =
|
||||
pattern == ip || (inet.isLoopbackAddress && pattern == "127.0.0.1") || (pattern
|
||||
.split('.')
|
||||
.zip(ipParts)
|
||||
.foldLeft(true) {
|
||||
case (r, (a, b)) =>
|
||||
r && (a == "*" || a == b)
|
||||
})
|
||||
|
||||
ips.exists(checkSingle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -83,10 +83,11 @@ object RestServer {
|
||||
|
||||
def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
|
||||
Router(
|
||||
"auth" -> LoginRoutes.login(restApp.backend.login, cfg),
|
||||
"signup" -> RegisterRoutes(restApp.backend, cfg),
|
||||
"upload" -> UploadRoutes.open(restApp.backend, cfg),
|
||||
"checkfile" -> CheckFileRoutes.open(restApp.backend)
|
||||
"auth" -> LoginRoutes.login(restApp.backend.login, cfg),
|
||||
"signup" -> RegisterRoutes(restApp.backend, cfg),
|
||||
"upload" -> UploadRoutes.open(restApp.backend, cfg),
|
||||
"checkfile" -> CheckFileRoutes.open(restApp.backend),
|
||||
"integration" -> IntegrationEndpointRoutes.open(restApp.backend, cfg)
|
||||
)
|
||||
|
||||
def redirectTo[F[_]: Effect](path: String): HttpRoutes[F] = {
|
||||
|
@ -0,0 +1,29 @@
|
||||
package docspell.restserver.http4s
|
||||
|
||||
import fs2.{Pure, Stream}
|
||||
import fs2.text.utf8Encode
|
||||
import org.http4s._
|
||||
import org.http4s.headers._
|
||||
|
||||
object Responses {
|
||||
|
||||
private[this] val pureForbidden: Response[Pure] =
|
||||
Response(
|
||||
Status.Forbidden,
|
||||
body = Stream("Forbidden").through(utf8Encode),
|
||||
headers = Headers(`Content-Type`(MediaType.text.plain, Charset.`UTF-8`) :: Nil)
|
||||
)
|
||||
|
||||
private[this] val pureUnauthorized: Response[Pure] =
|
||||
Response(
|
||||
Status.Unauthorized,
|
||||
body = Stream("Unauthorized").through(utf8Encode),
|
||||
headers = Headers(`Content-Type`(MediaType.text.plain, Charset.`UTF-8`) :: Nil)
|
||||
)
|
||||
|
||||
def forbidden[F[_]]: Response[F] =
|
||||
pureForbidden.copy(body = pureForbidden.body.covary[F])
|
||||
|
||||
def unauthorized[F[_]]: Response[F] =
|
||||
pureUnauthorized.copy(body = pureUnauthorized.body.covary[F])
|
||||
}
|
@ -4,6 +4,7 @@ import cats.effect._
|
||||
import cats.implicits._
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.ops.OCollective
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.conv.Conversions
|
||||
import docspell.restserver.http4s._
|
||||
@ -28,16 +29,17 @@ object CollectiveRoutes {
|
||||
case req @ POST -> Root / "settings" =>
|
||||
for {
|
||||
settings <- req.as[CollectiveSettings]
|
||||
sett = OCollective.Settings(settings.language, settings.integrationEnabled)
|
||||
res <-
|
||||
backend.collective
|
||||
.updateLanguage(user.account.collective, settings.language)
|
||||
resp <- Ok(Conversions.basicResult(res, "Language updated."))
|
||||
.updateSettings(user.account.collective, sett)
|
||||
resp <- Ok(Conversions.basicResult(res, "Settings updated."))
|
||||
} yield resp
|
||||
|
||||
case GET -> Root / "settings" =>
|
||||
for {
|
||||
collDb <- backend.collective.find(user.account.collective)
|
||||
sett = collDb.map(c => CollectiveSettings(c.language))
|
||||
sett = collDb.map(c => CollectiveSettings(c.language, c.integrationEnabled))
|
||||
resp <- sett.toResponse()
|
||||
} yield resp
|
||||
|
||||
|
@ -0,0 +1,150 @@
|
||||
package docspell.restserver.routes
|
||||
|
||||
import cats.data.{EitherT, OptionT}
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.common._
|
||||
import docspell.restserver.Config
|
||||
import docspell.restserver.conv.Conversions._
|
||||
import docspell.restserver.http4s.Responses
|
||||
import org.http4s._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.EntityDecoder._
|
||||
import org.http4s.headers.{Authorization, `WWW-Authenticate`}
|
||||
import org.http4s.multipart.Multipart
|
||||
import org.http4s.util.CaseInsensitiveString
|
||||
import org.log4s.getLogger
|
||||
|
||||
object IntegrationEndpointRoutes {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
def open[F[_]: Effect](backend: BackendApp[F], cfg: Config): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
HttpRoutes.of {
|
||||
case req @ POST -> Root / "item" / Ident(collective) =>
|
||||
(for {
|
||||
_ <- checkEnabled(cfg.integrationEndpoint)
|
||||
_ <- authRequest(req, cfg.integrationEndpoint)
|
||||
_ <- lookupCollective(collective, backend)
|
||||
res <- EitherT.liftF[F, Response[F], Response[F]](
|
||||
uploadFile(collective, backend, cfg, dsl)(req)
|
||||
)
|
||||
} yield res).fold(identity, identity)
|
||||
}
|
||||
}
|
||||
|
||||
def checkEnabled[F[_]: Effect](
|
||||
cfg: Config.IntegrationEndpoint
|
||||
): EitherT[F, Response[F], Unit] =
|
||||
EitherT.cond[F](cfg.enabled, (), Response.notFound[F])
|
||||
|
||||
def authRequest[F[_]: Effect](
|
||||
req: Request[F],
|
||||
cfg: Config.IntegrationEndpoint
|
||||
): EitherT[F, Response[F], Unit] = {
|
||||
val service =
|
||||
SourceIpAuth[F](cfg.allowedIps) <+> HeaderAuth(cfg.httpHeader) <+> HttpBasicAuth(
|
||||
cfg.httpBasic
|
||||
)
|
||||
service.run(req).toLeft(())
|
||||
}
|
||||
|
||||
def lookupCollective[F[_]: Effect](
|
||||
coll: Ident,
|
||||
backend: BackendApp[F]
|
||||
): EitherT[F, Response[F], Unit] =
|
||||
for {
|
||||
opt <- EitherT.liftF(backend.collective.find(coll))
|
||||
res <- EitherT.cond[F](opt.exists(_.integrationEnabled), (), Response.notFound[F])
|
||||
} yield res
|
||||
|
||||
def uploadFile[F[_]: Effect](
|
||||
coll: Ident,
|
||||
backend: BackendApp[F],
|
||||
cfg: Config,
|
||||
dsl: Http4sDsl[F]
|
||||
)(
|
||||
req: Request[F]
|
||||
): F[Response[F]] = {
|
||||
import dsl._
|
||||
for {
|
||||
multipart <- req.as[Multipart[F]]
|
||||
updata <- readMultipart(
|
||||
multipart,
|
||||
logger,
|
||||
cfg.integrationEndpoint.priority,
|
||||
cfg.backend.files.validMimeTypes
|
||||
)
|
||||
account = AccountId(coll, Ident.unsafe("docspell-system"))
|
||||
result <- backend.upload.submit(updata, account, true)
|
||||
res <- Ok(basicResult(result))
|
||||
} yield res
|
||||
}
|
||||
|
||||
object HeaderAuth {
|
||||
def apply[F[_]: Effect](cfg: Config.IntegrationEndpoint.HttpHeader): HttpRoutes[F] =
|
||||
if (cfg.enabled) checkHeader(cfg)
|
||||
else HttpRoutes.empty[F]
|
||||
|
||||
def checkHeader[F[_]: Effect](
|
||||
cfg: Config.IntegrationEndpoint.HttpHeader
|
||||
): HttpRoutes[F] =
|
||||
HttpRoutes { req =>
|
||||
val h = req.headers.find(_.name == CaseInsensitiveString(cfg.headerName))
|
||||
if (h.exists(_.value == cfg.headerValue)) OptionT.none[F, Response[F]]
|
||||
else OptionT.pure(Responses.forbidden[F])
|
||||
}
|
||||
}
|
||||
|
||||
object SourceIpAuth {
|
||||
def apply[F[_]: Effect](cfg: Config.IntegrationEndpoint.AllowedIps): HttpRoutes[F] =
|
||||
if (cfg.enabled) checkIps(cfg)
|
||||
else HttpRoutes.empty[F]
|
||||
|
||||
def checkIps[F[_]: Effect](
|
||||
cfg: Config.IntegrationEndpoint.AllowedIps
|
||||
): HttpRoutes[F] =
|
||||
HttpRoutes { req =>
|
||||
//The `req.from' take the X-Forwarded-For header into account,
|
||||
//which is not desirable here. The `http-header' auth config
|
||||
//can be used to authenticate based on headers.
|
||||
val from = req.remote.flatMap(remote => Option(remote.getAddress))
|
||||
if (from.exists(cfg.containsAddress)) OptionT.none[F, Response[F]]
|
||||
else OptionT.pure(Responses.forbidden[F])
|
||||
}
|
||||
}
|
||||
|
||||
object HttpBasicAuth {
|
||||
def apply[F[_]: Effect](cfg: Config.IntegrationEndpoint.HttpBasic): HttpRoutes[F] =
|
||||
if (cfg.enabled) checkHttpBasic(cfg)
|
||||
else HttpRoutes.empty[F]
|
||||
|
||||
def checkHttpBasic[F[_]: Effect](
|
||||
cfg: Config.IntegrationEndpoint.HttpBasic
|
||||
): HttpRoutes[F] =
|
||||
HttpRoutes { req =>
|
||||
req.headers.get(Authorization) match {
|
||||
case Some(auth) =>
|
||||
auth.credentials match {
|
||||
case BasicCredentials(user, pass)
|
||||
if user == cfg.user && pass == cfg.password =>
|
||||
OptionT.none[F, Response[F]]
|
||||
case _ =>
|
||||
OptionT.pure(Responses.forbidden[F])
|
||||
}
|
||||
case None =>
|
||||
OptionT.pure(
|
||||
Responses
|
||||
.unauthorized[F]
|
||||
.withHeaders(
|
||||
`WWW-Authenticate`(Challenge("Basic", cfg.realm))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -5,7 +5,6 @@ import cats.implicits._
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.common.{Ident, Priority}
|
||||
import docspell.restapi.model.BasicResult
|
||||
import docspell.restserver.Config
|
||||
import docspell.restserver.conv.Conversions._
|
||||
import docspell.restserver.http4s.ResponseGenerator
|
||||
@ -40,10 +39,6 @@ object UploadRoutes {
|
||||
result <- backend.upload.submit(updata, user.account, true)
|
||||
res <- Ok(basicResult(result))
|
||||
} yield res
|
||||
|
||||
case GET -> Root / "checkfile" / checksum =>
|
||||
Ok(BasicResult(false, s"not implemented $checksum"))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,9 +59,6 @@ object UploadRoutes {
|
||||
result <- backend.upload.submit(updata, id, true)
|
||||
res <- Ok(basicResult(result))
|
||||
} yield res
|
||||
|
||||
case GET -> Root / "checkfile" / Ident(id) / checksum =>
|
||||
Ok(BasicResult(false, s"not implemented $id $checksum"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,8 @@ case class Flags(
|
||||
appName: String,
|
||||
baseUrl: LenientUri,
|
||||
signupMode: SignupConfig.Mode,
|
||||
docspellAssetPath: String
|
||||
docspellAssetPath: String,
|
||||
integrationEnabled: Boolean
|
||||
)
|
||||
|
||||
object Flags {
|
||||
@ -21,7 +22,8 @@ object Flags {
|
||||
cfg.appName,
|
||||
cfg.baseUrl,
|
||||
cfg.backend.signup.mode,
|
||||
s"/app/assets/docspell-webapp/${BuildInfo.version}"
|
||||
s"/app/assets/docspell-webapp/${BuildInfo.version}",
|
||||
cfg.integrationEndpoint.enabled
|
||||
)
|
||||
|
||||
implicit val jsonEncoder: Encoder[Flags] =
|
||||
|
@ -0,0 +1,7 @@
|
||||
ALTER TABLE `collective`
|
||||
ADD COLUMN (`integration_enabled` BOOLEAN);
|
||||
|
||||
UPDATE `collective` SET `integration_enabled` = true;
|
||||
|
||||
ALTER TABLE `collective`
|
||||
MODIFY `integration_enabled` BOOLEAN NOT NULL;
|
@ -0,0 +1,7 @@
|
||||
ALTER TABLE "collective"
|
||||
ADD COLUMN "integration_enabled" BOOLEAN;
|
||||
|
||||
UPDATE "collective" SET "integration_enabled" = true;
|
||||
|
||||
ALTER TABLE "collective"
|
||||
ALTER COLUMN "integration_enabled" SET NOT NULL;
|
@ -11,6 +11,7 @@ case class RCollective(
|
||||
id: Ident,
|
||||
state: CollectiveState,
|
||||
language: Language,
|
||||
integrationEnabled: Boolean,
|
||||
created: Timestamp
|
||||
)
|
||||
|
||||
@ -20,12 +21,13 @@ object RCollective {
|
||||
|
||||
object Columns {
|
||||
|
||||
val id = Column("cid")
|
||||
val state = Column("state")
|
||||
val language = Column("doclang")
|
||||
val created = Column("created")
|
||||
val id = Column("cid")
|
||||
val state = Column("state")
|
||||
val language = Column("doclang")
|
||||
val integration = Column("integration_enabled")
|
||||
val created = Column("created")
|
||||
|
||||
val all = List(id, state, language, created)
|
||||
val all = List(id, state, language, integration, created)
|
||||
}
|
||||
|
||||
import Columns._
|
||||
@ -34,7 +36,7 @@ object RCollective {
|
||||
val sql = insertRow(
|
||||
table,
|
||||
Columns.all,
|
||||
fr"${value.id},${value.state},${value.language},${value.created}"
|
||||
fr"${value.id},${value.state},${value.language},${value.integrationEnabled},${value.created}"
|
||||
)
|
||||
sql.update.run
|
||||
}
|
||||
@ -56,6 +58,16 @@ object RCollective {
|
||||
def updateLanguage(cid: Ident, lang: Language): ConnectionIO[Int] =
|
||||
updateRow(table, id.is(cid), language.setTo(lang)).update.run
|
||||
|
||||
def updateSettings(cid: Ident, settings: Settings): ConnectionIO[Int] =
|
||||
updateRow(
|
||||
table,
|
||||
id.is(cid),
|
||||
commas(
|
||||
language.setTo(settings.language),
|
||||
integration.setTo(settings.integrationEnabled)
|
||||
)
|
||||
).update.run
|
||||
|
||||
def findById(cid: Ident): ConnectionIO[Option[RCollective]] = {
|
||||
val sql = selectSimple(all, table, id.is(cid))
|
||||
sql.query[RCollective].option
|
||||
@ -75,4 +87,6 @@ object RCollective {
|
||||
val sql = selectSimple(all, table, Fragment.empty) ++ orderBy(order(Columns).f)
|
||||
sql.query[RCollective].stream
|
||||
}
|
||||
|
||||
case class Settings(language: Language, integrationEnabled: Boolean)
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
module Comp.Settings exposing
|
||||
module Comp.CollectiveSettingsForm exposing
|
||||
( Model
|
||||
, Msg
|
||||
, getSettings
|
||||
@ -13,10 +13,12 @@ import Data.Flags exposing (Flags)
|
||||
import Data.Language exposing (Language)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (onCheck)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ langModel : Comp.Dropdown.Model Language
|
||||
, intEnabled : Bool
|
||||
, initSettings : CollectiveSettings
|
||||
}
|
||||
|
||||
@ -39,6 +41,7 @@ init settings =
|
||||
, options = Data.Language.all
|
||||
, selected = Just lang
|
||||
}
|
||||
, intEnabled = settings.integrationEnabled
|
||||
, initSettings = settings
|
||||
}
|
||||
|
||||
@ -51,10 +54,12 @@ getSettings model =
|
||||
|> Maybe.map Data.Language.toIso3
|
||||
|> Maybe.withDefault model.initSettings.language
|
||||
)
|
||||
model.intEnabled
|
||||
|
||||
|
||||
type Msg
|
||||
= LangDropdownMsg (Comp.Dropdown.Msg Language)
|
||||
| ToggleIntegrationEndpoint
|
||||
|
||||
|
||||
update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings )
|
||||
@ -77,12 +82,42 @@ update _ msg model =
|
||||
in
|
||||
( nextModel, Cmd.map LangDropdownMsg c2, nextSettings )
|
||||
|
||||
ToggleIntegrationEndpoint ->
|
||||
let
|
||||
nextModel =
|
||||
{ model | intEnabled = not model.intEnabled }
|
||||
in
|
||||
( nextModel, Cmd.none, Just (getSettings nextModel) )
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
|
||||
view : Flags -> Model -> Html Msg
|
||||
view flags model =
|
||||
div [ class "ui form" ]
|
||||
[ div [ class "field" ]
|
||||
[ label [] [ text "Document Language" ]
|
||||
, Html.map LangDropdownMsg (Comp.Dropdown.view model.langModel)
|
||||
, span [ class "small-info" ]
|
||||
[ text "The language of your documents. This helps text recognition (OCR) and text analysis."
|
||||
]
|
||||
]
|
||||
, div
|
||||
[ classList
|
||||
[ ( "field", True )
|
||||
, ( "invisible hidden", not flags.config.integrationEnabled )
|
||||
]
|
||||
]
|
||||
[ div [ class "ui checkbox" ]
|
||||
[ input
|
||||
[ type_ "checkbox"
|
||||
, onCheck (\_ -> ToggleIntegrationEndpoint)
|
||||
, checked model.intEnabled
|
||||
]
|
||||
[]
|
||||
, label [] [ text "Enable integration endpoint" ]
|
||||
, span [ class "small-info" ]
|
||||
[ text "The integration endpoint allows (local) applications to submit files. "
|
||||
, text "You can choose to disable it for your collective."
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
@ -14,6 +14,7 @@ type alias Config =
|
||||
, baseUrl : String
|
||||
, signupMode : String
|
||||
, docspellAssetPath : String
|
||||
, integrationEnabled : Bool
|
||||
}
|
||||
|
||||
|
||||
|
@ -8,7 +8,7 @@ module Page.CollectiveSettings.Data exposing
|
||||
import Api.Model.BasicResult exposing (BasicResult)
|
||||
import Api.Model.CollectiveSettings exposing (CollectiveSettings)
|
||||
import Api.Model.ItemInsights exposing (ItemInsights)
|
||||
import Comp.Settings
|
||||
import Comp.CollectiveSettingsForm
|
||||
import Comp.SourceManage
|
||||
import Comp.UserManage
|
||||
import Http
|
||||
@ -18,7 +18,7 @@ type alias Model =
|
||||
{ currentTab : Maybe Tab
|
||||
, sourceModel : Comp.SourceManage.Model
|
||||
, userModel : Comp.UserManage.Model
|
||||
, settingsModel : Comp.Settings.Model
|
||||
, settingsModel : Comp.CollectiveSettingsForm.Model
|
||||
, insights : ItemInsights
|
||||
, submitResult : Maybe BasicResult
|
||||
}
|
||||
@ -29,7 +29,7 @@ emptyModel =
|
||||
{ currentTab = Just InsightsTab
|
||||
, sourceModel = Comp.SourceManage.emptyModel
|
||||
, userModel = Comp.UserManage.emptyModel
|
||||
, settingsModel = Comp.Settings.init Api.Model.CollectiveSettings.empty
|
||||
, settingsModel = Comp.CollectiveSettingsForm.init Api.Model.CollectiveSettings.empty
|
||||
, insights = Api.Model.ItemInsights.empty
|
||||
, submitResult = Nothing
|
||||
}
|
||||
@ -46,7 +46,7 @@ type Msg
|
||||
= SetTab Tab
|
||||
| SourceMsg Comp.SourceManage.Msg
|
||||
| UserMsg Comp.UserManage.Msg
|
||||
| SettingsMsg Comp.Settings.Msg
|
||||
| SettingsFormMsg Comp.CollectiveSettingsForm.Msg
|
||||
| Init
|
||||
| GetInsightsResp (Result Http.Error ItemInsights)
|
||||
| CollectiveSettingsResp (Result Http.Error CollectiveSettings)
|
||||
|
@ -2,7 +2,7 @@ module Page.CollectiveSettings.Update exposing (update)
|
||||
|
||||
import Api
|
||||
import Api.Model.BasicResult exposing (BasicResult)
|
||||
import Comp.Settings
|
||||
import Comp.CollectiveSettingsForm
|
||||
import Comp.SourceManage
|
||||
import Comp.UserManage
|
||||
import Data.Flags exposing (Flags)
|
||||
@ -45,10 +45,10 @@ update flags msg model =
|
||||
in
|
||||
( { model | userModel = m2 }, Cmd.map UserMsg c2 )
|
||||
|
||||
SettingsMsg m ->
|
||||
SettingsFormMsg m ->
|
||||
let
|
||||
( m2, c2, msett ) =
|
||||
Comp.Settings.update flags m model.settingsModel
|
||||
Comp.CollectiveSettingsForm.update flags m model.settingsModel
|
||||
|
||||
cmd =
|
||||
case msett of
|
||||
@ -58,7 +58,9 @@ update flags msg model =
|
||||
Just sett ->
|
||||
Api.setCollectiveSettings flags sett SubmitResp
|
||||
in
|
||||
( { model | settingsModel = m2, submitResult = Nothing }, Cmd.batch [ cmd, Cmd.map SettingsMsg c2 ] )
|
||||
( { model | settingsModel = m2, submitResult = Nothing }
|
||||
, Cmd.batch [ cmd, Cmd.map SettingsFormMsg c2 ]
|
||||
)
|
||||
|
||||
Init ->
|
||||
( { model | submitResult = Nothing }
|
||||
@ -75,7 +77,7 @@ update flags msg model =
|
||||
( model, Cmd.none )
|
||||
|
||||
CollectiveSettingsResp (Ok data) ->
|
||||
( { model | settingsModel = Comp.Settings.init data }, Cmd.none )
|
||||
( { model | settingsModel = Comp.CollectiveSettingsForm.init data }, Cmd.none )
|
||||
|
||||
CollectiveSettingsResp (Err _) ->
|
||||
( model, Cmd.none )
|
||||
|
@ -1,7 +1,7 @@
|
||||
module Page.CollectiveSettings.View exposing (view)
|
||||
|
||||
import Api.Model.NameCount exposing (NameCount)
|
||||
import Comp.Settings
|
||||
import Comp.CollectiveSettingsForm
|
||||
import Comp.SourceManage
|
||||
import Comp.UserManage
|
||||
import Data.Flags exposing (Flags)
|
||||
@ -41,8 +41,8 @@ view flags model =
|
||||
[ classActive (model.currentTab == Just SettingsTab) "link icon item"
|
||||
, onClick (SetTab SettingsTab)
|
||||
]
|
||||
[ i [ class "language icon" ] []
|
||||
, text "Document Language"
|
||||
[ i [ class "cog icon" ] []
|
||||
, text "Settings"
|
||||
]
|
||||
, div
|
||||
[ classActive (model.currentTab == Just UserTab) "link icon item"
|
||||
@ -67,7 +67,7 @@ view flags model =
|
||||
viewInsights model
|
||||
|
||||
Just SettingsTab ->
|
||||
viewSettings model
|
||||
viewSettings flags model
|
||||
|
||||
Nothing ->
|
||||
[]
|
||||
@ -176,42 +176,25 @@ viewUsers model =
|
||||
]
|
||||
|
||||
|
||||
viewSettings : Model -> List (Html Msg)
|
||||
viewSettings model =
|
||||
[ div [ class "ui grid" ]
|
||||
[ div [ class "row" ]
|
||||
[ div [ class "sixteen wide colum" ]
|
||||
[ h2 [ class "ui header" ]
|
||||
[ i [ class "ui language icon" ] []
|
||||
, div [ class "content" ]
|
||||
[ text "Document Language"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "row" ]
|
||||
[ div [ class "six wide column" ]
|
||||
[ div [ class "ui basic segment" ]
|
||||
[ text "The language of your documents. This helps text recognition (OCR) and text analysis."
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "row" ]
|
||||
[ div [ class "six wide column" ]
|
||||
[ Html.map SettingsMsg (Comp.Settings.view model.settingsModel)
|
||||
, div
|
||||
[ classList
|
||||
[ ( "ui message", True )
|
||||
, ( "hidden", Util.Maybe.isEmpty model.submitResult )
|
||||
, ( "success", Maybe.map .success model.submitResult |> Maybe.withDefault False )
|
||||
, ( "error", Maybe.map .success model.submitResult |> Maybe.map not |> Maybe.withDefault False )
|
||||
]
|
||||
]
|
||||
[ Maybe.map .message model.submitResult
|
||||
|> Maybe.withDefault ""
|
||||
|> text
|
||||
]
|
||||
]
|
||||
viewSettings : Flags -> Model -> List (Html Msg)
|
||||
viewSettings flags model =
|
||||
[ h2 [ class "ui header" ]
|
||||
[ i [ class "cog icon" ] []
|
||||
, text "Settings"
|
||||
]
|
||||
, div [ class "ui segment" ]
|
||||
[ Html.map SettingsFormMsg (Comp.CollectiveSettingsForm.view flags model.settingsModel)
|
||||
]
|
||||
, div
|
||||
[ classList
|
||||
[ ( "ui message", True )
|
||||
, ( "hidden", Util.Maybe.isEmpty model.submitResult )
|
||||
, ( "success", Maybe.map .success model.submitResult |> Maybe.withDefault False )
|
||||
, ( "error", Maybe.map .success model.submitResult |> Maybe.map not |> Maybe.withDefault False )
|
||||
]
|
||||
]
|
||||
[ Maybe.map .message model.submitResult
|
||||
|> Maybe.withDefault ""
|
||||
|> text
|
||||
]
|
||||
]
|
||||
|
Loading…
x
Reference in New Issue
Block a user