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