From 892002b35168ef939d68012d6bfed0d5a4850b98 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 23 May 2020 10:12:58 +0200 Subject: [PATCH 1/4] Remove unused routes The functionality has been implemented in `CheckFileRoutes`. --- .../scala/docspell/restserver/routes/UploadRoutes.scala | 8 -------- 1 file changed, 8 deletions(-) diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/UploadRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/UploadRoutes.scala index 0689325f..d48d5159 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/UploadRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/UploadRoutes.scala @@ -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")) } } } From f74f8e5198701fc3f46308be6bf09f75f74d7759 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 23 May 2020 12:57:25 +0200 Subject: [PATCH 2/4] Add new way for uploading files to any collective Applications running next to docspell may want a way to upload files to any collective for integration purposes. This endpoint can be used for this. It is disabled by default and can be enabled via the configuration file. --- .../common/pureconfig/Implicits.scala | 3 + .../src/main/resources/reference.conf | 46 ++++++ .../scala/docspell/restserver/Config.scala | 35 +++- .../docspell/restserver/RestServer.scala | 9 +- .../restserver/http4s/Responses.scala | 29 ++++ .../routes/IntegrationEndpointRoutes.scala | 150 ++++++++++++++++++ 6 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala diff --git a/modules/common/src/main/scala/docspell/common/pureconfig/Implicits.scala b/modules/common/src/main/scala/docspell/common/pureconfig/Implicits.scala index 8fce1720..b6b3754d 100644 --- a/modules/common/src/main/scala/docspell/common/pureconfig/Implicits.scala +++ b/modules/common/src/main/scala/docspell/common/pureconfig/Implicits.scala @@ -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] = diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index 4e165dc5..edbe3c53 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -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 { diff --git a/modules/restserver/src/main/scala/docspell/restserver/Config.scala b/modules/restserver/src/main/scala/docspell/restserver/Config.scala index 95688331..db66c98b 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/Config.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/Config.scala @@ -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) + } + } + } } diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 02008552..169dfb47 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -82,10 +82,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] = diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala new file mode 100644 index 00000000..ce31bb03 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala @@ -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]) +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala new file mode 100644 index 00000000..c34fd1f7 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala @@ -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.isDefined, (), 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)) + ) + ) + } + } + } +} From f16632bc7f31e614cab6c3f66e9b07ef84c35ac5 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 23 May 2020 13:53:36 +0200 Subject: [PATCH 3/4] Allow a collective to disable the integration endpoint --- .../docspell/backend/ops/OCollective.scala | 9 ++- .../docspell/backend/signup/OSignup.scala | 8 ++- .../src/main/resources/docspell-openapi.yml | 11 +++- .../restserver/routes/CollectiveRoutes.scala | 8 ++- .../routes/IntegrationEndpointRoutes.scala | 2 +- .../docspell/restserver/webapp/Flags.scala | 6 +- .../mariadb/V1.6.0__integration_enabled.sql | 7 +++ .../V1.6.0__integration_enabled.sql | 7 +++ .../docspell/store/records/RCollective.scala | 26 ++++++-- ...ettings.elm => CollectiveSettingsForm.elm} | 41 +++++++++++- modules/webapp/src/main/elm/Data/Flags.elm | 1 + .../main/elm/Page/CollectiveSettings/Data.elm | 8 +-- .../elm/Page/CollectiveSettings/Update.elm | 12 ++-- .../main/elm/Page/CollectiveSettings/View.elm | 63 +++++++------------ 14 files changed, 138 insertions(+), 71 deletions(-) create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.6.0__integration_enabled.sql create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.6.0__integration_enabled.sql rename modules/webapp/src/main/elm/Comp/{Settings.elm => CollectiveSettingsForm.elm} (60%) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index 1e41db96..eda0f00b 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -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) diff --git a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala index 4b09318d..65cb6276 100644 --- a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala +++ b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala @@ -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, diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index dce7205a..e0ecf20f 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -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. diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala index a5163e88..8e3300c4 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala @@ -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 diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala index c34fd1f7..6f0361d4 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala @@ -59,7 +59,7 @@ object IntegrationEndpointRoutes { ): EitherT[F, Response[F], Unit] = for { opt <- EitherT.liftF(backend.collective.find(coll)) - res <- EitherT.cond[F](opt.isDefined, (), Response.notFound[F]) + res <- EitherT.cond[F](opt.exists(_.integrationEnabled), (), Response.notFound[F]) } yield res def uploadFile[F[_]: Effect]( diff --git a/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala b/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala index 463562dc..3326a839 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala @@ -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] = diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.6.0__integration_enabled.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.6.0__integration_enabled.sql new file mode 100644 index 00000000..1a039085 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.6.0__integration_enabled.sql @@ -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; diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.6.0__integration_enabled.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.6.0__integration_enabled.sql new file mode 100644 index 00000000..d1421f1d --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.6.0__integration_enabled.sql @@ -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; diff --git a/modules/store/src/main/scala/docspell/store/records/RCollective.scala b/modules/store/src/main/scala/docspell/store/records/RCollective.scala index 0eaec6f8..843960d2 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCollective.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCollective.scala @@ -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) } diff --git a/modules/webapp/src/main/elm/Comp/Settings.elm b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm similarity index 60% rename from modules/webapp/src/main/elm/Comp/Settings.elm rename to modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm index 55ccf613..066ef383 100644 --- a/modules/webapp/src/main/elm/Comp/Settings.elm +++ b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm @@ -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." + ] + ] ] ] diff --git a/modules/webapp/src/main/elm/Data/Flags.elm b/modules/webapp/src/main/elm/Data/Flags.elm index a129e76f..2be9c862 100644 --- a/modules/webapp/src/main/elm/Data/Flags.elm +++ b/modules/webapp/src/main/elm/Data/Flags.elm @@ -14,6 +14,7 @@ type alias Config = , baseUrl : String , signupMode : String , docspellAssetPath : String + , integrationEnabled : Bool } diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm index b55ba06c..d12aa44f 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/Data.elm @@ -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) diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm index 7b4f8cf8..fa9ab433 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/Update.elm @@ -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 ) diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/View.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/View.elm index c2c6287a..9e799833 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/View.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/View.elm @@ -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 + ] ] From 785cdde1a654d9658f952a4ec1badb9071361f89 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sat, 23 May 2020 14:27:56 +0200 Subject: [PATCH 4/4] Update documentation --- Changelog.md | 9 ++++++++- modules/microsite/docs/doc/uploading.md | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Changelog.md b/Changelog.md index 7788e364..857f1904 100644 --- a/Changelog.md +++ b/Changelog.md @@ -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. @@ -20,7 +22,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 @@ -29,6 +31,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 @@ -36,6 +40,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 diff --git a/modules/microsite/docs/doc/uploading.md b/modules/microsite/docs/doc/uploading.md index 2dd360bf..9b427dd7 100644 --- a/modules/microsite/docs/doc/uploading.md +++ b/modules/microsite/docs/doc/uploading.md @@ -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