mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-05 22:55:58 +00:00
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:
parent
892002b351
commit
f74f8e5198
@ -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] =
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -85,7 +85,8 @@ object RestServer {
|
||||
"auth" -> LoginRoutes.login(restApp.backend.login, cfg),
|
||||
"signup" -> RegisterRoutes(restApp.backend, cfg),
|
||||
"upload" -> UploadRoutes.open(restApp.backend, cfg),
|
||||
"checkfile" -> CheckFileRoutes.open(restApp.backend)
|
||||
"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])
|
||||
}
|
@ -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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user