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.
This commit is contained in:
Eike Kettner 2020-05-23 12:57:25 +02:00
parent 892002b351
commit f74f8e5198
6 changed files with 267 additions and 5 deletions

View File

@ -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] =

View File

@ -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 {

View File

@ -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)
}
}
}
}

View File

@ -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] =

View File

@ -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])
}

View File

@ -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))
)
)
}
}
}
}