mirror of
				https://github.com/TheAnachronism/docspell.git
				synced 2025-10-30 21:40:12 +00:00 
			
		
		
		
	Merge pull request #129 from eikek/integration-endpoint
Integration endpoint
This commit is contained in:
		| @@ -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 | ||||
|         ] | ||||
|     ] | ||||
|   | ||||
		Reference in New Issue
	
	Block a user