mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-05 22:55:58 +00:00
Merge branch 'master' into current-docs
This commit is contained in:
commit
8c33658c27
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -9,7 +9,7 @@ jobs:
|
||||
matrix:
|
||||
java: [ 'openjdk@1.11' ]
|
||||
steps:
|
||||
- uses: actions/checkout@v2.3.4
|
||||
- uses: actions/checkout@v2.3.5
|
||||
with:
|
||||
fetch-depth: 100
|
||||
- uses: jorelali/setup-elm@v3
|
||||
|
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@ -6,7 +6,7 @@ jobs:
|
||||
docker-images:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2.3.4
|
||||
- uses: actions/checkout@v2.3.5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set current version
|
||||
|
2
.github/workflows/release-nightly.yml
vendored
2
.github/workflows/release-nightly.yml
vendored
@ -11,7 +11,7 @@ jobs:
|
||||
matrix:
|
||||
java: [ 'openjdk@1.11' ]
|
||||
steps:
|
||||
- uses: actions/checkout@v2.3.4
|
||||
- uses: actions/checkout@v2.3.5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: olafurpg/setup-scala@v13
|
||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -11,7 +11,7 @@ jobs:
|
||||
matrix:
|
||||
java: [ 'openjdk@1.11' ]
|
||||
steps:
|
||||
- uses: actions/checkout@v2.3.4
|
||||
- uses: actions/checkout@v2.3.5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: olafurpg/setup-scala@v13
|
||||
|
2
.github/workflows/website.yml
vendored
2
.github/workflows/website.yml
vendored
@ -7,7 +7,7 @@ jobs:
|
||||
publish-website:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2.3.4
|
||||
- uses: actions/checkout@v2.3.5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set current version
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,5 +14,6 @@ _site/
|
||||
/website/site/templates/shortcodes/server.conf
|
||||
/website/site/templates/shortcodes/sample-exim.conf
|
||||
/website/site/templates/shortcodes/joex.conf
|
||||
/website/site/templates/shortcodes/config.env.txt
|
||||
/docker/docs
|
||||
/docker/dev-log
|
||||
|
@ -1,7 +1,8 @@
|
||||
version = "3.0.4"
|
||||
version = "3.0.6"
|
||||
|
||||
preset = default
|
||||
align.preset = some
|
||||
runner.dialect = scala213
|
||||
|
||||
maxColumn = 90
|
||||
|
||||
|
63
build.sbt
63
build.sbt
@ -260,6 +260,18 @@ val openapiScalaSettings = Seq(
|
||||
.copy(typeDef =
|
||||
TypeDef("AccountSource", Imports("docspell.common.AccountSource"))
|
||||
)
|
||||
case "itemquery" =>
|
||||
field =>
|
||||
field
|
||||
.copy(typeDef =
|
||||
TypeDef(
|
||||
"ItemQuery",
|
||||
Imports(
|
||||
"docspell.query.ItemQuery",
|
||||
"docspell.restapi.codec.ItemQueryJson._"
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
@ -275,15 +287,29 @@ val common = project
|
||||
.settings(testSettingsMUnit)
|
||||
.settings(
|
||||
name := "docspell-common",
|
||||
addCompilerPlugin(Dependencies.kindProjectorPlugin),
|
||||
libraryDependencies ++=
|
||||
Dependencies.fs2 ++
|
||||
Dependencies.circe ++
|
||||
Dependencies.loggingApi ++
|
||||
Dependencies.calevCore ++
|
||||
Dependencies.calevCirce ++
|
||||
Dependencies.pureconfig.map(_ % "optional")
|
||||
Dependencies.calevCirce
|
||||
)
|
||||
|
||||
val config = project
|
||||
.in(file("modules/config"))
|
||||
.disablePlugins(RevolverPlugin)
|
||||
.settings(sharedSettings)
|
||||
.settings(testSettingsMUnit)
|
||||
.settings(
|
||||
name := "docspell-config",
|
||||
addCompilerPlugin(Dependencies.kindProjectorPlugin),
|
||||
libraryDependencies ++=
|
||||
Dependencies.fs2 ++
|
||||
Dependencies.pureconfig
|
||||
)
|
||||
.dependsOn(common)
|
||||
|
||||
// Some example files for testing
|
||||
// https://file-examples.com/index.php/sample-documents-download/sample-doc-download/
|
||||
val files = project
|
||||
@ -366,6 +392,7 @@ val store = project
|
||||
.settings(testSettingsMUnit)
|
||||
.settings(
|
||||
name := "docspell-store",
|
||||
addCompilerPlugin(Dependencies.kindProjectorPlugin),
|
||||
libraryDependencies ++=
|
||||
Dependencies.doobie ++
|
||||
Dependencies.binny ++
|
||||
@ -409,7 +436,8 @@ val convert = project
|
||||
name := "docspell-convert",
|
||||
libraryDependencies ++=
|
||||
Dependencies.flexmark ++
|
||||
Dependencies.twelvemonkeys
|
||||
Dependencies.twelvemonkeys ++
|
||||
Dependencies.pdfbox
|
||||
)
|
||||
.dependsOn(common, files % "compile->compile;test->test")
|
||||
|
||||
@ -470,7 +498,7 @@ val restapi = project
|
||||
openapiSpec := (Compile / resourceDirectory).value / "docspell-openapi.yml",
|
||||
openapiStaticGen := OpenApiDocGenerator.Redoc
|
||||
)
|
||||
.dependsOn(common)
|
||||
.dependsOn(common, query.jvm)
|
||||
|
||||
val joexapi = project
|
||||
.in(file("modules/joexapi"))
|
||||
@ -588,7 +616,17 @@ val joex = project
|
||||
),
|
||||
Revolver.enableDebugging(port = 5051, suspend = false)
|
||||
)
|
||||
.dependsOn(store, backend, extract, convert, analysis, joexapi, restapi, ftssolr)
|
||||
.dependsOn(
|
||||
config,
|
||||
store,
|
||||
backend,
|
||||
extract,
|
||||
convert,
|
||||
analysis,
|
||||
joexapi,
|
||||
restapi,
|
||||
ftssolr
|
||||
)
|
||||
|
||||
val restserver = project
|
||||
.in(file("modules/restserver"))
|
||||
@ -651,7 +689,7 @@ val restserver = project
|
||||
}
|
||||
}
|
||||
)
|
||||
.dependsOn(restapi, joexapi, backend, webapp, ftssolr, oidc)
|
||||
.dependsOn(config, restapi, joexapi, backend, webapp, ftssolr, oidc)
|
||||
|
||||
// --- Website Documentation
|
||||
|
||||
@ -671,7 +709,6 @@ val website = project
|
||||
val templateOut = baseDirectory.value / "site" / "templates" / "shortcodes"
|
||||
val staticOut = baseDirectory.value / "site" / "static" / "openapi"
|
||||
IO.createDirectories(Seq(templateOut, staticOut))
|
||||
val logger = streams.value.log
|
||||
|
||||
val files = Seq(
|
||||
(restserver / Compile / resourceDirectory).value / "reference.conf" -> templateOut / "server.conf",
|
||||
@ -683,6 +720,17 @@ val website = project
|
||||
IO.copy(files)
|
||||
files.map(_._2)
|
||||
}.taskValue,
|
||||
Compile / resourceGenerators += Def.task {
|
||||
val templateOut =
|
||||
baseDirectory.value / "site" / "templates" / "shortcodes" / "config.env.txt"
|
||||
val files = List(
|
||||
(restserver / Compile / resourceDirectory).value / "reference.conf",
|
||||
(joex / Compile / resourceDirectory).value / "reference.conf"
|
||||
)
|
||||
val cfg = EnvConfig.makeConfig(files)
|
||||
EnvConfig.serializeTo(cfg, templateOut)
|
||||
Seq(templateOut)
|
||||
}.taskValue,
|
||||
Compile / resourceGenerators += Def.task {
|
||||
val changelog = (LocalRootProject / baseDirectory).value / "Changelog.md"
|
||||
val targetDir = baseDirectory.value / "site" / "content" / "docs" / "changelog"
|
||||
@ -716,6 +764,7 @@ val root = project
|
||||
)
|
||||
.aggregate(
|
||||
common,
|
||||
config,
|
||||
extract,
|
||||
convert,
|
||||
analysis,
|
||||
|
@ -19,6 +19,7 @@ services:
|
||||
image: docspell/joex:latest
|
||||
container_name: docspell-joex
|
||||
command:
|
||||
- -J-Xmx3G
|
||||
- /opt/docspell.conf
|
||||
restart: unless-stopped
|
||||
env_file: ./.env
|
||||
@ -50,7 +51,7 @@ services:
|
||||
- restserver
|
||||
|
||||
db:
|
||||
image: postgres:13.4
|
||||
image: postgres:14.0
|
||||
container_name: postgres_db
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
@ -48,6 +48,7 @@ trait BackendApp[F[_]] {
|
||||
def simpleSearch: OSimpleSearch[F]
|
||||
def clientSettings: OClientSettings[F]
|
||||
def totp: OTotp[F]
|
||||
def share: OShare[F]
|
||||
}
|
||||
|
||||
object BackendApp {
|
||||
@ -85,6 +86,9 @@ object BackendApp {
|
||||
customFieldsImpl <- OCustomFields(store)
|
||||
simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl)
|
||||
clientSettingsImpl <- OClientSettings(store)
|
||||
shareImpl <- Resource.pure(
|
||||
OShare(store, itemSearchImpl, simpleSearchImpl, javaEmil)
|
||||
)
|
||||
} yield new BackendApp[F] {
|
||||
val login = loginImpl
|
||||
val signup = signupImpl
|
||||
@ -107,16 +111,16 @@ object BackendApp {
|
||||
val simpleSearch = simpleSearchImpl
|
||||
val clientSettings = clientSettingsImpl
|
||||
val totp = totpImpl
|
||||
val share = shareImpl
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](
|
||||
cfg: Config,
|
||||
connectEC: ExecutionContext,
|
||||
httpClientEc: ExecutionContext
|
||||
connectEC: ExecutionContext
|
||||
)(ftsFactory: Client[F] => Resource[F, FtsClient[F]]): Resource[F, BackendApp[F]] =
|
||||
for {
|
||||
store <- Store.create(cfg.jdbc, cfg.files.chunkSize, connectEC)
|
||||
httpClient <- BlazeClientBuilder[F](httpClientEc).resource
|
||||
httpClient <- BlazeClientBuilder[F].resource
|
||||
ftsClient <- ftsFactory(httpClient)
|
||||
backend <- create(cfg, store, httpClient, ftsClient)
|
||||
} yield backend
|
||||
|
@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.auth
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.Common
|
||||
import docspell.common.{Ident, Timestamp}
|
||||
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
/** Can be used as an authenticator to access data behind a share. */
|
||||
final case class ShareToken(created: Timestamp, id: Ident, salt: String, sig: String) {
|
||||
def asString = s"${created.toMillis}-${TokenUtil.b64enc(id.id)}-$salt-$sig"
|
||||
|
||||
def sigValid(key: ByteVector): Boolean = {
|
||||
val newSig = TokenUtil.sign(this, key)
|
||||
TokenUtil.constTimeEq(sig, newSig)
|
||||
}
|
||||
def sigInvalid(key: ByteVector): Boolean =
|
||||
!sigValid(key)
|
||||
}
|
||||
|
||||
object ShareToken {
|
||||
|
||||
def fromString(s: String): Either[String, ShareToken] =
|
||||
s.split("-", 4) match {
|
||||
case Array(ms, id, salt, sig) =>
|
||||
for {
|
||||
created <- ms.toLongOption.toRight("Invalid timestamp")
|
||||
idStr <- TokenUtil.b64dec(id).toRight("Cannot read authenticator data")
|
||||
shareId <- Ident.fromString(idStr)
|
||||
} yield ShareToken(Timestamp.ofMillis(created), shareId, salt, sig)
|
||||
|
||||
case _ =>
|
||||
Left("Invalid authenticator")
|
||||
}
|
||||
|
||||
def create[F[_]: Sync](shareId: Ident, key: ByteVector): F[ShareToken] =
|
||||
for {
|
||||
now <- Timestamp.current[F]
|
||||
salt <- Common.genSaltString[F]
|
||||
cd = ShareToken(now, shareId, salt, "")
|
||||
sig = TokenUtil.sign(cd, key)
|
||||
} yield cd.copy(sig = sig)
|
||||
|
||||
}
|
@ -18,17 +18,24 @@ private[auth] object TokenUtil {
|
||||
|
||||
def sign(cd: RememberToken, key: ByteVector): String = {
|
||||
val raw = cd.nowMillis.toString + cd.rememberId.id + cd.salt
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1"))
|
||||
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64
|
||||
signRaw(raw, key)
|
||||
}
|
||||
|
||||
def sign(cd: AuthToken, key: ByteVector): String = {
|
||||
val raw =
|
||||
cd.nowMillis.toString + cd.account.asString + cd.requireSecondFactor + cd.salt
|
||||
signRaw(raw, key)
|
||||
}
|
||||
|
||||
def sign(sd: ShareToken, key: ByteVector): String = {
|
||||
val raw = s"${sd.created.toMillis}${sd.id.id}${sd.salt}"
|
||||
signRaw(raw, key)
|
||||
}
|
||||
|
||||
private def signRaw(data: String, key: ByteVector): String = {
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1"))
|
||||
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64
|
||||
ByteVector.view(mac.doFinal(data.getBytes(utf8))).toBase64
|
||||
}
|
||||
|
||||
def b64enc(s: String): String =
|
||||
|
@ -63,6 +63,12 @@ trait OCollective[F[_]] {
|
||||
|
||||
def findEnabledSource(sourceId: Ident): F[Option[RSource]]
|
||||
|
||||
def addPassword(collective: Ident, pw: Password): F[Unit]
|
||||
|
||||
def getPasswords(collective: Ident): F[List[RCollectivePassword]]
|
||||
|
||||
def removePassword(id: Ident): F[Unit]
|
||||
|
||||
def startLearnClassifier(collective: Ident): F[Unit]
|
||||
|
||||
def startEmptyTrash(args: EmptyTrashArgs): F[Unit]
|
||||
@ -149,7 +155,7 @@ object OCollective {
|
||||
private def updateLearnClassifierTask(coll: Ident, sett: Settings): F[Unit] =
|
||||
for {
|
||||
id <- Ident.randomId[F]
|
||||
on = sett.classifier.map(_.enabled).getOrElse(false)
|
||||
on = sett.classifier.exists(_.enabled)
|
||||
timer = sett.classifier.map(_.schedule).getOrElse(CalEvent.unsafe(""))
|
||||
args = LearnClassifierArgs(coll)
|
||||
ut = UserTask(
|
||||
@ -174,6 +180,18 @@ object OCollective {
|
||||
_ <- joex.notifyAllNodes
|
||||
} yield ()
|
||||
|
||||
def addPassword(collective: Ident, pw: Password): F[Unit] =
|
||||
for {
|
||||
cpass <- RCollectivePassword.createNew[F](collective, pw)
|
||||
_ <- store.transact(RCollectivePassword.upsert(cpass))
|
||||
} yield ()
|
||||
|
||||
def getPasswords(collective: Ident): F[List[RCollectivePassword]] =
|
||||
store.transact(RCollectivePassword.findAll(collective))
|
||||
|
||||
def removePassword(id: Ident): F[Unit] =
|
||||
store.transact(RCollectivePassword.deleteById(id)).map(_ => ())
|
||||
|
||||
def startLearnClassifier(collective: Ident): F[Unit] =
|
||||
for {
|
||||
id <- Ident.randomId[F]
|
||||
|
@ -6,8 +6,6 @@
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
@ -42,10 +40,7 @@ object OJoex {
|
||||
} yield cancel.success).getOrElse(false)
|
||||
})
|
||||
|
||||
def create[F[_]: Async](
|
||||
ec: ExecutionContext,
|
||||
store: Store[F]
|
||||
): Resource[F, OJoex[F]] =
|
||||
JoexClient.resource(ec).flatMap(client => apply(client, store))
|
||||
def create[F[_]: Async](store: Store[F]): Resource[F, OJoex[F]] =
|
||||
JoexClient.resource.flatMap(client => apply(client, store))
|
||||
|
||||
}
|
||||
|
@ -51,6 +51,22 @@ trait OMail[F[_]] {
|
||||
}
|
||||
|
||||
object OMail {
|
||||
sealed trait SendResult
|
||||
|
||||
object SendResult {
|
||||
|
||||
/** Mail was successfully sent and stored to db. */
|
||||
case class Success(id: Ident) extends SendResult
|
||||
|
||||
/** There was a failure sending the mail. The mail is then not saved to db. */
|
||||
case class SendFailure(ex: Throwable) extends SendResult
|
||||
|
||||
/** The mail was successfully sent, but storing to db failed. */
|
||||
case class StoreFailure(ex: Throwable) extends SendResult
|
||||
|
||||
/** Something could not be found required for sending (mail configs, items etc). */
|
||||
case object NotFound extends SendResult
|
||||
}
|
||||
|
||||
case class Sent(
|
||||
id: Ident,
|
||||
|
381
modules/backend/src/main/scala/docspell/backend/ops/OShare.scala
Normal file
381
modules/backend/src/main/scala/docspell/backend/ops/OShare.scala
Normal file
@ -0,0 +1,381 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.PasswordCrypt
|
||||
import docspell.backend.auth.ShareToken
|
||||
import docspell.backend.ops.OItemSearch._
|
||||
import docspell.backend.ops.OShare._
|
||||
import docspell.backend.ops.OSimpleSearch.StringSearchResult
|
||||
import docspell.common._
|
||||
import docspell.query.ItemQuery
|
||||
import docspell.query.ItemQuery.Expr
|
||||
import docspell.query.ItemQuery.Expr.AttachId
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.SearchSummary
|
||||
import docspell.store.records._
|
||||
|
||||
import emil._
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
trait OShare[F[_]] {
|
||||
|
||||
def findAll(
|
||||
collective: Ident,
|
||||
ownerLogin: Option[Ident],
|
||||
query: Option[String]
|
||||
): F[List[ShareData]]
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[Boolean]
|
||||
|
||||
def addNew(share: OShare.NewShare): F[OShare.ChangeResult]
|
||||
|
||||
def findOne(id: Ident, collective: Ident): OptionT[F, ShareData]
|
||||
|
||||
def update(
|
||||
id: Ident,
|
||||
share: OShare.NewShare,
|
||||
removePassword: Boolean
|
||||
): F[OShare.ChangeResult]
|
||||
|
||||
// ---
|
||||
|
||||
/** Verifies the given id and password and returns a authorization token on success. */
|
||||
def verify(key: ByteVector)(id: Ident, password: Option[Password]): F[VerifyResult]
|
||||
|
||||
/** Verifies the authorization token. */
|
||||
def verifyToken(key: ByteVector)(token: String): F[VerifyResult]
|
||||
|
||||
def findShareQuery(id: Ident): OptionT[F, ShareQuery]
|
||||
|
||||
def findAttachmentPreview(
|
||||
attachId: Ident,
|
||||
shareId: Ident
|
||||
): OptionT[F, AttachmentPreviewData[F]]
|
||||
|
||||
def findAttachment(attachId: Ident, shareId: Ident): OptionT[F, AttachmentData[F]]
|
||||
|
||||
def findItem(itemId: Ident, shareId: Ident): OptionT[F, ItemData]
|
||||
|
||||
def searchSummary(
|
||||
settings: OSimpleSearch.StatsSettings
|
||||
)(shareId: Ident, q: ItemQueryString): OptionT[F, StringSearchResult[SearchSummary]]
|
||||
|
||||
def sendMail(account: AccountId, connection: Ident, mail: ShareMail): F[SendResult]
|
||||
}
|
||||
|
||||
object OShare {
|
||||
final case class ShareMail(
|
||||
shareId: Ident,
|
||||
subject: String,
|
||||
recipients: List[MailAddress],
|
||||
cc: List[MailAddress],
|
||||
bcc: List[MailAddress],
|
||||
body: String
|
||||
)
|
||||
|
||||
sealed trait SendResult
|
||||
object SendResult {
|
||||
|
||||
/** Mail was successfully sent and stored to db. */
|
||||
case class Success(msgId: String) extends SendResult
|
||||
|
||||
/** There was a failure sending the mail. The mail is then not saved to db. */
|
||||
case class SendFailure(ex: Throwable) extends SendResult
|
||||
|
||||
/** Something could not be found required for sending (mail configs, items etc). */
|
||||
case object NotFound extends SendResult
|
||||
}
|
||||
|
||||
final case class ShareQuery(id: Ident, account: AccountId, query: ItemQuery)
|
||||
|
||||
sealed trait VerifyResult {
|
||||
def toEither: Either[String, ShareToken] =
|
||||
this match {
|
||||
case VerifyResult.Success(token, _) =>
|
||||
Right(token)
|
||||
case _ => Left("Authentication failed.")
|
||||
}
|
||||
}
|
||||
object VerifyResult {
|
||||
case class Success(token: ShareToken, shareName: Option[String]) extends VerifyResult
|
||||
case object NotFound extends VerifyResult
|
||||
case object PasswordMismatch extends VerifyResult
|
||||
case object InvalidToken extends VerifyResult
|
||||
|
||||
def success(token: ShareToken): VerifyResult = Success(token, None)
|
||||
def success(token: ShareToken, name: Option[String]): VerifyResult =
|
||||
Success(token, name)
|
||||
def notFound: VerifyResult = NotFound
|
||||
def passwordMismatch: VerifyResult = PasswordMismatch
|
||||
def invalidToken: VerifyResult = InvalidToken
|
||||
}
|
||||
|
||||
final case class NewShare(
|
||||
account: AccountId,
|
||||
name: Option[String],
|
||||
query: ItemQuery,
|
||||
enabled: Boolean,
|
||||
password: Option[Password],
|
||||
publishUntil: Timestamp
|
||||
)
|
||||
|
||||
sealed trait ChangeResult
|
||||
object ChangeResult {
|
||||
final case class Success(id: Ident) extends ChangeResult
|
||||
case object PublishUntilInPast extends ChangeResult
|
||||
case object NotFound extends ChangeResult
|
||||
|
||||
def success(id: Ident): ChangeResult = Success(id)
|
||||
def publishUntilInPast: ChangeResult = PublishUntilInPast
|
||||
def notFound: ChangeResult = NotFound
|
||||
}
|
||||
|
||||
final case class ShareData(share: RShare, user: RUser)
|
||||
|
||||
def apply[F[_]: Async](
|
||||
store: Store[F],
|
||||
itemSearch: OItemSearch[F],
|
||||
simpleSearch: OSimpleSearch[F],
|
||||
emil: Emil[F]
|
||||
): OShare[F] =
|
||||
new OShare[F] {
|
||||
private[this] val logger = Logger.log4s[F](org.log4s.getLogger)
|
||||
|
||||
def findAll(
|
||||
collective: Ident,
|
||||
ownerLogin: Option[Ident],
|
||||
query: Option[String]
|
||||
): F[List[ShareData]] =
|
||||
store
|
||||
.transact(RShare.findAllByCollective(collective, ownerLogin, query))
|
||||
.map(_.map(ShareData.tupled))
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[Boolean] =
|
||||
store.transact(RShare.deleteByIdAndCid(id, collective)).map(_ > 0)
|
||||
|
||||
def addNew(share: NewShare): F[ChangeResult] =
|
||||
for {
|
||||
curTime <- Timestamp.current[F]
|
||||
id <- Ident.randomId[F]
|
||||
user <- store.transact(RUser.findByAccount(share.account))
|
||||
pass = share.password.map(PasswordCrypt.crypt)
|
||||
record = RShare(
|
||||
id,
|
||||
user.map(_.uid).getOrElse(Ident.unsafe("-error-no-user-")),
|
||||
share.name,
|
||||
share.query,
|
||||
share.enabled,
|
||||
pass,
|
||||
curTime,
|
||||
share.publishUntil,
|
||||
0,
|
||||
None
|
||||
)
|
||||
res <-
|
||||
if (share.publishUntil < curTime) ChangeResult.publishUntilInPast.pure[F]
|
||||
else store.transact(RShare.insert(record)).map(_ => ChangeResult.success(id))
|
||||
} yield res
|
||||
|
||||
def update(
|
||||
id: Ident,
|
||||
share: OShare.NewShare,
|
||||
removePassword: Boolean
|
||||
): F[ChangeResult] =
|
||||
for {
|
||||
curTime <- Timestamp.current[F]
|
||||
user <- store.transact(RUser.findByAccount(share.account))
|
||||
record = RShare(
|
||||
id,
|
||||
user.map(_.uid).getOrElse(Ident.unsafe("-error-no-user-")),
|
||||
share.name,
|
||||
share.query,
|
||||
share.enabled,
|
||||
share.password.map(PasswordCrypt.crypt),
|
||||
Timestamp.Epoch,
|
||||
share.publishUntil,
|
||||
0,
|
||||
None
|
||||
)
|
||||
res <-
|
||||
if (share.publishUntil < curTime) ChangeResult.publishUntilInPast.pure[F]
|
||||
else
|
||||
store
|
||||
.transact(RShare.updateData(record, removePassword))
|
||||
.map(n => if (n > 0) ChangeResult.success(id) else ChangeResult.notFound)
|
||||
} yield res
|
||||
|
||||
def findOne(id: Ident, collective: Ident): OptionT[F, ShareData] =
|
||||
RShare
|
||||
.findOne(id, collective)
|
||||
.mapK(store.transform)
|
||||
.map(ShareData.tupled)
|
||||
|
||||
def verify(
|
||||
key: ByteVector
|
||||
)(id: Ident, password: Option[Password]): F[VerifyResult] =
|
||||
RShare
|
||||
.findCurrentActive(id)
|
||||
.mapK(store.transform)
|
||||
.semiflatMap { case (share, _) =>
|
||||
val pwCheck =
|
||||
share.password.map(encPw => password.exists(PasswordCrypt.check(_, encPw)))
|
||||
|
||||
// add the password (if existing) to the server secret key; this way the token
|
||||
// invalidates when the user changes the password
|
||||
val shareKey =
|
||||
share.password.map(pw => key ++ pw.asByteVector).getOrElse(key)
|
||||
|
||||
val token = ShareToken
|
||||
.create(id, shareKey)
|
||||
.flatTap(_ => store.transact(RShare.incAccess(share.id)))
|
||||
pwCheck match {
|
||||
case Some(true) => token.map(t => VerifyResult.success(t, share.name))
|
||||
case None => token.map(t => VerifyResult.success(t, share.name))
|
||||
case Some(false) => VerifyResult.passwordMismatch.pure[F]
|
||||
}
|
||||
}
|
||||
.getOrElse(VerifyResult.notFound)
|
||||
|
||||
def verifyToken(key: ByteVector)(token: String): F[VerifyResult] =
|
||||
ShareToken.fromString(token) match {
|
||||
case Right(st) =>
|
||||
RShare
|
||||
.findActivePassword(st.id)
|
||||
.mapK(store.transform)
|
||||
.semiflatMap { password =>
|
||||
val shareKey =
|
||||
password.map(pw => key ++ pw.asByteVector).getOrElse(key)
|
||||
if (st.sigValid(shareKey)) VerifyResult.success(st).pure[F]
|
||||
else
|
||||
logger.info(
|
||||
s"Signature failure for share: ${st.id.id}"
|
||||
) *> VerifyResult.invalidToken.pure[F]
|
||||
}
|
||||
.getOrElse(VerifyResult.notFound)
|
||||
|
||||
case Left(err) =>
|
||||
logger.debug(s"Invalid session token: $err") *>
|
||||
VerifyResult.invalidToken.pure[F]
|
||||
}
|
||||
|
||||
def findShareQuery(id: Ident): OptionT[F, ShareQuery] =
|
||||
RShare
|
||||
.findCurrentActive(id)
|
||||
.mapK(store.transform)
|
||||
.map { case (share, user) =>
|
||||
ShareQuery(share.id, user.accountId, share.query)
|
||||
}
|
||||
|
||||
def findAttachmentPreview(
|
||||
attachId: Ident,
|
||||
shareId: Ident
|
||||
): OptionT[F, AttachmentPreviewData[F]] =
|
||||
for {
|
||||
sq <- findShareQuery(shareId)
|
||||
_ <- checkAttachment(sq, AttachId(attachId.id))
|
||||
res <- OptionT(
|
||||
itemSearch.findAttachmentPreview(attachId, sq.account.collective)
|
||||
)
|
||||
} yield res
|
||||
|
||||
def findAttachment(attachId: Ident, shareId: Ident): OptionT[F, AttachmentData[F]] =
|
||||
for {
|
||||
sq <- findShareQuery(shareId)
|
||||
_ <- checkAttachment(sq, AttachId(attachId.id))
|
||||
res <- OptionT(itemSearch.findAttachment(attachId, sq.account.collective))
|
||||
} yield res
|
||||
|
||||
def findItem(itemId: Ident, shareId: Ident): OptionT[F, ItemData] =
|
||||
for {
|
||||
sq <- findShareQuery(shareId)
|
||||
_ <- checkAttachment(sq, Expr.itemIdEq(itemId.id))
|
||||
res <- OptionT(itemSearch.findItem(itemId, sq.account.collective))
|
||||
} yield res
|
||||
|
||||
/** Check whether the attachment with the given id is in the results of the given
|
||||
* share
|
||||
*/
|
||||
private def checkAttachment(sq: ShareQuery, idExpr: Expr): OptionT[F, Unit] = {
|
||||
val checkQuery = Query(
|
||||
Query.Fix(sq.account, Some(sq.query.expr), None),
|
||||
Query.QueryExpr(idExpr)
|
||||
)
|
||||
OptionT(
|
||||
itemSearch
|
||||
.findItems(0)(checkQuery, Batch.limit(1))
|
||||
.map(_.headOption.map(_ => ()))
|
||||
).flatTapNone(
|
||||
logger.info(
|
||||
s"Attempt to load unshared data '$idExpr' via share: ${sq.id.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def searchSummary(
|
||||
settings: OSimpleSearch.StatsSettings
|
||||
)(
|
||||
shareId: Ident,
|
||||
q: ItemQueryString
|
||||
): OptionT[F, StringSearchResult[SearchSummary]] =
|
||||
findShareQuery(shareId)
|
||||
.semiflatMap { share =>
|
||||
val fix = Query.Fix(share.account, Some(share.query.expr), None)
|
||||
simpleSearch
|
||||
.searchSummaryByString(settings)(fix, q)
|
||||
.map {
|
||||
case StringSearchResult.Success(summary) =>
|
||||
StringSearchResult.Success(summary.onlyExisting)
|
||||
case other => other
|
||||
}
|
||||
}
|
||||
|
||||
def sendMail(
|
||||
account: AccountId,
|
||||
connection: Ident,
|
||||
mail: ShareMail
|
||||
): F[SendResult] = {
|
||||
val getSmtpSettings: OptionT[F, RUserEmail] =
|
||||
OptionT(store.transact(RUserEmail.getByName(account, connection)))
|
||||
|
||||
def createMail(sett: RUserEmail): OptionT[F, Mail[F]] = {
|
||||
import _root_.emil.builder._
|
||||
|
||||
OptionT.pure(
|
||||
MailBuilder.build(
|
||||
From(sett.mailFrom),
|
||||
Tos(mail.recipients),
|
||||
Ccs(mail.cc),
|
||||
Bccs(mail.bcc),
|
||||
XMailer.emil,
|
||||
Subject(mail.subject),
|
||||
TextBody[F](mail.body)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def sendMail(cfg: MailConfig, mail: Mail[F]): F[Either[SendResult, String]] =
|
||||
emil(cfg).send(mail).map(_.head).attempt.map(_.left.map(SendResult.SendFailure))
|
||||
|
||||
(for {
|
||||
_ <- RShare
|
||||
.findCurrentActive(mail.shareId)
|
||||
.filter(_._2.cid == account.collective)
|
||||
.mapK(store.transform)
|
||||
mailCfg <- getSmtpSettings
|
||||
mail <- createMail(mailCfg)
|
||||
mid <- OptionT.liftF(sendMail(mailCfg.toMailConfig, mail))
|
||||
conv = mid.fold(identity, id => SendResult.Success(id))
|
||||
} yield conv).getOrElse(SendResult.NotFound)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import docspell.common._
|
||||
|
||||
sealed trait SendResult
|
||||
|
||||
object SendResult {
|
||||
|
||||
/** Mail was successfully sent and stored to db. */
|
||||
case class Success(id: Ident) extends SendResult
|
||||
|
||||
/** There was a failure sending the mail. The mail is then not saved to db. */
|
||||
case class SendFailure(ex: Throwable) extends SendResult
|
||||
|
||||
/** The mail was successfully sent, but storing to db failed. */
|
||||
case class StoreFailure(ex: Throwable) extends SendResult
|
||||
|
||||
/** Something could not be found required for sending (mail configs, items etc). */
|
||||
case object NotFound extends SendResult
|
||||
}
|
@ -8,6 +8,7 @@ package docspell.common
|
||||
|
||||
import io.circe._
|
||||
|
||||
/** The collective and user name. */
|
||||
case class AccountId(collective: Ident, user: Ident) {
|
||||
def asString =
|
||||
if (collective == user) user.id
|
||||
|
@ -7,12 +7,13 @@
|
||||
package docspell.common
|
||||
|
||||
import cats.effect.Sync
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.common.syntax.all._
|
||||
|
||||
import org.log4s.{Logger => Log4sLogger}
|
||||
|
||||
trait Logger[F[_]] {
|
||||
trait Logger[F[_]] { self =>
|
||||
|
||||
def trace(msg: => String): F[Unit]
|
||||
def debug(msg: => String): F[Unit]
|
||||
@ -21,6 +22,25 @@ trait Logger[F[_]] {
|
||||
def error(ex: Throwable)(msg: => String): F[Unit]
|
||||
def error(msg: => String): F[Unit]
|
||||
|
||||
final def s: Logger[Stream[F, *]] = new Logger[Stream[F, *]] {
|
||||
def trace(msg: => String): Stream[F, Unit] =
|
||||
Stream.eval(self.trace(msg))
|
||||
|
||||
def debug(msg: => String): Stream[F, Unit] =
|
||||
Stream.eval(self.debug(msg))
|
||||
|
||||
def info(msg: => String): Stream[F, Unit] =
|
||||
Stream.eval(self.info(msg))
|
||||
|
||||
def warn(msg: => String): Stream[F, Unit] =
|
||||
Stream.eval(self.warn(msg))
|
||||
|
||||
def error(msg: => String): Stream[F, Unit] =
|
||||
Stream.eval(self.error(msg))
|
||||
|
||||
def error(ex: Throwable)(msg: => String): Stream[F, Unit] =
|
||||
Stream.eval(self.error(ex)(msg))
|
||||
}
|
||||
}
|
||||
|
||||
object Logger {
|
||||
|
@ -6,18 +6,29 @@
|
||||
|
||||
package docspell.common
|
||||
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
import cats.effect.Sync
|
||||
import cats.implicits._
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
final class Password(val pass: String) extends AnyVal {
|
||||
|
||||
def isEmpty: Boolean = pass.isEmpty
|
||||
def nonEmpty: Boolean = pass.nonEmpty
|
||||
def length: Int = pass.length
|
||||
|
||||
def asByteVector: ByteVector =
|
||||
ByteVector.view(pass.getBytes(StandardCharsets.UTF_8))
|
||||
|
||||
override def toString: String =
|
||||
if (pass.isEmpty) "<empty>" else "***"
|
||||
|
||||
def compare(other: Password): Boolean =
|
||||
this.pass.zip(other.pass).forall { case (a, b) => a == b } &&
|
||||
this.nonEmpty && this.length == other.length
|
||||
}
|
||||
|
||||
object Password {
|
||||
|
@ -10,7 +10,7 @@ import scala.concurrent.ExecutionContext
|
||||
|
||||
/** Captures thread pools to use in an application. */
|
||||
case class Pools(
|
||||
connectEC: ExecutionContext,
|
||||
httpClientEC: ExecutionContext,
|
||||
restEC: ExecutionContext
|
||||
connectEC: ExecutionContext
|
||||
// httpClientEC: ExecutionContext,
|
||||
// restEC: ExecutionContext
|
||||
)
|
||||
|
@ -51,6 +51,9 @@ case class Timestamp(value: Instant) {
|
||||
|
||||
def <(other: Timestamp): Boolean =
|
||||
this.value.isBefore(other.value)
|
||||
|
||||
def >(other: Timestamp): Boolean =
|
||||
this.value.isAfter(other.value)
|
||||
}
|
||||
|
||||
object Timestamp {
|
||||
@ -67,6 +70,9 @@ object Timestamp {
|
||||
def atUtc(ldt: LocalDateTime): Timestamp =
|
||||
from(ldt.atZone(UTC))
|
||||
|
||||
def ofMillis(ms: Long): Timestamp =
|
||||
Timestamp(Instant.ofEpochMilli(ms))
|
||||
|
||||
def daysBetween(ts0: Timestamp, ts1: Timestamp): Long =
|
||||
ChronoUnit.DAYS.between(ts0.toUtcDate, ts1.toUtcDate)
|
||||
|
||||
|
@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.config
|
||||
|
||||
import scala.reflect.ClassTag
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import fs2.io.file.{Files, Path}
|
||||
|
||||
import docspell.common.Logger
|
||||
|
||||
import pureconfig.{ConfigReader, ConfigSource}
|
||||
|
||||
object ConfigFactory {
|
||||
|
||||
/** Reads the configuration trying the following in order:
|
||||
* 1. if 'args' contains at least one element, the first is interpreted as a config
|
||||
* file
|
||||
* 1. otherwise check the system property 'config.file' for an existing file and use
|
||||
* it if it does exist; ignore if it doesn't exist
|
||||
* 1. if no file is found, read the config from environment variables falling back to
|
||||
* the default config
|
||||
*/
|
||||
def default[F[_]: Async, C: ClassTag: ConfigReader](logger: Logger[F], atPath: String)(
|
||||
args: List[String]
|
||||
): F[C] =
|
||||
findFileFromArgs(args).flatMap {
|
||||
case Some(file) =>
|
||||
logger.info(s"Using config file: $file") *>
|
||||
readFile[F, C](file, atPath)
|
||||
case None =>
|
||||
checkSystemProperty.value.flatMap {
|
||||
case Some(file) =>
|
||||
logger.info(s"Using config file from system property: $file") *>
|
||||
readConfig(atPath)
|
||||
case None =>
|
||||
logger.info("Using config from environment variables!") *>
|
||||
readEnv(atPath)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the configuration from the given file. */
|
||||
private def readFile[F[_]: Sync, C: ClassTag: ConfigReader](
|
||||
file: Path,
|
||||
at: String
|
||||
): F[C] =
|
||||
Sync[F].delay {
|
||||
System.setProperty(
|
||||
"config.file",
|
||||
file.toNioPath.toAbsolutePath.normalize.toString
|
||||
)
|
||||
ConfigSource.default.at(at).loadOrThrow[C]
|
||||
}
|
||||
|
||||
/** Reads the config as specified in typesafe's config library; usually loading the file
|
||||
* given as system property 'config.file'.
|
||||
*/
|
||||
private def readConfig[F[_]: Sync, C: ClassTag: ConfigReader](
|
||||
at: String
|
||||
): F[C] =
|
||||
Sync[F].delay(ConfigSource.default.at(at).loadOrThrow[C])
|
||||
|
||||
/** Reads the configuration from environment variables. */
|
||||
private def readEnv[F[_]: Sync, C: ClassTag: ConfigReader](at: String): F[C] =
|
||||
Sync[F].delay(ConfigSource.fromConfig(EnvConfig.get).at(at).loadOrThrow[C])
|
||||
|
||||
/** Uses the first argument as a path to the config file. If it is specified but the
|
||||
* file doesn't exist, an exception is thrown.
|
||||
*/
|
||||
private def findFileFromArgs[F[_]: Async](args: List[String]): F[Option[Path]] =
|
||||
args.headOption
|
||||
.map(Path.apply)
|
||||
.traverse(p =>
|
||||
Files[F].exists(p).flatMap {
|
||||
case true => p.pure[F]
|
||||
case false => Async[F].raiseError(new Exception(s"File not found: $p"))
|
||||
}
|
||||
)
|
||||
|
||||
/** If the system property 'config.file' is set, it is checked whether the file exists.
|
||||
* If it doesn't exist, the property is removed to not raise any exception. In contrast
|
||||
* to giving the file as argument, it is not an error to specify a non-existing file
|
||||
* via a system property.
|
||||
*/
|
||||
private def checkSystemProperty[F[_]: Async]: OptionT[F, Path] =
|
||||
for {
|
||||
cf <- OptionT(
|
||||
Sync[F].delay(
|
||||
Option(System.getProperty("config.file")).map(_.trim).filter(_.nonEmpty)
|
||||
)
|
||||
)
|
||||
cp = Path(cf)
|
||||
exists <- OptionT.liftF(Files[F].exists(cp))
|
||||
file <-
|
||||
if (exists) OptionT.pure[F](cp)
|
||||
else
|
||||
OptionT
|
||||
.liftF(Sync[F].delay(System.clearProperty("config.file")))
|
||||
.flatMap(_ => OptionT.none[F, Path])
|
||||
} yield file
|
||||
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.config
|
||||
|
||||
import java.util.Properties
|
||||
|
||||
import scala.collection.{MapView, mutable}
|
||||
import scala.jdk.CollectionConverters._
|
||||
|
||||
import com.typesafe.config.{Config, ConfigFactory}
|
||||
|
||||
/** Creates a config from environment variables.
|
||||
*
|
||||
* The env variables are expected to be in same form as the config keys with the
|
||||
* following mangling: a dot is replaced by an underscore character, because this is the
|
||||
* standard separator for env variables. In order to represent dashes, two underscores
|
||||
* are needed (and for one underscore use three underscores in the env variable).
|
||||
*
|
||||
* For example, the config key
|
||||
* {{{
|
||||
* docspell.server.app-name
|
||||
* }}}
|
||||
* can be given as env variable
|
||||
* {{{
|
||||
* DOCSPELL_SERVER_APP__NAME
|
||||
* }}}
|
||||
*/
|
||||
object EnvConfig {
|
||||
|
||||
/** The config from current environment. */
|
||||
lazy val get: Config =
|
||||
loadFrom(System.getenv().asScala.view)
|
||||
|
||||
def loadFrom(env: MapView[String, String]): Config = {
|
||||
val cfg = new Properties()
|
||||
for (key <- env.keySet if key.startsWith("DOCSPELL_"))
|
||||
cfg.setProperty(envToProp(key), env(key))
|
||||
|
||||
ConfigFactory
|
||||
.parseProperties(cfg)
|
||||
.withFallback(ConfigFactory.defaultReference())
|
||||
.resolve()
|
||||
}
|
||||
|
||||
/** Docspell has all lowercase key names and uses snake case.
|
||||
*
|
||||
* So this converts to lowercase and then replaces underscores (like
|
||||
* [[com.typesafe.config.ConfigFactory.systemEnvironmentOverrides()]]
|
||||
*
|
||||
* - 3 underscores -> `_` (underscore)
|
||||
* - 2 underscores -> `-` (dash)
|
||||
* - 1 underscore -> `.` (dot)
|
||||
*/
|
||||
private[config] def envToProp(v: String): String = {
|
||||
val len = v.length
|
||||
val buffer = new mutable.StringBuilder()
|
||||
val underscoreMapping = Map(3 -> '_', 2 -> '-', 1 -> '.').withDefault(_ => '_')
|
||||
@annotation.tailrec
|
||||
def go(current: Int, underscores: Int): String =
|
||||
if (current >= len) buffer.toString()
|
||||
else
|
||||
v.charAt(current) match {
|
||||
case '_' => go(current + 1, underscores + 1)
|
||||
case c =>
|
||||
if (underscores > 0) {
|
||||
buffer.append(underscoreMapping(underscores))
|
||||
}
|
||||
buffer.append(c.toLower)
|
||||
go(current + 1, 0)
|
||||
}
|
||||
|
||||
go(0, 0)
|
||||
}
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common.config
|
||||
package docspell.config
|
||||
|
||||
import java.nio.file.{Path => JPath}
|
||||
|
||||
@ -15,12 +15,11 @@ import fs2.io.file.Path
|
||||
import docspell.common._
|
||||
|
||||
import com.github.eikek.calev.CalEvent
|
||||
import pureconfig._
|
||||
import pureconfig.ConfigReader
|
||||
import pureconfig.error.{CannotConvert, FailureReason}
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
object Implicits {
|
||||
|
||||
implicit val accountIdReader: ConfigReader[AccountId] =
|
||||
ConfigReader[String].emap(reason(AccountId.parse))
|
||||
|
5
modules/config/src/test/resources/reference.conf
Normal file
5
modules/config/src/test/resources/reference.conf
Normal file
@ -0,0 +1,5 @@
|
||||
docspell.server {
|
||||
bind {
|
||||
port = 7880
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.config
|
||||
|
||||
import munit.FunSuite
|
||||
|
||||
class EnvConfigTest extends FunSuite {
|
||||
|
||||
test("convert underscores") {
|
||||
assertEquals(EnvConfig.envToProp("A_B_C"), "a.b.c")
|
||||
assertEquals(EnvConfig.envToProp("A_B__C"), "a.b-c")
|
||||
assertEquals(EnvConfig.envToProp("AA_BB__CC___D"), "aa.bb-cc_d")
|
||||
}
|
||||
|
||||
test("insert docspell keys") {
|
||||
val cfg = EnvConfig.loadFrom(
|
||||
Map(
|
||||
"DOCSPELL_SERVER_APP__NAME" -> "Hello!",
|
||||
"DOCSPELL_JOEX_BIND_PORT" -> "1234"
|
||||
).view
|
||||
)
|
||||
|
||||
assertEquals(cfg.getString("docspell.server.app-name"), "Hello!")
|
||||
assertEquals(cfg.getInt("docspell.joex.bind.port"), 1234)
|
||||
}
|
||||
|
||||
test("find default values from reference.conf") {
|
||||
val cfg = EnvConfig.loadFrom(
|
||||
Map(
|
||||
"DOCSPELL_SERVER_APP__NAME" -> "Hello!",
|
||||
"DOCSPELL_JOEX_BIND_PORT" -> "1234"
|
||||
).view
|
||||
)
|
||||
assertEquals(cfg.getInt("docspell.server.bind.port"), 7880)
|
||||
}
|
||||
|
||||
test("discard non docspell keys") {
|
||||
val cfg = EnvConfig.loadFrom(Map("A_B_C" -> "12").view)
|
||||
assert(!cfg.hasPath("a.b.c"))
|
||||
}
|
||||
}
|
@ -33,6 +33,7 @@ object Conversion {
|
||||
def create[F[_]: Async](
|
||||
cfg: ConvertConfig,
|
||||
sanitizeHtml: SanitizeHtml,
|
||||
additionalPasswords: List[Password],
|
||||
logger: Logger[F]
|
||||
): Resource[F, Conversion[F]] =
|
||||
Resource.pure[F, Conversion[F]](new Conversion[F] {
|
||||
@ -42,8 +43,16 @@ object Conversion {
|
||||
): F[A] =
|
||||
TikaMimetype.resolve(dataType, in).flatMap {
|
||||
case MimeType.PdfMatch(_) =>
|
||||
val allPass = cfg.decryptPdf.passwords ++ additionalPasswords
|
||||
val pdfStream =
|
||||
if (cfg.decryptPdf.enabled) {
|
||||
logger.s
|
||||
.debug(s"Trying to read the PDF using ${allPass.size} passwords")
|
||||
.drain ++
|
||||
in.through(RemovePdfEncryption(logger, allPass))
|
||||
} else in
|
||||
OcrMyPdf
|
||||
.toPDF(cfg.ocrmypdf, lang, cfg.chunkSize, logger)(in, handler)
|
||||
.toPDF(cfg.ocrmypdf, lang, cfg.chunkSize, logger)(pdfStream, handler)
|
||||
|
||||
case MimeType.HtmlMatch(mt) =>
|
||||
val cs = mt.charsetOrUtf8
|
||||
|
@ -6,11 +6,13 @@
|
||||
|
||||
package docspell.convert
|
||||
|
||||
import docspell.common.Password
|
||||
import docspell.convert.ConvertConfig.DecryptPdf
|
||||
import docspell.convert.extern.OcrMyPdfConfig
|
||||
import docspell.convert.extern.{TesseractConfig, UnoconvConfig, WkHtmlPdfConfig}
|
||||
import docspell.convert.flexmark.MarkdownConfig
|
||||
|
||||
case class ConvertConfig(
|
||||
final case class ConvertConfig(
|
||||
chunkSize: Int,
|
||||
convertedFilenamePart: String,
|
||||
maxImageSize: Int,
|
||||
@ -18,5 +20,11 @@ case class ConvertConfig(
|
||||
wkhtmlpdf: WkHtmlPdfConfig,
|
||||
tesseract: TesseractConfig,
|
||||
unoconv: UnoconvConfig,
|
||||
ocrmypdf: OcrMyPdfConfig
|
||||
ocrmypdf: OcrMyPdfConfig,
|
||||
decryptPdf: DecryptPdf
|
||||
)
|
||||
|
||||
object ConvertConfig {
|
||||
|
||||
final case class DecryptPdf(enabled: Boolean, passwords: List[Password])
|
||||
}
|
||||
|
@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.convert
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
import cats.effect._
|
||||
import fs2.{Chunk, Pipe, Stream}
|
||||
|
||||
import docspell.common._
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument
|
||||
import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException
|
||||
|
||||
/** Using PDFBox, the incoming pdf is loaded while trying the given passwords. */
|
||||
object RemovePdfEncryption {
|
||||
|
||||
def apply[F[_]: Sync](
|
||||
logger: Logger[F],
|
||||
passwords: List[Password]
|
||||
): Pipe[F, Byte, Byte] =
|
||||
apply(logger, Stream.emits(passwords))
|
||||
|
||||
def apply[F[_]: Sync](
|
||||
logger: Logger[F],
|
||||
passwords: Stream[F, Password]
|
||||
): Pipe[F, Byte, Byte] = {
|
||||
val pws = passwords.cons1(Password.empty)
|
||||
in =>
|
||||
pws
|
||||
.flatMap(pw => in.through(openPdf[F](logger, pw)))
|
||||
.head
|
||||
.flatMap { doc =>
|
||||
if (doc.isEncrypted) {
|
||||
logger.s.debug("Removing protection/encryption from PDF").drain ++
|
||||
Stream.eval(Sync[F].delay(doc.setAllSecurityToBeRemoved(true))).drain ++
|
||||
toStream[F](doc)
|
||||
} else {
|
||||
in
|
||||
}
|
||||
}
|
||||
.ifEmpty(
|
||||
logger.s
|
||||
.info(
|
||||
s"None of the passwords helped to read the given PDF!"
|
||||
)
|
||||
.drain ++ in
|
||||
)
|
||||
}
|
||||
|
||||
private def openPdf[F[_]: Sync](
|
||||
logger: Logger[F],
|
||||
pw: Password
|
||||
): Pipe[F, Byte, PDDocument] = {
|
||||
def alloc(bytes: Array[Byte]): F[Option[PDDocument]] =
|
||||
Sync[F].delay(load(bytes, pw))
|
||||
|
||||
def free(doc: Option[PDDocument]): F[Unit] =
|
||||
Sync[F].delay(doc.foreach(_.close()))
|
||||
|
||||
val log =
|
||||
if (pw.isEmpty) Stream.empty
|
||||
else logger.s.debug(s"Try opening PDF with password: ${pw.pass.take(2)}***").drain
|
||||
|
||||
in =>
|
||||
Stream
|
||||
.eval(in.compile.to(Array))
|
||||
.flatMap(bytes => log ++ Stream.bracket(alloc(bytes))(free))
|
||||
.flatMap(opt => opt.map(Stream.emit).getOrElse(Stream.empty))
|
||||
}
|
||||
|
||||
private def load(bytes: Array[Byte], pw: Password): Option[PDDocument] =
|
||||
try Option(PDDocument.load(bytes, pw.pass))
|
||||
catch {
|
||||
case _: InvalidPasswordException =>
|
||||
None
|
||||
}
|
||||
|
||||
private def toStream[F[_]](doc: PDDocument): Stream[F, Byte] = {
|
||||
val baos = new ByteArrayOutputStream()
|
||||
doc.save(baos)
|
||||
Stream.chunk(Chunk.array(baos.toByteArray))
|
||||
}
|
||||
}
|
@ -74,11 +74,12 @@ class ConversionTest extends FunSuite with FileChecks {
|
||||
Duration.seconds(20)
|
||||
),
|
||||
target
|
||||
)
|
||||
),
|
||||
ConvertConfig.DecryptPdf(true, Nil)
|
||||
)
|
||||
|
||||
val conversion =
|
||||
Conversion.create[IO](convertConfig, SanitizeHtml.none, logger)
|
||||
Conversion.create[IO](convertConfig, SanitizeHtml.none, Nil, logger)
|
||||
|
||||
val bombs = List(
|
||||
ExampleFiles.bombs_20K_gray_jpeg,
|
||||
|
@ -9,6 +9,8 @@ package docspell.convert
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
|
||||
import scala.util.Try
|
||||
|
||||
import cats.data.Kleisli
|
||||
import cats.effect.IO
|
||||
import cats.effect.unsafe.implicits.global
|
||||
@ -19,6 +21,9 @@ import docspell.common._
|
||||
import docspell.convert.ConversionResult.Handler
|
||||
import docspell.files.TikaMimetype
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument
|
||||
import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException
|
||||
|
||||
trait FileChecks {
|
||||
|
||||
implicit class FileCheckOps(p: Path) {
|
||||
@ -34,15 +39,46 @@ trait FileChecks {
|
||||
|
||||
def isPlainText: Boolean =
|
||||
isType(MimeType.text("plain"))
|
||||
|
||||
def isUnencryptedPDF: Boolean =
|
||||
Try(PDDocument.load(p.toNioPath.toFile)).map(_.close()).isSuccess
|
||||
}
|
||||
|
||||
implicit class ByteStreamOps(delegate: Stream[IO, Byte]) {
|
||||
def isNonEmpty: IO[Boolean] =
|
||||
delegate.head.compile.last.map(_.isDefined)
|
||||
|
||||
def isType(mime: MimeType): IO[Boolean] =
|
||||
TikaMimetype.detect(delegate, MimeTypeHint.none).map(_ == mime)
|
||||
|
||||
def isPDF: IO[Boolean] =
|
||||
isType(MimeType.pdf)
|
||||
|
||||
def isUnencryptedPDF: IO[Boolean] =
|
||||
delegate.compile
|
||||
.to(Array)
|
||||
.map(PDDocument.load(_))
|
||||
.map(_.close())
|
||||
.map(_ => true)
|
||||
|
||||
def isEncryptedPDF: IO[Boolean] =
|
||||
delegate.compile
|
||||
.to(Array)
|
||||
.map(PDDocument.load(_))
|
||||
.attempt
|
||||
.map(e =>
|
||||
e.fold(
|
||||
_.isInstanceOf[InvalidPasswordException],
|
||||
doc => {
|
||||
doc.close();
|
||||
false
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def storeFile(file: Path): Pipe[IO, Byte, Path] =
|
||||
in =>
|
||||
Stream
|
||||
.eval(
|
||||
in.compile.to(Array).flatMap(bytes => IO(Files.write(file.toNioPath, bytes)))
|
||||
)
|
||||
.map(p => File.path(p))
|
||||
fs2.io.file.Files[IO].writeAll(file).andThen(s => s ++ Stream.emit(file))
|
||||
|
||||
def storePdfHandler(file: Path): Handler[IO, Path] =
|
||||
storePdfTxtHandler(file, file.resolveSibling("unexpected.txt")).map(_._1)
|
||||
|
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.convert
|
||||
|
||||
import cats.effect.IO
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.common._
|
||||
import docspell.files.ExampleFiles
|
||||
|
||||
import munit.CatsEffectSuite
|
||||
|
||||
class RemovePdfEncryptionTest extends CatsEffectSuite with FileChecks {
|
||||
val logger: Logger[IO] = Logger.log4s(org.log4s.getLogger)
|
||||
|
||||
private val protectedPdf =
|
||||
ExampleFiles.secured_protected_test123_pdf.readURL[IO](16 * 1024)
|
||||
private val encryptedPdf =
|
||||
ExampleFiles.secured_encrypted_test123_pdf.readURL[IO](16 * 1024)
|
||||
private val plainPdf = ExampleFiles.letter_en_pdf.readURL[IO](16 * 1024)
|
||||
|
||||
test("have encrypted pdfs") {
|
||||
for {
|
||||
_ <- assertIO(encryptedPdf.isEncryptedPDF, true)
|
||||
_ <- assertIO(encryptedPdf.isEncryptedPDF, true)
|
||||
} yield ()
|
||||
}
|
||||
|
||||
test("decrypt pdf") {
|
||||
encryptedPdf
|
||||
.through(RemovePdfEncryption(logger, List(Password("test123"))))
|
||||
.isUnencryptedPDF
|
||||
.map(assert(_))
|
||||
}
|
||||
|
||||
test("decrypt pdf with multiple passwords") {
|
||||
encryptedPdf
|
||||
.through(
|
||||
RemovePdfEncryption(
|
||||
logger,
|
||||
List("xy123", "123xy", "test123", "abc123").map(Password(_))
|
||||
)
|
||||
)
|
||||
.isUnencryptedPDF
|
||||
.map(assert(_))
|
||||
}
|
||||
|
||||
test("remove protection") {
|
||||
protectedPdf
|
||||
.through(RemovePdfEncryption(logger, Nil))
|
||||
.isUnencryptedPDF
|
||||
.map(assert(_))
|
||||
}
|
||||
|
||||
test("read unprotected pdf") {
|
||||
plainPdf
|
||||
.through(RemovePdfEncryption(logger, Nil))
|
||||
.isUnencryptedPDF
|
||||
.map(assert(_))
|
||||
}
|
||||
|
||||
test("decrypt with multiple passwords, stop on first") {
|
||||
val passwords: Stream[IO, String] =
|
||||
Stream("test123") ++ Stream.raiseError[IO](new Exception("is not called"))
|
||||
val decrypt = RemovePdfEncryption(logger, passwords.map(Password(_)))
|
||||
encryptedPdf
|
||||
.through(decrypt)
|
||||
.isUnencryptedPDF
|
||||
.map(assert(_))
|
||||
}
|
||||
|
||||
test("return input stream if nothing helps") {
|
||||
encryptedPdf
|
||||
.through(RemovePdfEncryption(logger, List("a", "b").map(Password(_))))
|
||||
.isEncryptedPDF
|
||||
.map(assert(_))
|
||||
}
|
||||
}
|
BIN
modules/files/src/test/resources/secured/encrypted-test123.pdf
Normal file
BIN
modules/files/src/test/resources/secured/encrypted-test123.pdf
Normal file
Binary file not shown.
BIN
modules/files/src/test/resources/secured/protected-test123.pdf
Normal file
BIN
modules/files/src/test/resources/secured/protected-test123.pdf
Normal file
Binary file not shown.
@ -20,14 +20,19 @@ docspell.joex {
|
||||
|
||||
# The database connection.
|
||||
#
|
||||
# By default a H2 file-based database is configured. You can provide
|
||||
# a postgresql or mariadb connection here. When using H2 use the
|
||||
# PostgreSQL compatibility mode and AUTO_SERVER feature.
|
||||
#
|
||||
# It must be the same connection as the rest server is using.
|
||||
jdbc {
|
||||
|
||||
# The JDBC url to the database. By default a H2 file-based
|
||||
# database is configured. You can provide a postgresql or mariadb
|
||||
# connection here. When using H2 use the PostgreSQL compatibility
|
||||
# mode and AUTO_SERVER feature.
|
||||
url = "jdbc:h2://"${java.io.tmpdir}"/docspell-demo.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;AUTO_SERVER=TRUE"
|
||||
|
||||
# The database user.
|
||||
user = "sa"
|
||||
|
||||
# The database password.
|
||||
password = ""
|
||||
}
|
||||
|
||||
@ -586,6 +591,25 @@ Docpell Update Check
|
||||
}
|
||||
working-dir = ${java.io.tmpdir}"/docspell-convert"
|
||||
}
|
||||
|
||||
# Allows to try to decrypt a PDF with encryption or protection. If
|
||||
# enabled, a PDFs encryption or protection will be removed during
|
||||
# conversion.
|
||||
#
|
||||
# For encrypted PDFs, this is necessary to be processed, because
|
||||
# docspell needs to read it. It also requires to specify a
|
||||
# password here. All passwords are tried when reading a PDF.
|
||||
#
|
||||
# This is enabled by default with an empty password list. This
|
||||
# removes protection from PDFs, which is better for processing.
|
||||
#
|
||||
# Passwords can be given here and each collective can maintain
|
||||
# their passwords as well. But if the `enabled` setting below is
|
||||
# `false`, then no attempt at decrypting is done.
|
||||
decrypt-pdf = {
|
||||
enabled = true
|
||||
passwords = []
|
||||
}
|
||||
}
|
||||
|
||||
# The same section is also present in the rest-server config. It is
|
||||
|
@ -8,9 +8,12 @@ package docspell.joex
|
||||
|
||||
import cats.data.Validated
|
||||
import cats.data.ValidatedNec
|
||||
import cats.effect.Async
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common.config.Implicits._
|
||||
import docspell.common.Logger
|
||||
import docspell.config.ConfigFactory
|
||||
import docspell.config.Implicits._
|
||||
import docspell.joex.scheduler.CountingScheme
|
||||
|
||||
import emil.MailAddress
|
||||
@ -22,8 +25,12 @@ import yamusca.imports._
|
||||
object ConfigFile {
|
||||
import Implicits._
|
||||
|
||||
def loadConfig: Config =
|
||||
validOrThrow(ConfigSource.default.at("docspell.joex").loadOrThrow[Config])
|
||||
def loadConfig[F[_]: Async](args: List[String]): F[Config] = {
|
||||
val logger = Logger.log4s[F](org.log4s.getLogger)
|
||||
ConfigFactory
|
||||
.default[F, Config](logger, "docspell.joex")(args)
|
||||
.map(cfg => validOrThrow(cfg))
|
||||
}
|
||||
|
||||
private def validOrThrow(cfg: Config): Config =
|
||||
validate(cfg).fold(err => sys.error(err.toList.mkString("- ", "\n", "")), identity)
|
||||
|
@ -116,11 +116,10 @@ object JoexAppImpl {
|
||||
def create[F[_]: Async](
|
||||
cfg: Config,
|
||||
termSignal: SignallingRef[F, Boolean],
|
||||
connectEC: ExecutionContext,
|
||||
clientEC: ExecutionContext
|
||||
connectEC: ExecutionContext
|
||||
): Resource[F, JoexApp[F]] =
|
||||
for {
|
||||
httpClient <- BlazeClientBuilder[F](clientEC).resource
|
||||
httpClient <- BlazeClientBuilder[F].resource
|
||||
client = JoexClient(httpClient)
|
||||
store <- Store.create(cfg.jdbc, cfg.files.chunkSize, connectEC)
|
||||
queue <- JobQueue(store)
|
||||
|
@ -33,9 +33,7 @@ object JoexServer {
|
||||
val app = for {
|
||||
signal <- Resource.eval(SignallingRef[F, Boolean](false))
|
||||
exitCode <- Resource.eval(Ref[F].of(ExitCode.Success))
|
||||
joexApp <-
|
||||
JoexAppImpl
|
||||
.create[F](cfg, signal, pools.connectEC, pools.httpClientEC)
|
||||
joexApp <- JoexAppImpl.create[F](cfg, signal, pools.connectEC)
|
||||
|
||||
httpApp = Router(
|
||||
"/api/info" -> InfoRoutes(cfg),
|
||||
@ -50,7 +48,7 @@ object JoexServer {
|
||||
Stream
|
||||
.resource(app)
|
||||
.flatMap(app =>
|
||||
BlazeServerBuilder[F](pools.restEC)
|
||||
BlazeServerBuilder[F]
|
||||
.bindHttp(cfg.bind.port, cfg.bind.address)
|
||||
.withHttpApp(app.httpApp)
|
||||
.withoutBanner
|
||||
|
@ -6,46 +6,23 @@
|
||||
|
||||
package docspell.joex
|
||||
|
||||
import java.nio.file.{Files, Paths}
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
|
||||
import org.log4s._
|
||||
import org.log4s.getLogger
|
||||
|
||||
object Main extends IOApp {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
val blockingEC =
|
||||
ThreadFactories.cached[IO](ThreadFactories.ofName("docspell-joex-blocking"))
|
||||
val connectEC =
|
||||
private val logger: Logger[IO] = Logger.log4s[IO](getLogger)
|
||||
|
||||
private val connectEC =
|
||||
ThreadFactories.fixed[IO](5, ThreadFactories.ofName("docspell-joex-dbconnect"))
|
||||
val restserverEC =
|
||||
ThreadFactories.workSteal[IO](ThreadFactories.ofNameFJ("docspell-joex-server"))
|
||||
|
||||
def run(args: List[String]) = {
|
||||
args match {
|
||||
case file :: Nil =>
|
||||
val path = Paths.get(file).toAbsolutePath.normalize
|
||||
logger.info(s"Using given config file: $path")
|
||||
System.setProperty("config.file", file)
|
||||
case _ =>
|
||||
Option(System.getProperty("config.file")) match {
|
||||
case Some(f) if f.nonEmpty =>
|
||||
val path = Paths.get(f).toAbsolutePath.normalize
|
||||
if (!Files.exists(path)) {
|
||||
logger.info(s"Not using config file '$f' because it doesn't exist")
|
||||
System.clearProperty("config.file")
|
||||
} else
|
||||
logger.info(s"Using config file from system properties: $f")
|
||||
case _ =>
|
||||
}
|
||||
}
|
||||
|
||||
val cfg = ConfigFile.loadConfig
|
||||
val banner = Banner(
|
||||
def run(args: List[String]): IO[ExitCode] =
|
||||
for {
|
||||
cfg <- ConfigFile.loadConfig[IO](args)
|
||||
banner = Banner(
|
||||
"JOEX",
|
||||
BuildInfo.version,
|
||||
BuildInfo.gitHeadCommit,
|
||||
@ -55,22 +32,19 @@ object Main extends IOApp {
|
||||
cfg.baseUrl,
|
||||
Some(cfg.fullTextSearch.solr.url).filter(_ => cfg.fullTextSearch.enabled)
|
||||
)
|
||||
logger.info(s"\n${banner.render("***>")}")
|
||||
_ <- logger.info(s"\n${banner.render("***>")}")
|
||||
_ <-
|
||||
if (EnvMode.current.isDev) {
|
||||
logger.warn(">>>>> Docspell is running in DEV mode! <<<<<")
|
||||
}
|
||||
} else IO(())
|
||||
|
||||
val pools = for {
|
||||
cec <- connectEC
|
||||
bec <- blockingEC
|
||||
rec <- restserverEC
|
||||
} yield Pools(cec, bec, rec)
|
||||
pools.use(p =>
|
||||
pools = connectEC.map(Pools.apply)
|
||||
rc <- pools.use(p =>
|
||||
JoexServer
|
||||
.stream[IO](cfg, p)
|
||||
.compile
|
||||
.drain
|
||||
.as(ExitCode.Success)
|
||||
)
|
||||
}
|
||||
} yield rc
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ object CheckNodesTask {
|
||||
for {
|
||||
_ <- ctx.logger.info("Check nodes reachability")
|
||||
ec = scala.concurrent.ExecutionContext.global
|
||||
_ <- BlazeClientBuilder[F](ec).resource.use { client =>
|
||||
_ <- BlazeClientBuilder[F].withExecutionContext(ec).resource.use { client =>
|
||||
checkNodes(ctx, client)
|
||||
}
|
||||
_ <- ctx.logger.info(
|
||||
|
@ -77,17 +77,27 @@ object ConvertPdf {
|
||||
ctx: Context[F, ProcessItemArgs],
|
||||
item: ItemData
|
||||
)(ra: RAttachment, mime: MimeType): F[(RAttachment, Option[RAttachmentMeta])] =
|
||||
Conversion.create[F](cfg, sanitizeHtml, ctx.logger).use { conv =>
|
||||
loadCollectivePasswords(ctx).flatMap(collPass =>
|
||||
Conversion.create[F](cfg, sanitizeHtml, collPass, ctx.logger).use { conv =>
|
||||
mime match {
|
||||
case mt =>
|
||||
val data = ctx.store.fileStore.getBytes(ra.fileId)
|
||||
val handler = conversionHandler[F](ctx, cfg, ra, item)
|
||||
ctx.logger.info(s"Converting file ${ra.name} (${mime.asString}) into a PDF") *>
|
||||
ctx.logger
|
||||
.info(s"Converting file ${ra.name} (${mime.asString}) into a PDF") *>
|
||||
conv.toPDF(DataType(mt), ctx.args.meta.language, handler)(
|
||||
data
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
private def loadCollectivePasswords[F[_]: Async](
|
||||
ctx: Context[F, ProcessItemArgs]
|
||||
): F[List[Password]] =
|
||||
ctx.store
|
||||
.transact(RCollectivePassword.findAll(ctx.args.meta.collective))
|
||||
.map(_.map(_.password).distinct)
|
||||
|
||||
private def conversionHandler[F[_]: Sync](
|
||||
ctx: Context[F, ProcessItemArgs],
|
||||
|
@ -2,7 +2,7 @@ openapi: 3.0.0
|
||||
|
||||
info:
|
||||
title: Docspell JOEX
|
||||
version: 0.27.0
|
||||
version: 0.28.0-SNAPSHOT
|
||||
description: |
|
||||
This is the remote API to the job executor component of Docspell.
|
||||
Docspell is a free document management system focused on small
|
||||
|
@ -6,8 +6,6 @@
|
||||
|
||||
package docspell.joexapi.client
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
@ -69,6 +67,6 @@ object JoexClient {
|
||||
Uri.unsafeFromString(u.asString)
|
||||
}
|
||||
|
||||
def resource[F[_]: Async](ec: ExecutionContext): Resource[F, JoexClient[F]] =
|
||||
BlazeClientBuilder[F](ec).resource.map(apply[F])
|
||||
def resource[F[_]: Async]: Resource[F, JoexClient[F]] =
|
||||
BlazeClientBuilder[F].resource.map(apply[F])
|
||||
}
|
||||
|
@ -123,9 +123,11 @@ object ItemQuery {
|
||||
final case class ChecksumMatch(checksum: String) extends Expr
|
||||
final case class AttachId(id: String) extends Expr
|
||||
|
||||
final case object ValidItemStates extends Expr
|
||||
final case object Trashed extends Expr
|
||||
final case object ValidItemsOrTrashed extends Expr
|
||||
/** A "private" expression is only visible in code, but cannot be parsed. */
|
||||
sealed trait PrivateExpr extends Expr
|
||||
final case object ValidItemStates extends PrivateExpr
|
||||
final case object Trashed extends PrivateExpr
|
||||
final case object ValidItemsOrTrashed extends PrivateExpr
|
||||
|
||||
// things that can be expressed with terms above
|
||||
sealed trait MacroExpr extends Expr {
|
||||
@ -186,6 +188,10 @@ object ItemQuery {
|
||||
|
||||
def date(op: Operator, attr: DateAttr, value: Date): SimpleExpr =
|
||||
SimpleExpr(op, Property(attr, value))
|
||||
|
||||
def itemIdEq(itemId1: String, moreIds: String*): Expr =
|
||||
if (moreIds.isEmpty) string(Operator.Eq, Attr.ItemId, itemId1)
|
||||
else InExpr(Attr.ItemId, Nel(itemId1, moreIds.toList))
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -8,12 +8,23 @@ package docspell.query
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
|
||||
import docspell.query.internal.ExprParser
|
||||
import docspell.query.internal.ExprUtil
|
||||
import docspell.query.internal.{ExprParser, ExprString, ExprUtil}
|
||||
|
||||
object ItemQueryParser {
|
||||
|
||||
val PrivateExprError = ExprString.PrivateExprError
|
||||
type PrivateExprError = ExprString.PrivateExprError
|
||||
|
||||
def parse(input: String): Either[ParseFailure, ItemQuery] =
|
||||
parse0(input, expandMacros = true)
|
||||
|
||||
def parseKeepMacros(input: String): Either[ParseFailure, ItemQuery] =
|
||||
parse0(input, expandMacros = false)
|
||||
|
||||
private def parse0(
|
||||
input: String,
|
||||
expandMacros: Boolean
|
||||
): Either[ParseFailure, ItemQuery] =
|
||||
if (input.isEmpty)
|
||||
Left(
|
||||
ParseFailure("", 0, NonEmptyList.of(ParseFailure.SimpleMessage(0, "No input.")))
|
||||
@ -24,9 +35,16 @@ object ItemQueryParser {
|
||||
.parseQuery(in)
|
||||
.left
|
||||
.map(ParseFailure.fromError(in))
|
||||
.map(q => q.copy(expr = ExprUtil.reduce(q.expr)))
|
||||
.map(q => q.copy(expr = ExprUtil.reduce(expandMacros)(q.expr)))
|
||||
}
|
||||
|
||||
def parseUnsafe(input: String): ItemQuery =
|
||||
parse(input).fold(m => sys.error(m.render), identity)
|
||||
|
||||
def asString(q: ItemQuery.Expr): Either[PrivateExprError, String] =
|
||||
ExprString(q)
|
||||
|
||||
def unsafeAsString(q: ItemQuery.Expr): String =
|
||||
asString(q).fold(f => sys.error(s"Cannot expose private query part: $f"), identity)
|
||||
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ object BasicParser {
|
||||
)
|
||||
|
||||
private[this] val identChars: Set[Char] =
|
||||
(('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.").toSet
|
||||
(('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.@").toSet
|
||||
|
||||
val parenAnd: P[Unit] =
|
||||
P.stringIn(List("(&", "(and")).void <* ws0
|
||||
|
@ -0,0 +1,244 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.query.internal
|
||||
|
||||
import java.time.Period
|
||||
|
||||
import docspell.query.Date
|
||||
import docspell.query.Date.DateLiteral
|
||||
import docspell.query.ItemQuery.Attr._
|
||||
import docspell.query.ItemQuery.Expr._
|
||||
import docspell.query.ItemQuery._
|
||||
import docspell.query.internal.{Constants => C}
|
||||
|
||||
/** Creates the string representation for a given expression. The returned string can be
|
||||
* parsed back to the expression using `ExprParser`. Note that expressions obtained from
|
||||
* the `ItemQueryParser` have macros already expanded.
|
||||
*
|
||||
* It may fail when the expression contains non-public parts. Every expression that has
|
||||
* been created by parsing a string, can be transformed back to a string. But an
|
||||
* expression created via code may contain parts that cannot be transformed to a string.
|
||||
*/
|
||||
object ExprString {
|
||||
|
||||
final case class PrivateExprError(expr: Expr.PrivateExpr)
|
||||
type Result = Either[PrivateExprError, String]
|
||||
|
||||
def apply(expr: Expr): Result =
|
||||
expr match {
|
||||
case AndExpr(inner) =>
|
||||
val es = inner.traverse(ExprString.apply)
|
||||
es.map(_.toList.mkString(" ")).map(els => s"(& $els )")
|
||||
|
||||
case OrExpr(inner) =>
|
||||
val es = inner.traverse(ExprString.apply)
|
||||
es.map(_.toList.mkString(" ")).map(els => s"(| $els )")
|
||||
|
||||
case NotExpr(inner) =>
|
||||
inner match {
|
||||
case NotExpr(inner2) =>
|
||||
apply(inner2)
|
||||
case _ =>
|
||||
apply(inner).map(n => s"!$n")
|
||||
}
|
||||
|
||||
case m: MacroExpr =>
|
||||
Right(macroStr(m))
|
||||
|
||||
case DirectionExpr(v) =>
|
||||
Right(s"${C.incoming}${C.like}${v}")
|
||||
|
||||
case InboxExpr(v) =>
|
||||
Right(s"${C.inbox}${C.like}${v}")
|
||||
|
||||
case InExpr(attr, values) =>
|
||||
val els = values.map(quote).toList.mkString(",")
|
||||
Right(s"${attrStr(attr)}${C.in}$els")
|
||||
|
||||
case InDateExpr(attr, values) =>
|
||||
val els = values.map(dateStr).toList.mkString(",")
|
||||
Right(s"${attrStr(attr)}${C.in}$els")
|
||||
|
||||
case TagsMatch(op, values) =>
|
||||
val els = values.map(quote).toList.mkString(",")
|
||||
Right(s"${C.tag}${tagOpStr(op)}$els")
|
||||
|
||||
case TagIdsMatch(op, values) =>
|
||||
val els = values.map(quote).toList.mkString(",")
|
||||
Right(s"${C.tagId}${tagOpStr(op)}$els")
|
||||
|
||||
case Exists(field) =>
|
||||
Right(s"${C.exist}${C.like}${attrStr(field)}")
|
||||
|
||||
case Fulltext(v) =>
|
||||
Right(s"${C.content}${C.like}${quote(v)}")
|
||||
|
||||
case SimpleExpr(op, prop) =>
|
||||
prop match {
|
||||
case Property.StringProperty(attr, value) =>
|
||||
Right(s"${stringAttr(attr)}${opStr(op)}${quote(value)}")
|
||||
case Property.DateProperty(attr, value) =>
|
||||
Right(s"${dateAttr(attr)}${opStr(op)}${dateStr(value)}")
|
||||
case Property.IntProperty(attr, value) =>
|
||||
Right(s"${attrStr(attr)}${opStr(op)}$value")
|
||||
}
|
||||
|
||||
case TagCategoryMatch(op, values) =>
|
||||
val els = values.map(quote).toList.mkString(",")
|
||||
Right(s"${C.cat}${tagOpStr(op)}$els")
|
||||
|
||||
case CustomFieldMatch(name, op, value) =>
|
||||
Right(s"${C.customField}:$name${opStr(op)}${quote(value)}")
|
||||
|
||||
case CustomFieldIdMatch(id, op, value) =>
|
||||
Right(s"${C.customFieldId}:$id${opStr(op)}${quote(value)}")
|
||||
|
||||
case ChecksumMatch(cs) =>
|
||||
Right(s"${C.checksum}${C.like}$cs")
|
||||
|
||||
case AttachId(aid) =>
|
||||
Right(s"${C.attachId}${C.eqs}$aid")
|
||||
|
||||
case pe: PrivateExpr =>
|
||||
// There is no parser equivalent for this
|
||||
Left(PrivateExprError(pe))
|
||||
}
|
||||
|
||||
private[internal] def macroStr(expr: Expr.MacroExpr): String =
|
||||
expr match {
|
||||
case Expr.NamesMacro(name) =>
|
||||
s"${C.names}:${quote(name)}"
|
||||
case Expr.YearMacro(_, year) =>
|
||||
s"${C.year}:$year" //currently, only for Attr.Date
|
||||
case Expr.ConcMacro(term) =>
|
||||
s"${C.conc}:${quote(term)}"
|
||||
case Expr.CorrMacro(term) =>
|
||||
s"${C.corr}:${quote(term)}"
|
||||
case Expr.DateRangeMacro(attr, left, right) =>
|
||||
val name = attr match {
|
||||
case Attr.CreatedDate =>
|
||||
C.createdIn
|
||||
case Attr.Date =>
|
||||
C.dateIn
|
||||
case Attr.DueDate =>
|
||||
C.dueIn
|
||||
}
|
||||
(left, right) match {
|
||||
case (_: Date.DateLiteral, Date.Calc(date, calc, period)) =>
|
||||
s"$name:${dateStr(date)};${calcStr(calc)}${periodStr(period)}"
|
||||
|
||||
case (Date.Calc(date, calc, period), _: DateLiteral) =>
|
||||
s"$name:${dateStr(date)};${calcStr(calc)}${periodStr(period)}"
|
||||
|
||||
case (Date.Calc(d1, _, p1), Date.Calc(_, _, _)) =>
|
||||
s"$name:${dateStr(d1)};/${periodStr(p1)}"
|
||||
|
||||
case (_: DateLiteral, _: DateLiteral) =>
|
||||
sys.error("Invalid date range")
|
||||
}
|
||||
}
|
||||
|
||||
private[internal] def dateStr(date: Date): String =
|
||||
date match {
|
||||
case Date.Today =>
|
||||
"today"
|
||||
case Date.Local(ld) =>
|
||||
f"${ld.getYear}-${ld.getMonthValue}%02d-${ld.getDayOfMonth}%02d"
|
||||
|
||||
case Date.Millis(ms) =>
|
||||
s"ms$ms"
|
||||
|
||||
case Date.Calc(date, calc, period) =>
|
||||
val ds = dateStr(date)
|
||||
s"$ds;${calcStr(calc)}${periodStr(period)}"
|
||||
}
|
||||
|
||||
private[internal] def calcStr(c: Date.CalcDirection): String =
|
||||
c match {
|
||||
case Date.CalcDirection.Plus => "+"
|
||||
case Date.CalcDirection.Minus => "-"
|
||||
}
|
||||
|
||||
private[internal] def periodStr(p: Period): String =
|
||||
if (p.toTotalMonths == 0) s"${p.getDays}d"
|
||||
else s"${p.toTotalMonths}m"
|
||||
|
||||
private[internal] def attrStr(attr: Attr): String =
|
||||
attr match {
|
||||
case a: StringAttr => stringAttr(a)
|
||||
case a: DateAttr => dateAttr(a)
|
||||
case a: IntAttr => intAttr(a)
|
||||
}
|
||||
|
||||
private[internal] def intAttr(attr: IntAttr): String =
|
||||
attr match {
|
||||
case AttachCount =>
|
||||
Constants.attachCount
|
||||
}
|
||||
|
||||
private[internal] def dateAttr(attr: DateAttr): String =
|
||||
attr match {
|
||||
case Attr.Date =>
|
||||
Constants.date
|
||||
case DueDate =>
|
||||
Constants.due
|
||||
case CreatedDate =>
|
||||
Constants.created
|
||||
}
|
||||
|
||||
private[internal] def stringAttr(attr: StringAttr): String =
|
||||
attr match {
|
||||
case Attr.ItemName =>
|
||||
Constants.name
|
||||
case Attr.ItemId =>
|
||||
Constants.id
|
||||
case Attr.ItemSource =>
|
||||
Constants.source
|
||||
case Attr.ItemNotes =>
|
||||
Constants.notes
|
||||
case Correspondent.OrgId =>
|
||||
Constants.corrOrgId
|
||||
case Correspondent.OrgName =>
|
||||
Constants.corrOrgName
|
||||
case Correspondent.PersonId =>
|
||||
Constants.corrPersId
|
||||
case Correspondent.PersonName =>
|
||||
Constants.corrPersName
|
||||
case Concerning.EquipId =>
|
||||
Constants.concEquipId
|
||||
case Concerning.EquipName =>
|
||||
Constants.concEquipName
|
||||
case Concerning.PersonId =>
|
||||
Constants.concPersId
|
||||
case Concerning.PersonName =>
|
||||
Constants.concPersName
|
||||
case Folder.FolderName =>
|
||||
Constants.folder
|
||||
case Folder.FolderId =>
|
||||
Constants.folderId
|
||||
}
|
||||
|
||||
private[internal] def opStr(op: Operator): String =
|
||||
op match {
|
||||
case Operator.Like => Constants.like.toString
|
||||
case Operator.Gte => Constants.gte
|
||||
case Operator.Lte => Constants.lte
|
||||
case Operator.Eq => Constants.eqs.toString
|
||||
case Operator.Lt => Constants.lt.toString
|
||||
case Operator.Gt => Constants.gt.toString
|
||||
case Operator.Neq => Constants.neq
|
||||
}
|
||||
|
||||
private[internal] def tagOpStr(op: TagOperator): String =
|
||||
op match {
|
||||
case TagOperator.AllMatch => C.eqs.toString
|
||||
case TagOperator.AnyMatch => C.like.toString
|
||||
}
|
||||
|
||||
private def quote(s: String): String =
|
||||
s"\"$s\""
|
||||
}
|
@ -13,35 +13,42 @@ import docspell.query.ItemQuery._
|
||||
|
||||
object ExprUtil {
|
||||
|
||||
def reduce(expr: Expr): Expr =
|
||||
reduce(expandMacros = true)(expr)
|
||||
|
||||
/** Does some basic transformation, like unfolding nested and trees containing one value
|
||||
* etc.
|
||||
*/
|
||||
def reduce(expr: Expr): Expr =
|
||||
def reduce(expandMacros: Boolean)(expr: Expr): Expr =
|
||||
expr match {
|
||||
case AndExpr(inner) =>
|
||||
val nodes = spliceAnd(inner)
|
||||
if (nodes.tail.isEmpty) reduce(nodes.head)
|
||||
else AndExpr(nodes.map(reduce))
|
||||
if (nodes.tail.isEmpty) reduce(expandMacros)(nodes.head)
|
||||
else AndExpr(nodes.map(reduce(expandMacros)))
|
||||
|
||||
case OrExpr(inner) =>
|
||||
val nodes = spliceOr(inner)
|
||||
if (nodes.tail.isEmpty) reduce(nodes.head)
|
||||
else OrExpr(nodes.map(reduce))
|
||||
if (nodes.tail.isEmpty) reduce(expandMacros)(nodes.head)
|
||||
else OrExpr(nodes.map(reduce(expandMacros)))
|
||||
|
||||
case NotExpr(inner) =>
|
||||
inner match {
|
||||
case NotExpr(inner2) =>
|
||||
reduce(inner2)
|
||||
reduce(expandMacros)(inner2)
|
||||
case InboxExpr(flag) =>
|
||||
InboxExpr(!flag)
|
||||
case DirectionExpr(flag) =>
|
||||
DirectionExpr(!flag)
|
||||
case _ =>
|
||||
NotExpr(reduce(inner))
|
||||
NotExpr(reduce(expandMacros)(inner))
|
||||
}
|
||||
|
||||
case m: MacroExpr =>
|
||||
reduce(m.body)
|
||||
if (expandMacros) {
|
||||
reduce(expandMacros)(m.body)
|
||||
} else {
|
||||
m
|
||||
}
|
||||
|
||||
case DirectionExpr(_) =>
|
||||
expr
|
||||
|
@ -0,0 +1,287 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.query
|
||||
|
||||
import java.time.{Instant, Period, ZoneOffset}
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
|
||||
import docspell.query.ItemQuery.Expr.TagIdsMatch
|
||||
import docspell.query.ItemQuery._
|
||||
|
||||
import org.scalacheck.Gen
|
||||
|
||||
/** Generator for syntactically valid queries. */
|
||||
object ItemQueryGen {
|
||||
|
||||
def exprGen: Gen[Expr] =
|
||||
Gen.oneOf(
|
||||
simpleExprGen,
|
||||
existsExprGen,
|
||||
inExprGen,
|
||||
inDateExprGen,
|
||||
inboxExprGen,
|
||||
directionExprGen,
|
||||
tagIdsMatchExprGen,
|
||||
tagMatchExprGen,
|
||||
tagCatMatchExpr,
|
||||
customFieldMatchExprGen,
|
||||
customFieldIdMatchExprGen,
|
||||
fulltextExprGen,
|
||||
checksumMatchExprGen,
|
||||
attachIdExprGen,
|
||||
namesMacroGen,
|
||||
corrMacroGen,
|
||||
concMacroGen,
|
||||
yearMacroGen,
|
||||
dateRangeMacro,
|
||||
Gen.lzy(andExprGen(exprGen)),
|
||||
Gen.lzy(orExprGen(exprGen)),
|
||||
Gen.lzy(notExprGen(exprGen))
|
||||
)
|
||||
|
||||
def andExprGen(g: Gen[Expr]): Gen[Expr.AndExpr] =
|
||||
nelGen(g).map(Expr.AndExpr)
|
||||
|
||||
def orExprGen(g: Gen[Expr]): Gen[Expr.OrExpr] =
|
||||
nelGen(g).map(Expr.OrExpr)
|
||||
|
||||
// avoid generating nested not expressions, they are already flattened by the parser
|
||||
// and only occur artificially
|
||||
def notExprGen(g: Gen[Expr]): Gen[Expr] =
|
||||
g.map {
|
||||
case Expr.NotExpr(inner) => inner
|
||||
case e => Expr.NotExpr(e)
|
||||
}
|
||||
|
||||
val opGen: Gen[Operator] =
|
||||
Gen.oneOf(
|
||||
Operator.Like,
|
||||
Operator.Gte,
|
||||
Operator.Lt,
|
||||
Operator.Gt,
|
||||
Operator.Lte,
|
||||
Operator.Eq,
|
||||
Operator.Neq
|
||||
)
|
||||
|
||||
val tagOpGen: Gen[TagOperator] =
|
||||
Gen.oneOf(TagOperator.AllMatch, TagOperator.AnyMatch)
|
||||
|
||||
val stringAttrGen: Gen[Attr.StringAttr] =
|
||||
Gen.oneOf(
|
||||
Attr.Concerning.EquipName,
|
||||
Attr.Concerning.EquipId,
|
||||
Attr.Concerning.PersonName,
|
||||
Attr.Concerning.PersonId,
|
||||
Attr.Correspondent.OrgName,
|
||||
Attr.Correspondent.OrgId,
|
||||
Attr.Correspondent.PersonName,
|
||||
Attr.Correspondent.PersonId,
|
||||
Attr.ItemId,
|
||||
Attr.ItemName,
|
||||
Attr.ItemSource,
|
||||
Attr.ItemNotes,
|
||||
Attr.Folder.FolderId,
|
||||
Attr.Folder.FolderName
|
||||
)
|
||||
|
||||
val dateAttrGen: Gen[Attr.DateAttr] =
|
||||
Gen.oneOf(Attr.Date, Attr.DueDate, Attr.CreatedDate)
|
||||
|
||||
val intAttrGen: Gen[Attr.IntAttr] =
|
||||
Gen.const(Attr.AttachCount)
|
||||
|
||||
val attrGen: Gen[Attr] =
|
||||
Gen.oneOf(stringAttrGen, dateAttrGen, intAttrGen)
|
||||
|
||||
private val valueChars =
|
||||
Gen.oneOf(Gen.alphaNumChar, Gen.oneOf(" /{}*?-:@#$~+%…_[]^!ß"))
|
||||
|
||||
private val stringValueGen: Gen[String] =
|
||||
Gen.choose(1, 20).flatMap(n => Gen.stringOfN(n, valueChars))
|
||||
|
||||
private val intValueGen: Gen[Int] =
|
||||
Gen.choose(1900, 9999)
|
||||
|
||||
private val identGen: Gen[String] =
|
||||
Gen
|
||||
.choose(3, 12)
|
||||
.flatMap(n =>
|
||||
Gen.stringOfN(
|
||||
n,
|
||||
Gen.oneOf((('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.@").toSet)
|
||||
)
|
||||
)
|
||||
|
||||
private def nelGen[T](gen: Gen[T]): Gen[NonEmptyList[T]] =
|
||||
for {
|
||||
head <- gen
|
||||
tail <- Gen.choose(0, 9).flatMap(n => Gen.listOfN(n, gen))
|
||||
} yield NonEmptyList(head, tail)
|
||||
|
||||
private val dateMillisGen: Gen[Long] =
|
||||
Gen.choose(0, Instant.parse("2100-12-24T20:00:00Z").toEpochMilli)
|
||||
|
||||
val localDateGen: Gen[Date.Local] =
|
||||
dateMillisGen
|
||||
.map(ms => Instant.ofEpochMilli(ms).atOffset(ZoneOffset.UTC).toLocalDate)
|
||||
.map(Date.Local)
|
||||
|
||||
val millisDateGen: Gen[Date.Millis] =
|
||||
dateMillisGen.map(Date.Millis)
|
||||
|
||||
val dateLiteralGen: Gen[Date.DateLiteral] =
|
||||
Gen.oneOf(
|
||||
localDateGen,
|
||||
millisDateGen,
|
||||
Gen.const(Date.Today)
|
||||
)
|
||||
|
||||
val periodGen: Gen[Period] =
|
||||
for {
|
||||
mOrD <- Gen.oneOf(a => Period.ofDays(a), a => Period.ofMonths(a))
|
||||
num <- Gen.choose(1, 30)
|
||||
} yield mOrD(num)
|
||||
|
||||
val calcGen: Gen[Date.CalcDirection] =
|
||||
Gen.oneOf(Date.CalcDirection.Plus, Date.CalcDirection.Minus)
|
||||
|
||||
val dateCalcGen: Gen[Date.Calc] =
|
||||
for {
|
||||
dl <- dateLiteralGen
|
||||
calc <- calcGen
|
||||
period <- periodGen
|
||||
} yield Date.Calc(dl, calc, period)
|
||||
|
||||
val dateValueGen: Gen[Date] =
|
||||
Gen.oneOf(dateLiteralGen, dateCalcGen)
|
||||
|
||||
val stringPropGen: Gen[Property.StringProperty] =
|
||||
for {
|
||||
attr <- stringAttrGen
|
||||
sval <- stringValueGen
|
||||
} yield Property.StringProperty(attr, sval)
|
||||
|
||||
val intPropGen: Gen[Property.IntProperty] =
|
||||
for {
|
||||
attr <- intAttrGen
|
||||
ival <- intValueGen
|
||||
} yield Property.IntProperty(attr, ival)
|
||||
|
||||
val datePropGen: Gen[Property.DateProperty] =
|
||||
for {
|
||||
attr <- dateAttrGen
|
||||
dv <- dateValueGen
|
||||
} yield Property.DateProperty(attr, dv)
|
||||
|
||||
val propertyGen: Gen[Property] =
|
||||
Gen.oneOf(stringPropGen, datePropGen, intPropGen)
|
||||
|
||||
val simpleExprGen: Gen[Expr.SimpleExpr] =
|
||||
for {
|
||||
op <- opGen
|
||||
prop <- propertyGen
|
||||
} yield Expr.SimpleExpr(op, prop)
|
||||
|
||||
val existsExprGen: Gen[Expr.Exists] =
|
||||
attrGen.map(Expr.Exists)
|
||||
|
||||
val inExprGen: Gen[Expr.InExpr] =
|
||||
for {
|
||||
attr <- stringAttrGen
|
||||
vals <- nelGen(stringValueGen)
|
||||
} yield Expr.InExpr(attr, vals)
|
||||
|
||||
val inDateExprGen: Gen[Expr.InDateExpr] =
|
||||
for {
|
||||
attr <- dateAttrGen
|
||||
vals <- nelGen(dateValueGen)
|
||||
} yield Expr.InDateExpr(attr, vals)
|
||||
|
||||
val inboxExprGen: Gen[Expr.InboxExpr] =
|
||||
Gen.oneOf(true, false).map(Expr.InboxExpr)
|
||||
|
||||
val directionExprGen: Gen[Expr.DirectionExpr] =
|
||||
Gen.oneOf(true, false).map(Expr.DirectionExpr)
|
||||
|
||||
val tagIdsMatchExprGen: Gen[Expr.TagIdsMatch] =
|
||||
for {
|
||||
op <- tagOpGen
|
||||
vals <- nelGen(stringValueGen)
|
||||
} yield TagIdsMatch(op, vals)
|
||||
|
||||
val tagMatchExprGen: Gen[Expr.TagsMatch] =
|
||||
for {
|
||||
op <- tagOpGen
|
||||
vals <- nelGen(stringValueGen)
|
||||
} yield Expr.TagsMatch(op, vals)
|
||||
|
||||
val tagCatMatchExpr: Gen[Expr.TagCategoryMatch] =
|
||||
for {
|
||||
op <- tagOpGen
|
||||
vals <- nelGen(stringValueGen)
|
||||
} yield Expr.TagCategoryMatch(op, vals)
|
||||
|
||||
val customFieldMatchExprGen: Gen[Expr.CustomFieldMatch] =
|
||||
for {
|
||||
name <- identGen
|
||||
op <- opGen
|
||||
value <- stringValueGen
|
||||
} yield Expr.CustomFieldMatch(name, op, value)
|
||||
|
||||
val customFieldIdMatchExprGen: Gen[Expr.CustomFieldIdMatch] =
|
||||
for {
|
||||
name <- identGen
|
||||
op <- opGen
|
||||
value <- identGen
|
||||
} yield Expr.CustomFieldIdMatch(name, op, value)
|
||||
|
||||
val fulltextExprGen: Gen[Expr.Fulltext] =
|
||||
Gen
|
||||
.choose(3, 20)
|
||||
.flatMap(n => Gen.stringOfN(n, valueChars))
|
||||
.map(Expr.Fulltext)
|
||||
|
||||
val checksumMatchExprGen: Gen[Expr.ChecksumMatch] =
|
||||
Gen.stringOfN(64, Gen.hexChar).map(Expr.ChecksumMatch)
|
||||
|
||||
val attachIdExprGen: Gen[Expr.AttachId] =
|
||||
identGen.map(Expr.AttachId)
|
||||
|
||||
val namesMacroGen: Gen[Expr.NamesMacro] =
|
||||
stringValueGen.map(Expr.NamesMacro)
|
||||
|
||||
val concMacroGen: Gen[Expr.ConcMacro] =
|
||||
stringValueGen.map(Expr.ConcMacro)
|
||||
|
||||
val corrMacroGen: Gen[Expr.CorrMacro] =
|
||||
stringValueGen.map(Expr.CorrMacro)
|
||||
|
||||
val yearMacroGen: Gen[Expr.YearMacro] =
|
||||
Gen.choose(1900, 9999).map(Expr.YearMacro(Attr.Date, _))
|
||||
|
||||
val dateRangeMacro: Gen[Expr.DateRangeMacro] =
|
||||
for {
|
||||
attr <- dateAttrGen
|
||||
dl <- dateLiteralGen
|
||||
p <- periodGen
|
||||
calc <- Gen.option(calcGen)
|
||||
range = calc match {
|
||||
case Some(c @ Date.CalcDirection.Plus) =>
|
||||
Expr.DateRangeMacro(attr, dl, Date.Calc(dl, c, p))
|
||||
case Some(c @ Date.CalcDirection.Minus) =>
|
||||
Expr.DateRangeMacro(attr, Date.Calc(dl, c, p), dl)
|
||||
case None =>
|
||||
Expr.DateRangeMacro(
|
||||
attr,
|
||||
Date.Calc(dl, Date.CalcDirection.Minus, p),
|
||||
Date.Calc(dl, Date.CalcDirection.Plus, p)
|
||||
)
|
||||
}
|
||||
} yield range
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.query.internal
|
||||
|
||||
import java.time.{LocalDate, Period}
|
||||
|
||||
import docspell.query.ItemQuery._
|
||||
import docspell.query.{Date, ItemQueryGen, ParseFailure}
|
||||
|
||||
import munit.{FunSuite, ScalaCheckSuite}
|
||||
import org.scalacheck.Prop.forAll
|
||||
|
||||
class ExprStringTest extends FunSuite with ScalaCheckSuite {
|
||||
|
||||
// parses the query without reducing and expanding macros
|
||||
def singleParse(s: String): Expr =
|
||||
ExprParser
|
||||
.parseQuery(s)
|
||||
.left
|
||||
.map(ParseFailure.fromError(s))
|
||||
.fold(f => sys.error(f.render), _.expr)
|
||||
|
||||
def exprString(expr: Expr): String =
|
||||
ExprString(expr).fold(f => sys.error(f.toString), identity)
|
||||
|
||||
test("macro: name") {
|
||||
val str = exprString(Expr.NamesMacro("test"))
|
||||
val q = singleParse(str)
|
||||
assertEquals(str, "names:\"test\"")
|
||||
assertEquals(q, Expr.NamesMacro("test"))
|
||||
}
|
||||
|
||||
test("macro: year") {
|
||||
val str = exprString(Expr.YearMacro(Attr.Date, 1990))
|
||||
val q = singleParse(str)
|
||||
assertEquals(str, "year:1990")
|
||||
assertEquals(q, Expr.YearMacro(Attr.Date, 1990))
|
||||
}
|
||||
|
||||
test("macro: daterange") {
|
||||
val range = Expr.DateRangeMacro(
|
||||
attr = Attr.Date,
|
||||
left = Date.Calc(
|
||||
date = Date.Local(
|
||||
date = LocalDate.of(2076, 12, 9)
|
||||
),
|
||||
calc = Date.CalcDirection.Minus,
|
||||
period = Period.ofMonths(27)
|
||||
),
|
||||
right = Date.Local(LocalDate.of(2076, 12, 9))
|
||||
)
|
||||
val str = exprString(range)
|
||||
val q = singleParse(str)
|
||||
assertEquals(str, "dateIn:2076-12-09;-27m")
|
||||
assertEquals(q, range)
|
||||
}
|
||||
|
||||
property("generate expr and parse it") {
|
||||
forAll(ItemQueryGen.exprGen) { expr =>
|
||||
val str = exprString(expr)
|
||||
val q = singleParse(str)
|
||||
assertEquals(q, expr)
|
||||
}
|
||||
}
|
||||
}
|
@ -8,7 +8,7 @@ package docspell.query.internal
|
||||
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.query.ItemQueryParser
|
||||
import docspell.query.{ItemQuery, ItemQueryParser}
|
||||
|
||||
import munit._
|
||||
|
||||
@ -64,4 +64,14 @@ class ItemQueryParserTest extends FunSuite {
|
||||
ItemQueryParser.parseUnsafe("(| name:hello date:2021-02 name:world name:hello )")
|
||||
assertEquals(expect.copy(raw = raw.some), q)
|
||||
}
|
||||
|
||||
test("f.id:name=value") {
|
||||
val raw = "f.id:QsuGW@=\"dAHBstXJd0\""
|
||||
val q = ItemQueryParser.parseUnsafe(raw)
|
||||
val expect =
|
||||
ItemQuery.Expr.CustomFieldIdMatch("QsuGW@", ItemQuery.Operator.Eq, "dAHBstXJd0")
|
||||
|
||||
assertEquals(q.expr, expect)
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ openapi: 3.0.0
|
||||
|
||||
info:
|
||||
title: Docspell
|
||||
version: 0.27.0
|
||||
version: 0.28.0-SNAPSHOT
|
||||
description: |
|
||||
This is the remote API to Docspell. Docspell is a free document
|
||||
management system focused on small groups or families.
|
||||
@ -538,6 +538,37 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InviteResult"
|
||||
|
||||
/open/share/verify:
|
||||
post:
|
||||
operationId: "open-share-verify"
|
||||
tags: [ Share ]
|
||||
summary: Verify a secret for a share
|
||||
description: |
|
||||
Given the share id and optionally a password, it verifies the
|
||||
correctness of the given data. As a result, a token is
|
||||
returned that must be used with all `share/*` routes. If the
|
||||
password is missing, but required, the response indicates
|
||||
this. Then the requests needs to be replayed with the correct
|
||||
password to retrieve the token.
|
||||
|
||||
The token is also added as a session cookie to the response.
|
||||
|
||||
The token is used to avoid passing the user define password
|
||||
with every request.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ShareSecret"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ShareVerifyResult"
|
||||
|
||||
/sec/auth/session:
|
||||
post:
|
||||
operationId: "sec-auth-session"
|
||||
@ -1527,6 +1558,187 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/share/search/query:
|
||||
post:
|
||||
operationId: "share-search-query"
|
||||
tags: [Share]
|
||||
summary: Performs a search in a share.
|
||||
description: |
|
||||
Allows to run a search query in the shared documents. The
|
||||
input data structure is the same as with a standard query. The
|
||||
`searchMode` parameter is ignored here.
|
||||
security:
|
||||
- shareTokenHeader: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ItemQuery"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ItemLightList"
|
||||
/share/search/stats:
|
||||
post:
|
||||
operationId: "share-search-stats"
|
||||
tags: [ Share ]
|
||||
summary: Get basic statistics about search results.
|
||||
description: |
|
||||
Instead of returning the results of a query, uses it to return
|
||||
a summary, constraint to the share.
|
||||
security:
|
||||
- shareTokenHeader: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ItemQuery"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SearchStats"
|
||||
/share/item/{id}:
|
||||
get:
|
||||
operationId: "share-item-get"
|
||||
tags: [ Share ]
|
||||
summary: Get details about an item.
|
||||
description: |
|
||||
Get detailed information about an item.
|
||||
security:
|
||||
- shareTokenHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ItemDetail"
|
||||
/share/attachment/{id}:
|
||||
head:
|
||||
operationId: "share-attach-head"
|
||||
tags: [ Share ]
|
||||
summary: Get headers to an attachment file.
|
||||
description: |
|
||||
Get information about the binary file belonging to the
|
||||
attachment with the given id.
|
||||
security:
|
||||
- shareTokenHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
headers:
|
||||
Content-Type:
|
||||
schema:
|
||||
type: string
|
||||
Content-Length:
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
ETag:
|
||||
schema:
|
||||
type: string
|
||||
Content-Disposition:
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
operationId: "share-attach-get"
|
||||
tags: [ Share ]
|
||||
summary: Get an attachment file.
|
||||
description: |
|
||||
Get the binary file belonging to the attachment with the given
|
||||
id. The binary is a pdf file. If conversion failed, then the
|
||||
original file is returned.
|
||||
security:
|
||||
- shareTokenHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
/share/attachment/{id}/view:
|
||||
get:
|
||||
operationId: "share-attach-show-viewerjs"
|
||||
tags: [ Share ]
|
||||
summary: A javascript rendered view of the pdf attachment
|
||||
description: |
|
||||
This provides a preview of the attachment rendered in a
|
||||
browser.
|
||||
|
||||
It currently uses a third-party javascript library (viewerjs)
|
||||
to display the preview. This works by redirecting to the
|
||||
viewerjs url with the attachment url as parameter. Note that
|
||||
the resulting url that is redirected to is not stable. It may
|
||||
change from version to version. This route, however, is meant
|
||||
to provide a stable url for the preview.
|
||||
security:
|
||||
- shareTokenHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
303:
|
||||
description: See Other
|
||||
200:
|
||||
description: Ok
|
||||
/share/attachment/{id}/preview:
|
||||
head:
|
||||
operationId: "share-attach-check-preview"
|
||||
tags: [ Attachment ]
|
||||
summary: Get the headers to a preview image of an attachment file.
|
||||
description: |
|
||||
Checks if an image file showing a preview of the attachment is
|
||||
available. If not available, a 404 is returned.
|
||||
security:
|
||||
- shareTokenHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
404:
|
||||
description: NotFound
|
||||
get:
|
||||
operationId: "share-attach-get-preview"
|
||||
tags: [ Attachment ]
|
||||
summary: Get a preview image of an attachment file.
|
||||
description: |
|
||||
Gets a image file showing a preview of the attachment. Usually
|
||||
it is a small image of the first page of the document.If not
|
||||
available, a 404 is returned. However, if the query parameter
|
||||
`withFallback` is `true`, a fallback preview image is
|
||||
returned. You can also use the `HEAD` method to check for
|
||||
existence.
|
||||
|
||||
The attachment must be in the search results of the current
|
||||
share.
|
||||
security:
|
||||
- shareTokenHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
- $ref: "#/components/parameters/withFallback"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
|
||||
/admin/user/resetPassword:
|
||||
post:
|
||||
operationId: "admin-user-reset-password"
|
||||
@ -1711,6 +1923,125 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/sec/share:
|
||||
get:
|
||||
operationId: "sec-share-get-all"
|
||||
tags: [ Share ]
|
||||
summary: Get a list of shares
|
||||
description: |
|
||||
Return a list of all shares for this collective.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/q"
|
||||
- $ref: "#/components/parameters/owningShare"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ShareList"
|
||||
post:
|
||||
operationId: "sec-share-new"
|
||||
tags: [ Share ]
|
||||
summary: Create a new share.
|
||||
description: |
|
||||
Create a new share.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ShareData"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/IdResult"
|
||||
/sec/share/email/send/{name}:
|
||||
post:
|
||||
operationId: "sec-share-email-send"
|
||||
tags: [ Share, E-Mail ]
|
||||
summary: Send an email.
|
||||
description: |
|
||||
Sends an email as specified in the body of the request.
|
||||
|
||||
An existing shareId must be given with the request, no matter
|
||||
the content of the mail.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/name"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SimpleShareMail"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
/sec/share/{shareId}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/shareId"
|
||||
get:
|
||||
operationId: "sec-share-get"
|
||||
tags: [Share]
|
||||
summary: Get details to a single share.
|
||||
description: |
|
||||
Given the id of a share, returns some details about it.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ShareDetail"
|
||||
put:
|
||||
operationId: "sec-share-update"
|
||||
tags: [ Share ]
|
||||
summary: Update an existing share.
|
||||
description: |
|
||||
Updates an existing share.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ShareData"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
delete:
|
||||
operationId: "sec-share-delete-by-id"
|
||||
tags: [ Share ]
|
||||
summary: Delete a share.
|
||||
description: |
|
||||
Deletes a share
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/sec/item/search:
|
||||
get:
|
||||
operationId: "sec-item-search-by-get"
|
||||
@ -4096,6 +4427,126 @@ paths:
|
||||
|
||||
components:
|
||||
schemas:
|
||||
ShareSecret:
|
||||
description: |
|
||||
The secret (the share id + optional password) to access a
|
||||
share.
|
||||
required:
|
||||
- shareId
|
||||
properties:
|
||||
shareId:
|
||||
type: string
|
||||
format: ident
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
|
||||
ShareVerifyResult:
|
||||
description: |
|
||||
The data returned when verifying a `ShareSecret`.
|
||||
required:
|
||||
- success
|
||||
- token
|
||||
- passwordRequired
|
||||
- message
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
token:
|
||||
type: string
|
||||
passwordRequired:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description: |
|
||||
The name of the share if it exists. Only valid to use when
|
||||
`success` is `true`.
|
||||
|
||||
ShareData:
|
||||
description: |
|
||||
Editable data for a share.
|
||||
required:
|
||||
- query
|
||||
- enabled
|
||||
- publishUntil
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
query:
|
||||
type: string
|
||||
format: itemquery
|
||||
enabled:
|
||||
type: boolean
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
publishUntil:
|
||||
type: integer
|
||||
format: date-time
|
||||
removePassword:
|
||||
type: boolean
|
||||
description: |
|
||||
For an update request, this can control whether to delete
|
||||
the password. Otherwise if the password is not set, it
|
||||
will not be changed. When adding a new share, this has no
|
||||
effect.
|
||||
|
||||
ShareDetail:
|
||||
description: |
|
||||
Details for an existing share.
|
||||
required:
|
||||
- id
|
||||
- query
|
||||
- owner
|
||||
- enabled
|
||||
- publishAt
|
||||
- publishUntil
|
||||
- password
|
||||
- views
|
||||
- expired
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: ident
|
||||
query:
|
||||
type: string
|
||||
format: itemquery
|
||||
owner:
|
||||
$ref: "#/components/schemas/IdName"
|
||||
name:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
publishAt:
|
||||
type: integer
|
||||
format: date-time
|
||||
publishUntil:
|
||||
type: integer
|
||||
format: date-time
|
||||
expired:
|
||||
type: boolean
|
||||
password:
|
||||
type: boolean
|
||||
views:
|
||||
type: integer
|
||||
format: int32
|
||||
lastAccess:
|
||||
type: integer
|
||||
format: date-time
|
||||
|
||||
ShareList:
|
||||
description: |
|
||||
A list of shares.
|
||||
required:
|
||||
- items
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ShareDetail"
|
||||
|
||||
DeleteUserData:
|
||||
description: |
|
||||
An excerpt of data that would be deleted when deleting the
|
||||
@ -4103,6 +4554,7 @@ components:
|
||||
required:
|
||||
- folders
|
||||
- sentMails
|
||||
- shares
|
||||
properties:
|
||||
folders:
|
||||
type: array
|
||||
@ -4112,6 +4564,9 @@ components:
|
||||
sentMails:
|
||||
type: integer
|
||||
format: int32
|
||||
shares:
|
||||
type: integer
|
||||
format: int32
|
||||
|
||||
SecondFactor:
|
||||
description: |
|
||||
@ -4864,6 +5319,36 @@ components:
|
||||
items:
|
||||
type: string
|
||||
format: ident
|
||||
SimpleShareMail:
|
||||
description: |
|
||||
A simple e-mail related to a share.
|
||||
required:
|
||||
- shareId
|
||||
- recipients
|
||||
- cc
|
||||
- bcc
|
||||
- subject
|
||||
- body
|
||||
properties:
|
||||
shareId:
|
||||
type: string
|
||||
format: ident
|
||||
recipients:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
cc:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
bcc:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
EmailSettingsList:
|
||||
description: |
|
||||
A list of user email settings.
|
||||
@ -4977,6 +5462,10 @@ components:
|
||||
- tagCategoryCloud
|
||||
- fieldStats
|
||||
- folderStats
|
||||
- corrOrgStats
|
||||
- corrPersStats
|
||||
- concPersStats
|
||||
- concEquipStats
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
@ -4993,6 +5482,23 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/FolderStats"
|
||||
corrOrgStats:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IdRefStats"
|
||||
corrPersStats:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IdRefStats"
|
||||
concPersStats:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IdRefStats"
|
||||
concEquipStats:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IdRefStats"
|
||||
|
||||
ItemInsights:
|
||||
description: |
|
||||
Information about the items in docspell.
|
||||
@ -5126,6 +5632,19 @@ components:
|
||||
type: integer
|
||||
format: int32
|
||||
|
||||
IdRefStats:
|
||||
description: |
|
||||
Counting some objects that have an id and a name.
|
||||
required:
|
||||
- ref
|
||||
- count
|
||||
properties:
|
||||
ref:
|
||||
$ref: "#/components/schemas/IdName"
|
||||
count:
|
||||
type: integer
|
||||
format: int32
|
||||
|
||||
AttachmentMeta:
|
||||
description: |
|
||||
Extracted meta data of an attachment.
|
||||
@ -5635,6 +6154,7 @@ components:
|
||||
- integrationEnabled
|
||||
- classifier
|
||||
- emptyTrash
|
||||
- passwords
|
||||
properties:
|
||||
language:
|
||||
type: string
|
||||
@ -5648,6 +6168,11 @@ components:
|
||||
$ref: "#/components/schemas/ClassifierSetting"
|
||||
emptyTrash:
|
||||
$ref: "#/components/schemas/EmptyTrashSetting"
|
||||
passwords:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: password
|
||||
|
||||
EmptyTrashSetting:
|
||||
description: |
|
||||
@ -6115,8 +6640,8 @@ components:
|
||||
type: string
|
||||
IdResult:
|
||||
description: |
|
||||
Some basic result of an operation with an ID as payload. If
|
||||
success if `false` the id is not usable.
|
||||
Some basic result of an operation with an ID as payload, if
|
||||
success is true. If success is `false` the id is not usable.
|
||||
required:
|
||||
- success
|
||||
- message
|
||||
@ -6236,6 +6761,10 @@ components:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: Docspell-Admin-Secret
|
||||
shareTokenHeader:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: Docspell-Share-Auth
|
||||
parameters:
|
||||
id:
|
||||
name: id
|
||||
@ -6251,6 +6780,13 @@ components:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
shareId:
|
||||
name: shareId
|
||||
in: path
|
||||
description: An identifier for a share
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
username:
|
||||
name: username
|
||||
in: path
|
||||
@ -6279,6 +6815,13 @@ components:
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
owningShare:
|
||||
name: owning
|
||||
in: query
|
||||
description: Return my own shares only
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
checksum:
|
||||
name: checksum
|
||||
in: path
|
||||
|
@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.restapi.codec
|
||||
|
||||
import docspell.query.{ItemQuery, ItemQueryParser}
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
trait ItemQueryJson {
|
||||
|
||||
implicit val itemQueryDecoder: Decoder[ItemQuery] =
|
||||
Decoder.decodeString.emap(str => ItemQueryParser.parse(str).left.map(_.render))
|
||||
|
||||
implicit val itemQueryEncoder: Encoder[ItemQuery] =
|
||||
Encoder.encodeString.contramap(q =>
|
||||
q.raw.getOrElse(ItemQueryParser.unsafeAsString(q.expr))
|
||||
)
|
||||
}
|
||||
|
||||
object ItemQueryJson extends ItemQueryJson
|
@ -47,8 +47,9 @@ docspell.server {
|
||||
# The secret for this server that is used to sign the authenicator
|
||||
# tokens. If multiple servers are running, all must share the same
|
||||
# secret. You can use base64 or hex strings (prefix with b64: and
|
||||
# hex:, respectively).
|
||||
server-secret = "hex:caffee"
|
||||
# hex:, respectively). If empty, a random secret is generated.
|
||||
# Example: b64:YRx77QujCGkHSvll0TVEmtTaw3Z5eXr+nWMsEJowgKg=
|
||||
server-secret = ""
|
||||
|
||||
# How long an authentication token is valid. The web application
|
||||
# will get a new one periodically.
|
||||
@ -279,6 +280,7 @@ docspell.server {
|
||||
|
||||
# Configuration for the backend.
|
||||
backend {
|
||||
|
||||
# Enable or disable debugging for e-mail related functionality. This
|
||||
# applies to both sending and receiving mails. For security reasons
|
||||
# logging is not very extensive on authentication failures. Setting
|
||||
@ -286,13 +288,17 @@ docspell.server {
|
||||
mail-debug = false
|
||||
|
||||
# The database connection.
|
||||
#
|
||||
# By default a H2 file-based database is configured. You can
|
||||
# provide a postgresql or mariadb connection here. When using H2
|
||||
# use the PostgreSQL compatibility mode and AUTO_SERVER feature.
|
||||
jdbc {
|
||||
# The JDBC url to the database. By default a H2 file-based
|
||||
# database is configured. You can provide a postgresql or
|
||||
# mariadb connection here. When using H2 use the PostgreSQL
|
||||
# compatibility mode and AUTO_SERVER feature.
|
||||
url = "jdbc:h2://"${java.io.tmpdir}"/docspell-demo.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;AUTO_SERVER=TRUE"
|
||||
|
||||
# The database user.
|
||||
user = "sa"
|
||||
|
||||
# The database password.
|
||||
password = ""
|
||||
}
|
||||
|
||||
|
@ -6,23 +6,34 @@
|
||||
|
||||
package docspell.restserver
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
import cats.Semigroup
|
||||
import cats.data.{Validated, ValidatedNec}
|
||||
import cats.effect.Async
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.signup.{Config => SignupConfig}
|
||||
import docspell.common.config.Implicits._
|
||||
import docspell.common.Logger
|
||||
import docspell.config.ConfigFactory
|
||||
import docspell.config.Implicits._
|
||||
import docspell.oidc.{ProviderConfig, SignatureAlgo}
|
||||
import docspell.restserver.auth.OpenId
|
||||
|
||||
import pureconfig._
|
||||
import pureconfig.generic.auto._
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
object ConfigFile {
|
||||
private[this] val unsafeLogger = org.log4s.getLogger
|
||||
import Implicits._
|
||||
|
||||
def loadConfig: Config =
|
||||
Validate(ConfigSource.default.at("docspell.server").loadOrThrow[Config])
|
||||
def loadConfig[F[_]: Async](args: List[String]): F[Config] = {
|
||||
val logger = Logger.log4s(unsafeLogger)
|
||||
ConfigFactory
|
||||
.default[F, Config](logger, "docspell.server")(args)
|
||||
.map(cfg => Validate(cfg))
|
||||
}
|
||||
|
||||
object Implicits {
|
||||
implicit val signupModeReader: ConfigReader[SignupConfig.Mode] =
|
||||
@ -50,12 +61,25 @@ object ConfigFile {
|
||||
|
||||
def all(cfg: Config) = List(
|
||||
duplicateOpenIdProvider(cfg),
|
||||
signKeyVsUserUrl(cfg)
|
||||
signKeyVsUserUrl(cfg),
|
||||
generateSecretIfEmpty(cfg)
|
||||
)
|
||||
|
||||
private def valid(cfg: Config): ValidatedNec[String, Config] =
|
||||
Validated.validNec(cfg)
|
||||
|
||||
def generateSecretIfEmpty(cfg: Config): ValidatedNec[String, Config] =
|
||||
if (cfg.auth.serverSecret.isEmpty) {
|
||||
unsafeLogger.warn(
|
||||
"No serverSecret specified. Generating a random one. It is recommended to add a server-secret in the config file."
|
||||
)
|
||||
val random = new SecureRandom()
|
||||
val buffer = new Array[Byte](32)
|
||||
random.nextBytes(buffer)
|
||||
val secret = ByteVector.view(buffer)
|
||||
valid(cfg.copy(auth = cfg.auth.copy(serverSecret = secret)))
|
||||
} else valid(cfg)
|
||||
|
||||
def duplicateOpenIdProvider(cfg: Config): ValidatedNec[String, Config] = {
|
||||
val dupes =
|
||||
cfg.openid
|
||||
|
@ -6,46 +6,21 @@
|
||||
|
||||
package docspell.restserver
|
||||
|
||||
import java.nio.file.{Files, Paths}
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
|
||||
import org.log4s._
|
||||
import org.log4s.getLogger
|
||||
|
||||
object Main extends IOApp {
|
||||
private[this] val logger = getLogger
|
||||
private[this] val logger: Logger[IO] = Logger.log4s(getLogger)
|
||||
|
||||
val blockingEC =
|
||||
ThreadFactories.cached[IO](ThreadFactories.ofName("docspell-restserver-blocking"))
|
||||
val connectEC =
|
||||
private val connectEC =
|
||||
ThreadFactories.fixed[IO](5, ThreadFactories.ofName("docspell-dbconnect"))
|
||||
val restserverEC =
|
||||
ThreadFactories.workSteal[IO](ThreadFactories.ofNameFJ("docspell-restserver"))
|
||||
|
||||
def run(args: List[String]) = {
|
||||
args match {
|
||||
case file :: Nil =>
|
||||
val path = Paths.get(file).toAbsolutePath.normalize
|
||||
logger.info(s"Using given config file: $path")
|
||||
System.setProperty("config.file", file)
|
||||
case _ =>
|
||||
Option(System.getProperty("config.file")) match {
|
||||
case Some(f) if f.nonEmpty =>
|
||||
val path = Paths.get(f).toAbsolutePath.normalize
|
||||
if (!Files.exists(path)) {
|
||||
logger.info(s"Not using config file '$f' because it doesn't exist")
|
||||
System.clearProperty("config.file")
|
||||
} else
|
||||
logger.info(s"Using config file from system properties: $f")
|
||||
case _ =>
|
||||
}
|
||||
}
|
||||
|
||||
val cfg = ConfigFile.loadConfig
|
||||
val banner = Banner(
|
||||
def run(args: List[String]) = for {
|
||||
cfg <- ConfigFile.loadConfig[IO](args)
|
||||
banner = Banner(
|
||||
"REST Server",
|
||||
BuildInfo.version,
|
||||
BuildInfo.gitHeadCommit,
|
||||
@ -55,17 +30,14 @@ object Main extends IOApp {
|
||||
cfg.baseUrl,
|
||||
Some(cfg.fullTextSearch.solr.url).filter(_ => cfg.fullTextSearch.enabled)
|
||||
)
|
||||
val pools = for {
|
||||
cec <- connectEC
|
||||
bec <- blockingEC
|
||||
rec <- restserverEC
|
||||
} yield Pools(cec, bec, rec)
|
||||
|
||||
logger.info(s"\n${banner.render("***>")}")
|
||||
_ <- logger.info(s"\n${banner.render("***>")}")
|
||||
_ <-
|
||||
if (EnvMode.current.isDev) {
|
||||
logger.warn(">>>>> Docspell is running in DEV mode! <<<<<")
|
||||
}
|
||||
} else IO(())
|
||||
|
||||
pools = connectEC.map(Pools.apply)
|
||||
rc <-
|
||||
pools.use(p =>
|
||||
RestServer
|
||||
.stream[IO](cfg, p)
|
||||
@ -73,5 +45,5 @@ object Main extends IOApp {
|
||||
.drain
|
||||
.as(ExitCode.Success)
|
||||
)
|
||||
}
|
||||
} yield rc
|
||||
}
|
||||
|
@ -32,11 +32,10 @@ object RestAppImpl {
|
||||
|
||||
def create[F[_]: Async](
|
||||
cfg: Config,
|
||||
connectEC: ExecutionContext,
|
||||
httpClientEc: ExecutionContext
|
||||
connectEC: ExecutionContext
|
||||
): Resource[F, RestApp[F]] =
|
||||
for {
|
||||
backend <- BackendApp(cfg.backend, connectEC, httpClientEc)(
|
||||
backend <- BackendApp(cfg.backend, connectEC)(
|
||||
createFtsClient[F](cfg)
|
||||
)
|
||||
app = new RestAppImpl[F](cfg, backend)
|
||||
|
@ -10,7 +10,7 @@ import cats.effect._
|
||||
import cats.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.auth.{AuthToken, ShareToken}
|
||||
import docspell.common._
|
||||
import docspell.oidc.CodeFlowRoutes
|
||||
import docspell.restserver.auth.OpenId
|
||||
@ -34,19 +34,20 @@ object RestServer {
|
||||
|
||||
val templates = TemplateRoutes[F](cfg)
|
||||
val app = for {
|
||||
restApp <-
|
||||
RestAppImpl
|
||||
.create[F](cfg, pools.connectEC, pools.httpClientEC)
|
||||
httpClient <- BlazeClientBuilder[F](pools.httpClientEC).resource
|
||||
restApp <- RestAppImpl.create[F](cfg, pools.connectEC)
|
||||
httpClient <- BlazeClientBuilder[F].resource
|
||||
httpApp = Router(
|
||||
"/api/info" -> routes.InfoRoutes(),
|
||||
"/api/v1/open/" -> openRoutes(cfg, httpClient, restApp),
|
||||
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
|
||||
securedRoutes(cfg, restApp, token)
|
||||
},
|
||||
"/api/v1/admin" -> AdminRoutes(cfg.adminEndpoint) {
|
||||
"/api/v1/admin" -> AdminAuth(cfg.adminEndpoint) {
|
||||
adminRoutes(cfg, restApp)
|
||||
},
|
||||
"/api/v1/share" -> ShareAuth(restApp.backend.share, cfg.auth) { token =>
|
||||
shareRoutes(cfg, restApp, token)
|
||||
},
|
||||
"/api/doc" -> templates.doc,
|
||||
"/app/assets" -> EnvMiddleware(WebjarRoutes.appRoutes[F]),
|
||||
"/app" -> EnvMiddleware(templates.app),
|
||||
@ -61,7 +62,7 @@ object RestServer {
|
||||
Stream
|
||||
.resource(app)
|
||||
.flatMap(httpApp =>
|
||||
BlazeServerBuilder[F](pools.restEC)
|
||||
BlazeServerBuilder[F]
|
||||
.bindHttp(cfg.bind.port, cfg.bind.address)
|
||||
.withHttpApp(httpApp)
|
||||
.withoutBanner
|
||||
@ -94,6 +95,7 @@ object RestServer {
|
||||
"email/send" -> MailSendRoutes(restApp.backend, token),
|
||||
"email/settings" -> MailSettingsRoutes(restApp.backend, token),
|
||||
"email/sent" -> SentMailRoutes(restApp.backend, token),
|
||||
"share" -> ShareRoutes.manage(restApp.backend, token),
|
||||
"usertask/notifydueitems" -> NotifyDueItemsRoutes(cfg, restApp.backend, token),
|
||||
"usertask/scanmailbox" -> ScanMailboxRoutes(restApp.backend, token),
|
||||
"calevent/check" -> CalEventCheckRoutes(),
|
||||
@ -119,7 +121,8 @@ object RestServer {
|
||||
"signup" -> RegisterRoutes(restApp.backend, cfg),
|
||||
"upload" -> UploadRoutes.open(restApp.backend, cfg),
|
||||
"checkfile" -> CheckFileRoutes.open(restApp.backend),
|
||||
"integration" -> IntegrationEndpointRoutes.open(restApp.backend, cfg)
|
||||
"integration" -> IntegrationEndpointRoutes.open(restApp.backend, cfg),
|
||||
"share" -> ShareRoutes.verify(restApp.backend, cfg)
|
||||
)
|
||||
|
||||
def adminRoutes[F[_]: Async](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
|
||||
@ -131,6 +134,17 @@ object RestServer {
|
||||
"attachments" -> AttachmentRoutes.admin(restApp.backend)
|
||||
)
|
||||
|
||||
def shareRoutes[F[_]: Async](
|
||||
cfg: Config,
|
||||
restApp: RestApp[F],
|
||||
token: ShareToken
|
||||
): HttpRoutes[F] =
|
||||
Router(
|
||||
"search" -> ShareSearchRoutes(restApp.backend, cfg, token),
|
||||
"attachment" -> ShareAttachmentRoutes(restApp.backend, token),
|
||||
"item" -> ShareItemRoutes(restApp.backend, token)
|
||||
)
|
||||
|
||||
def redirectTo[F[_]: Async](path: String): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.restserver.auth
|
||||
|
||||
import docspell.backend.auth.ShareToken
|
||||
import docspell.common._
|
||||
|
||||
import org.http4s._
|
||||
import org.typelevel.ci.CIString
|
||||
|
||||
final case class ShareCookieData(token: ShareToken) {
|
||||
def asString: String = token.asString
|
||||
|
||||
def asCookie(baseUrl: LenientUri): ResponseCookie = {
|
||||
val sec = baseUrl.scheme.exists(_.endsWith("s"))
|
||||
val path = baseUrl.path / "api" / "v1"
|
||||
ResponseCookie(
|
||||
name = ShareCookieData.cookieName,
|
||||
content = asString,
|
||||
domain = None,
|
||||
path = Some(path.asString),
|
||||
httpOnly = true,
|
||||
secure = sec,
|
||||
maxAge = None,
|
||||
expires = None
|
||||
)
|
||||
}
|
||||
|
||||
def addCookie[F[_]](baseUrl: LenientUri)(
|
||||
resp: Response[F]
|
||||
): Response[F] =
|
||||
resp.addCookie(asCookie(baseUrl))
|
||||
}
|
||||
|
||||
object ShareCookieData {
|
||||
val cookieName = "docspell_share"
|
||||
val headerName = "Docspell-Share-Auth"
|
||||
|
||||
def fromCookie[F[_]](req: Request[F]): Option[String] =
|
||||
for {
|
||||
header <- req.headers.get[headers.Cookie]
|
||||
cookie <- header.values.toList.find(_.name == cookieName)
|
||||
} yield cookie.content
|
||||
|
||||
def fromHeader[F[_]](req: Request[F]): Option[String] =
|
||||
req.headers
|
||||
.get(CIString(headerName))
|
||||
.map(_.head.value)
|
||||
|
||||
def fromRequest[F[_]](req: Request[F]): Option[String] =
|
||||
fromCookie(req).orElse(fromHeader(req))
|
||||
|
||||
def delete(baseUrl: LenientUri): ResponseCookie =
|
||||
ResponseCookie(
|
||||
name = cookieName,
|
||||
content = "",
|
||||
domain = None,
|
||||
path = Some(baseUrl.path / "api" / "v1").map(_.asString),
|
||||
httpOnly = true,
|
||||
secure = baseUrl.scheme.exists(_.endsWith("s")),
|
||||
maxAge = None,
|
||||
expires = None
|
||||
)
|
||||
|
||||
}
|
@ -22,7 +22,7 @@ import docspell.common.syntax.all._
|
||||
import docspell.ftsclient.FtsResult
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.conv.Conversions._
|
||||
import docspell.store.queries.{AttachmentLight => QAttachmentLight}
|
||||
import docspell.store.queries.{AttachmentLight => QAttachmentLight, IdRefCount}
|
||||
import docspell.store.records._
|
||||
import docspell.store.{AddResult, UpdateResult}
|
||||
|
||||
@ -38,9 +38,16 @@ trait Conversions {
|
||||
mkTagCloud(sum.tags),
|
||||
mkTagCategoryCloud(sum.cats),
|
||||
sum.fields.map(mkFieldStats),
|
||||
sum.folders.map(mkFolderStats)
|
||||
sum.folders.map(mkFolderStats),
|
||||
sum.corrOrgs.map(mkIdRefStats),
|
||||
sum.corrPers.map(mkIdRefStats),
|
||||
sum.concPers.map(mkIdRefStats),
|
||||
sum.concEquip.map(mkIdRefStats)
|
||||
)
|
||||
|
||||
def mkIdRefStats(s: IdRefCount): IdRefStats =
|
||||
IdRefStats(mkIdName(s.ref), s.count)
|
||||
|
||||
def mkFolderStats(fs: docspell.store.queries.FolderCount): FolderStats =
|
||||
FolderStats(fs.id, fs.name, mkIdName(fs.owner), fs.count)
|
||||
|
||||
|
@ -11,7 +11,10 @@ import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.ops.OItemSearch.{AttachmentData, AttachmentPreviewData}
|
||||
import docspell.backend.ops._
|
||||
import docspell.restapi.model.BasicResult
|
||||
import docspell.restserver.http4s.{QueryParam => QP}
|
||||
import docspell.store.records.RFileMeta
|
||||
|
||||
import org.http4s._
|
||||
@ -23,6 +26,68 @@ import org.typelevel.ci.CIString
|
||||
|
||||
object BinaryUtil {
|
||||
|
||||
def respond[F[_]: Async](dsl: Http4sDsl[F], req: Request[F])(
|
||||
fileData: Option[AttachmentData[F]]
|
||||
): F[Response[F]] = {
|
||||
import dsl._
|
||||
|
||||
val inm = req.headers.get[`If-None-Match`].flatMap(_.tags)
|
||||
val matches = BinaryUtil.matchETag(fileData.map(_.meta), inm)
|
||||
fileData
|
||||
.map { data =>
|
||||
if (matches) withResponseHeaders(dsl, NotModified())(data)
|
||||
else makeByteResp(dsl)(data)
|
||||
}
|
||||
.getOrElse(NotFound(BasicResult(false, "Not found")))
|
||||
}
|
||||
|
||||
def respondHead[F[_]: Async](dsl: Http4sDsl[F])(
|
||||
fileData: Option[AttachmentData[F]]
|
||||
): F[Response[F]] = {
|
||||
import dsl._
|
||||
|
||||
fileData
|
||||
.map(data => withResponseHeaders(dsl, Ok())(data))
|
||||
.getOrElse(NotFound(BasicResult(false, "Not found")))
|
||||
}
|
||||
|
||||
def respondPreview[F[_]: Async](dsl: Http4sDsl[F], req: Request[F])(
|
||||
fileData: Option[AttachmentPreviewData[F]]
|
||||
): F[Response[F]] = {
|
||||
import dsl._
|
||||
def notFound =
|
||||
NotFound(BasicResult(false, "Not found"))
|
||||
|
||||
QP.WithFallback.unapply(req.multiParams) match {
|
||||
case Some(bool) =>
|
||||
val fallback = bool.getOrElse(false)
|
||||
val inm = req.headers.get[`If-None-Match`].flatMap(_.tags)
|
||||
val matches = matchETag(fileData.map(_.meta), inm)
|
||||
|
||||
fileData
|
||||
.map { data =>
|
||||
if (matches) withResponseHeaders(dsl, NotModified())(data)
|
||||
else makeByteResp(dsl)(data)
|
||||
}
|
||||
.getOrElse(
|
||||
if (fallback) BinaryUtil.noPreview(req.some).getOrElseF(notFound)
|
||||
else notFound
|
||||
)
|
||||
|
||||
case None =>
|
||||
BadRequest(BasicResult(false, "Invalid query parameter 'withFallback'"))
|
||||
}
|
||||
}
|
||||
|
||||
def respondPreviewHead[F[_]: Async](
|
||||
dsl: Http4sDsl[F]
|
||||
)(fileData: Option[AttachmentPreviewData[F]]): F[Response[F]] = {
|
||||
import dsl._
|
||||
fileData
|
||||
.map(data => withResponseHeaders(dsl, Ok())(data))
|
||||
.getOrElse(NotFound(BasicResult(false, "Not found")))
|
||||
}
|
||||
|
||||
def withResponseHeaders[F[_]: Sync](dsl: Http4sDsl[F], resp: F[Response[F]])(
|
||||
data: OItemSearch.BinaryData[F]
|
||||
): F[Response[F]] = {
|
||||
|
@ -16,7 +16,7 @@ import docspell.common.SearchMode
|
||||
|
||||
import org.http4s.ParseFailure
|
||||
import org.http4s.QueryParamDecoder
|
||||
import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher
|
||||
import org.http4s.dsl.impl.{FlagQueryParamMatcher, OptionalQueryParamDecoderMatcher}
|
||||
|
||||
object QueryParam {
|
||||
case class QueryString(q: String)
|
||||
@ -67,6 +67,7 @@ object QueryParam {
|
||||
object FullOpt extends OptionalQueryParamDecoderMatcher[Boolean]("full")
|
||||
|
||||
object OwningOpt extends OptionalQueryParamDecoderMatcher[Boolean]("owning")
|
||||
object OwningFlag extends FlagQueryParamMatcher("owning")
|
||||
|
||||
object ContactKindOpt extends OptionalQueryParamDecoderMatcher[ContactKind]("kind")
|
||||
|
||||
|
@ -10,6 +10,7 @@ import cats.data.{Kleisli, OptionT}
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common.Password
|
||||
import docspell.restserver.Config
|
||||
import docspell.restserver.http4s.Responses
|
||||
|
||||
@ -19,7 +20,7 @@ import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.server._
|
||||
import org.typelevel.ci.CIString
|
||||
|
||||
object AdminRoutes {
|
||||
object AdminAuth {
|
||||
private val adminHeader = CIString("Docspell-Admin-Secret")
|
||||
|
||||
def apply[F[_]: Async](cfg: Config.AdminEndpoint)(
|
||||
@ -55,6 +56,5 @@ object AdminRoutes {
|
||||
req.headers.get(adminHeader).map(_.head.value)
|
||||
|
||||
private def compareSecret(s1: String)(s2: String): Boolean =
|
||||
s1.length > 0 && s1.length == s2.length &&
|
||||
s1.zip(s2).forall { case (a, b) => a == b }
|
||||
Password(s1).compare(Password(s2))
|
||||
}
|
@ -17,7 +17,6 @@ import docspell.common.MakePreviewArgs
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.conv.Conversions
|
||||
import docspell.restserver.http4s.BinaryUtil
|
||||
import docspell.restserver.http4s.{QueryParam => QP}
|
||||
import docspell.restserver.webapp.Webjars
|
||||
|
||||
import org.http4s._
|
||||
@ -47,24 +46,13 @@ object AttachmentRoutes {
|
||||
case HEAD -> Root / Ident(id) =>
|
||||
for {
|
||||
fileData <- backend.itemSearch.findAttachment(id, user.account.collective)
|
||||
resp <-
|
||||
fileData
|
||||
.map(data => withResponseHeaders(Ok())(data))
|
||||
.getOrElse(NotFound(BasicResult(false, "Not found")))
|
||||
resp <- BinaryUtil.respondHead(dsl)(fileData)
|
||||
} yield resp
|
||||
|
||||
case req @ GET -> Root / Ident(id) =>
|
||||
for {
|
||||
fileData <- backend.itemSearch.findAttachment(id, user.account.collective)
|
||||
inm = req.headers.get[`If-None-Match`].flatMap(_.tags)
|
||||
matches = BinaryUtil.matchETag(fileData.map(_.meta), inm)
|
||||
resp <-
|
||||
fileData
|
||||
.map { data =>
|
||||
if (matches) withResponseHeaders(NotModified())(data)
|
||||
else makeByteResp(data)
|
||||
}
|
||||
.getOrElse(NotFound(BasicResult(false, "Not found")))
|
||||
resp <- BinaryUtil.respond[F](dsl, req)(fileData)
|
||||
} yield resp
|
||||
|
||||
case HEAD -> Root / Ident(id) / "original" =>
|
||||
@ -115,35 +103,18 @@ object AttachmentRoutes {
|
||||
.getOrElse(NotFound(BasicResult(false, "Not found")))
|
||||
} yield resp
|
||||
|
||||
case req @ GET -> Root / Ident(id) / "preview" :? QP.WithFallback(flag) =>
|
||||
def notFound =
|
||||
NotFound(BasicResult(false, "Not found"))
|
||||
case req @ GET -> Root / Ident(id) / "preview" =>
|
||||
for {
|
||||
fileData <-
|
||||
backend.itemSearch.findAttachmentPreview(id, user.account.collective)
|
||||
inm = req.headers.get[`If-None-Match`].flatMap(_.tags)
|
||||
matches = BinaryUtil.matchETag(fileData.map(_.meta), inm)
|
||||
fallback = flag.getOrElse(false)
|
||||
resp <-
|
||||
fileData
|
||||
.map { data =>
|
||||
if (matches) withResponseHeaders(NotModified())(data)
|
||||
else makeByteResp(data)
|
||||
}
|
||||
.getOrElse(
|
||||
if (fallback) BinaryUtil.noPreview(req.some).getOrElseF(notFound)
|
||||
else notFound
|
||||
)
|
||||
resp <- BinaryUtil.respondPreview(dsl, req)(fileData)
|
||||
} yield resp
|
||||
|
||||
case HEAD -> Root / Ident(id) / "preview" =>
|
||||
for {
|
||||
fileData <-
|
||||
backend.itemSearch.findAttachmentPreview(id, user.account.collective)
|
||||
resp <-
|
||||
fileData
|
||||
.map(data => withResponseHeaders(Ok())(data))
|
||||
.getOrElse(NotFound(BasicResult(false, "Not found")))
|
||||
resp <- BinaryUtil.respondPreviewHead(dsl)(fileData)
|
||||
} yield resp
|
||||
|
||||
case POST -> Root / Ident(id) / "preview" =>
|
||||
|
@ -12,8 +12,7 @@ import cats.implicits._
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.ops.OCollective
|
||||
import docspell.common.EmptyTrashArgs
|
||||
import docspell.common.ListType
|
||||
import docspell.common._
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.conv.Conversions
|
||||
import docspell.restserver.http4s._
|
||||
@ -62,7 +61,8 @@ object CollectiveRoutes {
|
||||
settings.emptyTrash.schedule,
|
||||
settings.emptyTrash.minAge
|
||||
)
|
||||
)
|
||||
),
|
||||
settings.passwords.map(Password.apply)
|
||||
)
|
||||
res <-
|
||||
backend.collective
|
||||
@ -89,7 +89,8 @@ object CollectiveRoutes {
|
||||
EmptyTrashSetting(
|
||||
trash.schedule,
|
||||
trash.minAge
|
||||
)
|
||||
),
|
||||
settDb.map(_.passwords).getOrElse(Nil).map(_.pass)
|
||||
)
|
||||
)
|
||||
resp <- sett.toResponse()
|
||||
|
@ -28,11 +28,11 @@ import docspell.restserver.http4s.BinaryUtil
|
||||
import docspell.restserver.http4s.Responses
|
||||
import docspell.restserver.http4s.{QueryParam => QP}
|
||||
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.circe.CirceEntityDecoder._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.headers._
|
||||
import org.http4s.{HttpRoutes, Response}
|
||||
import org.log4s._
|
||||
|
||||
object ItemRoutes {
|
||||
@ -415,7 +415,11 @@ object ItemRoutes {
|
||||
def searchItems[F[_]: Sync](
|
||||
backend: BackendApp[F],
|
||||
dsl: Http4sDsl[F]
|
||||
)(settings: OSimpleSearch.Settings, fixQuery: Query.Fix, itemQuery: ItemQueryString) = {
|
||||
)(
|
||||
settings: OSimpleSearch.Settings,
|
||||
fixQuery: Query.Fix,
|
||||
itemQuery: ItemQueryString
|
||||
): F[Response[F]] = {
|
||||
import dsl._
|
||||
|
||||
def convertFts(res: OSimpleSearch.Items.FtsItems): ItemLightList =
|
||||
@ -452,14 +456,14 @@ object ItemRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private def searchItemStats[F[_]: Sync](
|
||||
def searchItemStats[F[_]: Sync](
|
||||
backend: BackendApp[F],
|
||||
dsl: Http4sDsl[F]
|
||||
)(
|
||||
settings: OSimpleSearch.StatsSettings,
|
||||
fixQuery: Query.Fix,
|
||||
itemQuery: ItemQueryString
|
||||
) = {
|
||||
): F[Response[F]] = {
|
||||
import dsl._
|
||||
|
||||
backend.simpleSearch
|
||||
@ -479,7 +483,6 @@ object ItemRoutes {
|
||||
case StringSearchResult.ParseFailed(pf) =>
|
||||
BadRequest(BasicResult(false, s"Error reading query: ${pf.render}"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
implicit final class OptionString(opt: Option[String]) {
|
||||
|
@ -11,8 +11,7 @@ import cats.implicits._
|
||||
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.ops.OMail.{AttachSelection, ItemMail}
|
||||
import docspell.backend.ops.SendResult
|
||||
import docspell.backend.ops.OMail.{AttachSelection, ItemMail, SendResult}
|
||||
import docspell.common._
|
||||
import docspell.restapi.model._
|
||||
|
||||
|
@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.restserver.routes
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.ShareToken
|
||||
import docspell.common._
|
||||
import docspell.restserver.http4s.BinaryUtil
|
||||
import docspell.restserver.webapp.Webjars
|
||||
|
||||
import org.http4s._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.headers._
|
||||
|
||||
object ShareAttachmentRoutes {
|
||||
|
||||
def apply[F[_]: Async](
|
||||
backend: BackendApp[F],
|
||||
token: ShareToken
|
||||
): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
HttpRoutes.of {
|
||||
case HEAD -> Root / Ident(id) =>
|
||||
for {
|
||||
fileData <- backend.share.findAttachment(id, token.id).value
|
||||
resp <- BinaryUtil.respondHead(dsl)(fileData)
|
||||
} yield resp
|
||||
|
||||
case req @ GET -> Root / Ident(id) =>
|
||||
for {
|
||||
fileData <- backend.share.findAttachment(id, token.id).value
|
||||
resp <- BinaryUtil.respond(dsl, req)(fileData)
|
||||
} yield resp
|
||||
|
||||
case GET -> Root / Ident(id) / "view" =>
|
||||
// this route exists to provide a stable url
|
||||
// it redirects currently to viewerjs
|
||||
val attachUrl = s"/api/v1/share/attachment/${id.id}"
|
||||
val path = s"/app/assets${Webjars.viewerjs}/index.html#$attachUrl"
|
||||
SeeOther(Location(Uri(path = Uri.Path.unsafeFromString(path))))
|
||||
|
||||
case req @ GET -> Root / Ident(id) / "preview" =>
|
||||
for {
|
||||
fileData <- backend.share.findAttachmentPreview(id, token.id).value
|
||||
resp <- BinaryUtil.respondPreview(dsl, req)(fileData)
|
||||
} yield resp
|
||||
|
||||
case HEAD -> Root / Ident(id) / "preview" =>
|
||||
for {
|
||||
fileData <- backend.share.findAttachmentPreview(id, token.id).value
|
||||
resp <- BinaryUtil.respondPreviewHead(dsl)(fileData)
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.restserver.routes
|
||||
|
||||
import cats.data.{Kleisli, OptionT}
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.auth.{Login, ShareToken}
|
||||
import docspell.backend.ops.OShare
|
||||
import docspell.backend.ops.OShare.VerifyResult
|
||||
import docspell.restserver.auth.ShareCookieData
|
||||
|
||||
import org.http4s._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.server._
|
||||
|
||||
object ShareAuth {
|
||||
|
||||
def authenticateRequest[F[_]: Async](
|
||||
validate: String => F[VerifyResult]
|
||||
)(req: Request[F]): F[OShare.VerifyResult] =
|
||||
ShareCookieData.fromRequest(req) match {
|
||||
case Some(tokenStr) =>
|
||||
validate(tokenStr)
|
||||
case None =>
|
||||
VerifyResult.notFound.pure[F]
|
||||
}
|
||||
|
||||
private def getToken[F[_]: Async](
|
||||
auth: String => F[VerifyResult]
|
||||
): Kleisli[F, Request[F], Either[String, ShareToken]] =
|
||||
Kleisli(r => authenticateRequest(auth)(r).map(_.toEither))
|
||||
|
||||
def of[F[_]: Async](S: OShare[F], cfg: Login.Config)(
|
||||
pf: PartialFunction[AuthedRequest[F, ShareToken], F[Response[F]]]
|
||||
): HttpRoutes[F] = {
|
||||
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
val authUser = getToken[F](S.verifyToken(cfg.serverSecret))
|
||||
|
||||
val onFailure: AuthedRoutes[String, F] =
|
||||
Kleisli(req => OptionT.liftF(Forbidden(req.context)))
|
||||
|
||||
val middleware: AuthMiddleware[F, ShareToken] =
|
||||
AuthMiddleware(authUser, onFailure)
|
||||
|
||||
middleware(AuthedRoutes.of(pf))
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](S: OShare[F], cfg: Login.Config)(
|
||||
f: ShareToken => HttpRoutes[F]
|
||||
): HttpRoutes[F] = {
|
||||
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
val authUser = getToken[F](S.verifyToken(cfg.serverSecret))
|
||||
|
||||
val onFailure: AuthedRoutes[String, F] =
|
||||
Kleisli(req => OptionT.liftF(Forbidden(req.context)))
|
||||
|
||||
val middleware: AuthMiddleware[F, ShareToken] =
|
||||
AuthMiddleware(authUser, onFailure)
|
||||
|
||||
middleware(AuthedRoutes(authReq => f(authReq.context).run(authReq.req)))
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.restserver.routes
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.ShareToken
|
||||
import docspell.common._
|
||||
import docspell.restapi.model.BasicResult
|
||||
import docspell.restserver.conv.Conversions
|
||||
|
||||
import org.http4s._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
|
||||
object ShareItemRoutes {
|
||||
|
||||
def apply[F[_]: Async](
|
||||
backend: BackendApp[F],
|
||||
token: ShareToken
|
||||
): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
HttpRoutes.of { case GET -> Root / Ident(id) =>
|
||||
for {
|
||||
item <- backend.share.findItem(id, token.id).value
|
||||
result = item.map(Conversions.mkItemDetail)
|
||||
resp <-
|
||||
result
|
||||
.map(r => Ok(r))
|
||||
.getOrElse(NotFound(BasicResult(false, "Not found.")))
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.restserver.routes
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.ops.OShare
|
||||
import docspell.backend.ops.OShare.{SendResult, ShareMail, VerifyResult}
|
||||
import docspell.common.{Ident, Timestamp}
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.Config
|
||||
import docspell.restserver.auth.ShareCookieData
|
||||
import docspell.restserver.http4s.{ClientRequestInfo, QueryParam => QP, ResponseGenerator}
|
||||
|
||||
import emil.MailAddress
|
||||
import emil.javamail.syntax._
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.circe.CirceEntityDecoder._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
|
||||
object ShareRoutes {
|
||||
|
||||
def manage[F[_]: Async](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F] with ResponseGenerator[F] {}
|
||||
import dsl._
|
||||
|
||||
HttpRoutes.of {
|
||||
case GET -> Root :? QP.Query(q) :? QP.OwningFlag(owning) =>
|
||||
val login = if (owning) Some(user.account.user) else None
|
||||
for {
|
||||
all <- backend.share.findAll(user.account.collective, login, q)
|
||||
now <- Timestamp.current[F]
|
||||
res <- Ok(ShareList(all.map(mkShareDetail(now))))
|
||||
} yield res
|
||||
|
||||
case req @ POST -> Root =>
|
||||
for {
|
||||
data <- req.as[ShareData]
|
||||
share = mkNewShare(data, user)
|
||||
res <- backend.share.addNew(share)
|
||||
resp <- Ok(mkIdResult(res, "New share created."))
|
||||
} yield resp
|
||||
|
||||
case GET -> Root / Ident(id) =>
|
||||
(for {
|
||||
share <- backend.share.findOne(id, user.account.collective)
|
||||
now <- OptionT.liftF(Timestamp.current[F])
|
||||
resp <- OptionT.liftF(Ok(mkShareDetail(now)(share)))
|
||||
} yield resp).getOrElseF(NotFound())
|
||||
|
||||
case req @ PUT -> Root / Ident(id) =>
|
||||
for {
|
||||
data <- req.as[ShareData]
|
||||
share = mkNewShare(data, user)
|
||||
updated <- backend.share.update(id, share, data.removePassword.getOrElse(false))
|
||||
resp <- Ok(mkBasicResult(updated, "Share updated."))
|
||||
} yield resp
|
||||
|
||||
case DELETE -> Root / Ident(id) =>
|
||||
for {
|
||||
del <- backend.share.delete(id, user.account.collective)
|
||||
resp <- Ok(BasicResult(del, if (del) "Share deleted." else "Deleting failed."))
|
||||
} yield resp
|
||||
|
||||
case req @ POST -> Root / "email" / "send" / Ident(name) =>
|
||||
for {
|
||||
in <- req.as[SimpleShareMail]
|
||||
mail = convertIn(in)
|
||||
res <- mail.traverse(m => backend.share.sendMail(user.account, name, m))
|
||||
resp <- res.fold(
|
||||
err => Ok(BasicResult(false, s"Invalid mail data: $err")),
|
||||
res => Ok(convertOut(res))
|
||||
)
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
def verify[F[_]: Async](backend: BackendApp[F], cfg: Config): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F] with ResponseGenerator[F] {}
|
||||
import dsl._
|
||||
|
||||
HttpRoutes.of { case req @ POST -> Root / "verify" =>
|
||||
for {
|
||||
secret <- req.as[ShareSecret]
|
||||
res <- backend.share
|
||||
.verify(cfg.auth.serverSecret)(secret.shareId, secret.password)
|
||||
resp <- res match {
|
||||
case VerifyResult.Success(token, name) =>
|
||||
val cd = ShareCookieData(token)
|
||||
Ok(ShareVerifyResult(true, token.asString, false, "Success", name))
|
||||
.map(cd.addCookie(ClientRequestInfo.getBaseUrl(cfg, req)))
|
||||
case VerifyResult.PasswordMismatch =>
|
||||
Ok(ShareVerifyResult(false, "", true, "Failed", None))
|
||||
case VerifyResult.NotFound =>
|
||||
Ok(ShareVerifyResult(false, "", false, "Failed", None))
|
||||
case VerifyResult.InvalidToken =>
|
||||
Ok(ShareVerifyResult(false, "", false, "Failed", None))
|
||||
}
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
def mkNewShare(data: ShareData, user: AuthToken): OShare.NewShare =
|
||||
OShare.NewShare(
|
||||
user.account,
|
||||
data.name,
|
||||
data.query,
|
||||
data.enabled,
|
||||
data.password,
|
||||
data.publishUntil
|
||||
)
|
||||
|
||||
def mkIdResult(r: OShare.ChangeResult, msg: => String): IdResult =
|
||||
r match {
|
||||
case OShare.ChangeResult.Success(id) => IdResult(true, msg, id)
|
||||
case OShare.ChangeResult.PublishUntilInPast =>
|
||||
IdResult(false, "Until date must not be in the past", Ident.unsafe(""))
|
||||
case OShare.ChangeResult.NotFound =>
|
||||
IdResult(
|
||||
false,
|
||||
"Share not found or not owner. Only the owner can update a share.",
|
||||
Ident.unsafe("")
|
||||
)
|
||||
}
|
||||
|
||||
def mkBasicResult(r: OShare.ChangeResult, msg: => String): BasicResult =
|
||||
r match {
|
||||
case OShare.ChangeResult.Success(_) => BasicResult(true, msg)
|
||||
case OShare.ChangeResult.PublishUntilInPast =>
|
||||
BasicResult(false, "Until date must not be in the past")
|
||||
case OShare.ChangeResult.NotFound =>
|
||||
BasicResult(
|
||||
false,
|
||||
"Share not found or not owner. Only the owner can update a share."
|
||||
)
|
||||
}
|
||||
|
||||
def mkShareDetail(now: Timestamp)(r: OShare.ShareData): ShareDetail =
|
||||
ShareDetail(
|
||||
r.share.id,
|
||||
r.share.query,
|
||||
IdName(r.user.uid, r.user.login.id),
|
||||
r.share.name,
|
||||
r.share.enabled,
|
||||
r.share.publishAt,
|
||||
r.share.publishUntil,
|
||||
now > r.share.publishUntil,
|
||||
r.share.password.isDefined,
|
||||
r.share.views,
|
||||
r.share.lastAccess
|
||||
)
|
||||
|
||||
def convertIn(s: SimpleShareMail): Either[String, ShareMail] =
|
||||
for {
|
||||
rec <- s.recipients.traverse(MailAddress.parse)
|
||||
cc <- s.cc.traverse(MailAddress.parse)
|
||||
bcc <- s.bcc.traverse(MailAddress.parse)
|
||||
} yield ShareMail(s.shareId, s.subject, rec, cc, bcc, s.body)
|
||||
|
||||
def convertOut(res: SendResult): BasicResult =
|
||||
res match {
|
||||
case SendResult.Success(_) =>
|
||||
BasicResult(true, "Mail sent.")
|
||||
case SendResult.SendFailure(ex) =>
|
||||
BasicResult(false, s"Mail sending failed: ${ex.getMessage}")
|
||||
case SendResult.NotFound =>
|
||||
BasicResult(false, s"There was no mail-connection or item found.")
|
||||
}
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.restserver.routes
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.ShareToken
|
||||
import docspell.backend.ops.OSimpleSearch
|
||||
import docspell.backend.ops.OSimpleSearch.StringSearchResult
|
||||
import docspell.common._
|
||||
import docspell.query.FulltextExtract.Result.{TooMany, UnsupportedPosition}
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.Config
|
||||
import docspell.restserver.conv.Conversions
|
||||
import docspell.store.qb.Batch
|
||||
import docspell.store.queries.{Query, SearchSummary}
|
||||
|
||||
import org.http4s.circe.CirceEntityDecoder._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.{HttpRoutes, Response}
|
||||
|
||||
object ShareSearchRoutes {
|
||||
|
||||
def apply[F[_]: Async](
|
||||
backend: BackendApp[F],
|
||||
cfg: Config,
|
||||
token: ShareToken
|
||||
): HttpRoutes[F] = {
|
||||
val logger = Logger.log4s[F](org.log4s.getLogger)
|
||||
|
||||
val dsl = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
HttpRoutes.of {
|
||||
case req @ POST -> Root / "query" =>
|
||||
backend.share
|
||||
.findShareQuery(token.id)
|
||||
.semiflatMap { share =>
|
||||
for {
|
||||
userQuery <- req.as[ItemQuery]
|
||||
batch = Batch(
|
||||
userQuery.offset.getOrElse(0),
|
||||
userQuery.limit.getOrElse(cfg.maxItemPageSize)
|
||||
).restrictLimitTo(
|
||||
cfg.maxItemPageSize
|
||||
)
|
||||
itemQuery = ItemQueryString(userQuery.query)
|
||||
settings = OSimpleSearch.Settings(
|
||||
batch,
|
||||
cfg.fullTextSearch.enabled,
|
||||
userQuery.withDetails.getOrElse(false),
|
||||
cfg.maxNoteLength,
|
||||
searchMode = SearchMode.Normal
|
||||
)
|
||||
account = share.account
|
||||
fixQuery = Query.Fix(account, Some(share.query.expr), None)
|
||||
_ <- logger.debug(s"Searching in share ${share.id.id}: ${userQuery.query}")
|
||||
resp <- ItemRoutes.searchItems(backend, dsl)(settings, fixQuery, itemQuery)
|
||||
} yield resp
|
||||
}
|
||||
.getOrElseF(NotFound())
|
||||
|
||||
case req @ POST -> Root / "stats" =>
|
||||
for {
|
||||
userQuery <- req.as[ItemQuery]
|
||||
itemQuery = ItemQueryString(userQuery.query)
|
||||
settings = OSimpleSearch.StatsSettings(
|
||||
useFTS = cfg.fullTextSearch.enabled,
|
||||
searchMode = userQuery.searchMode.getOrElse(SearchMode.Normal)
|
||||
)
|
||||
stats <- backend.share.searchSummary(settings)(token.id, itemQuery).value
|
||||
resp <- stats.map(mkSummaryResponse(dsl)).getOrElse(NotFound())
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
def mkSummaryResponse[F[_]: Sync](
|
||||
dsl: Http4sDsl[F]
|
||||
)(r: StringSearchResult[SearchSummary]): F[Response[F]] = {
|
||||
import dsl._
|
||||
r match {
|
||||
case StringSearchResult.Success(summary) =>
|
||||
Ok(Conversions.mkSearchStats(summary))
|
||||
case StringSearchResult.FulltextMismatch(TooMany) =>
|
||||
BadRequest(BasicResult(false, "Fulltext search is not possible in this share."))
|
||||
case StringSearchResult.FulltextMismatch(UnsupportedPosition) =>
|
||||
BadRequest(
|
||||
BasicResult(
|
||||
false,
|
||||
"Fulltext search must be in root position or inside the first AND."
|
||||
)
|
||||
)
|
||||
case StringSearchResult.ParseFailed(pf) =>
|
||||
BadRequest(BasicResult(false, s"Error reading query: ${pf.render}"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -72,7 +72,9 @@ object UserRoutes {
|
||||
data <- backend.collective.getDeleteUserData(
|
||||
AccountId(user.account.collective, username)
|
||||
)
|
||||
resp <- Ok(DeleteUserData(data.ownedFolders.map(_.id), data.sentMails))
|
||||
resp <- Ok(
|
||||
DeleteUserData(data.ownedFolders.map(_.id), data.sentMails, data.shares)
|
||||
)
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
@ -43,8 +43,21 @@
|
||||
// this is required for transitioning; elm fails to parse the account
|
||||
account["requireSecondFactor"] = false;
|
||||
}
|
||||
|
||||
// hack to guess if the browser can display PDFs natively. It
|
||||
// seems that almost all browsers allow to query the
|
||||
// navigator.mimeTypes array, except firefox.
|
||||
var ua = navigator.userAgent.toLowerCase();
|
||||
var pdfSupported = false;
|
||||
if (ua.indexOf("firefox") > -1) {
|
||||
pdfSupported = ua.indexOf("mobile") == -1;
|
||||
} else {
|
||||
pdfSupported = "application/pdf" in navigator.mimeTypes;
|
||||
}
|
||||
|
||||
var elmFlags = {
|
||||
"account": account,
|
||||
"pdfSupported": pdfSupported,
|
||||
"config": {{{flagsJson}}}
|
||||
};
|
||||
</script>
|
||||
|
@ -0,0 +1,7 @@
|
||||
CREATE TABLE "collective_password" (
|
||||
"id" varchar(254) not null primary key,
|
||||
"cid" varchar(254) not null,
|
||||
"pass" varchar(254) not null,
|
||||
"created" timestamp not null,
|
||||
foreign key ("cid") references "collective"("cid") on delete cascade
|
||||
)
|
@ -0,0 +1,13 @@
|
||||
CREATE TABLE "item_share" (
|
||||
"id" varchar(254) not null primary key,
|
||||
"user_id" varchar(254) not null,
|
||||
"name" varchar(254),
|
||||
"query" varchar(2000) not null,
|
||||
"enabled" boolean not null,
|
||||
"pass" varchar(254),
|
||||
"publish_at" timestamp not null,
|
||||
"publish_until" timestamp not null,
|
||||
"views" int not null,
|
||||
"last_access" timestamp,
|
||||
foreign key ("user_id") references "user_"("uid") on delete cascade
|
||||
)
|
@ -0,0 +1,7 @@
|
||||
CREATE TABLE `collective_password` (
|
||||
`id` varchar(254) not null primary key,
|
||||
`cid` varchar(254) not null,
|
||||
`pass` varchar(254) not null,
|
||||
`created` timestamp not null,
|
||||
foreign key (`cid`) references `collective`(`cid`) on delete cascade
|
||||
)
|
@ -0,0 +1,13 @@
|
||||
CREATE TABLE `item_share` (
|
||||
`id` varchar(254) not null primary key,
|
||||
`user_id` varchar(254) not null,
|
||||
`name` varchar(254),
|
||||
`query` varchar(2000) not null,
|
||||
`enabled` boolean not null,
|
||||
`pass` varchar(254),
|
||||
`publish_at` timestamp not null,
|
||||
`publish_until` timestamp not null,
|
||||
`views` int not null,
|
||||
`last_access` timestamp,
|
||||
foreign key (`user_id`) references `user_`(`uid`) on delete cascade
|
||||
)
|
@ -0,0 +1,7 @@
|
||||
CREATE TABLE "collective_password" (
|
||||
"id" varchar(254) not null primary key,
|
||||
"cid" varchar(254) not null,
|
||||
"pass" varchar(254) not null,
|
||||
"created" timestamp not null,
|
||||
foreign key ("cid") references "collective"("cid") on delete cascade
|
||||
)
|
@ -0,0 +1,13 @@
|
||||
CREATE TABLE "item_share" (
|
||||
"id" varchar(254) not null primary key,
|
||||
"user_id" varchar(254) not null,
|
||||
"name" varchar(254),
|
||||
"query" varchar(2000) not null,
|
||||
"enabled" boolean not null,
|
||||
"pass" varchar(254),
|
||||
"publish_at" timestamp not null,
|
||||
"publish_until" timestamp not null,
|
||||
"views" int not null,
|
||||
"last_access" timestamp,
|
||||
foreign key ("user_id") references "user_"("uid") on delete cascade
|
||||
)
|
@ -9,6 +9,7 @@ package docspell.store
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
import cats.effect._
|
||||
import cats.~>
|
||||
import fs2._
|
||||
|
||||
import docspell.store.file.FileStore
|
||||
@ -19,6 +20,7 @@ import doobie._
|
||||
import doobie.hikari.HikariTransactor
|
||||
|
||||
trait Store[F[_]] {
|
||||
def transform: ConnectionIO ~> F
|
||||
|
||||
def transact[A](prg: ConnectionIO[A]): F[A]
|
||||
|
||||
|
@ -11,6 +11,7 @@ import java.time.{Instant, LocalDate}
|
||||
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.query.{ItemQuery, ItemQueryParser}
|
||||
import docspell.totp.Key
|
||||
|
||||
import com.github.eikek.calev.CalEvent
|
||||
@ -142,6 +143,11 @@ trait DoobieMeta extends EmilDoobieMeta {
|
||||
|
||||
implicit val metaByteSize: Meta[ByteSize] =
|
||||
Meta[Long].timap(ByteSize.apply)(_.bytes)
|
||||
|
||||
implicit val metaItemQuery: Meta[ItemQuery] =
|
||||
Meta[String].timap(s => ItemQueryParser.parseUnsafe(s))(q =>
|
||||
q.raw.getOrElse(ItemQueryParser.unsafeAsString(q.expr))
|
||||
)
|
||||
}
|
||||
|
||||
object DoobieMeta extends DoobieMeta {
|
||||
|
@ -6,8 +6,10 @@
|
||||
|
||||
package docspell.store.impl
|
||||
|
||||
import cats.arrow.FunctionK
|
||||
import cats.effect.Async
|
||||
import cats.implicits._
|
||||
import cats.~>
|
||||
|
||||
import docspell.store.file.FileStore
|
||||
import docspell.store.migrate.FlywayMigrate
|
||||
@ -22,6 +24,9 @@ final class StoreImpl[F[_]: Async](
|
||||
xa: Transactor[F]
|
||||
) extends Store[F] {
|
||||
|
||||
def transform: ConnectionIO ~> F =
|
||||
FunctionK.lift(transact)
|
||||
|
||||
def migrate: F[Int] =
|
||||
FlywayMigrate.run[F](jdbc).map(_.migrationsExecuted)
|
||||
|
||||
|
@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.store.queries
|
||||
|
||||
import docspell.common._
|
||||
|
||||
final case class IdRefCount(ref: IdRef, count: Int) {}
|
@ -192,7 +192,21 @@ object QItem {
|
||||
cats <- searchTagCategorySummary(today)(q)
|
||||
fields <- searchFieldSummary(today)(q)
|
||||
folders <- searchFolderSummary(today)(q)
|
||||
} yield SearchSummary(count, tags, cats, fields, folders)
|
||||
orgs <- searchCorrOrgSummary(today)(q)
|
||||
corrPers <- searchCorrPersonSummary(today)(q)
|
||||
concPers <- searchConcPersonSummary(today)(q)
|
||||
concEquip <- searchConcEquipSummary(today)(q)
|
||||
} yield SearchSummary(
|
||||
count,
|
||||
tags,
|
||||
cats,
|
||||
fields,
|
||||
folders,
|
||||
orgs,
|
||||
corrPers,
|
||||
concPers,
|
||||
concEquip
|
||||
)
|
||||
|
||||
def searchTagCategorySummary(
|
||||
today: LocalDate
|
||||
@ -251,6 +265,40 @@ object QItem {
|
||||
.query[Int]
|
||||
.unique
|
||||
|
||||
def searchCorrOrgSummary(today: LocalDate)(q: Query): ConnectionIO[List[IdRefCount]] =
|
||||
searchIdRefSummary(org.oid, org.name, i.corrOrg, today)(q)
|
||||
|
||||
def searchCorrPersonSummary(today: LocalDate)(
|
||||
q: Query
|
||||
): ConnectionIO[List[IdRefCount]] =
|
||||
searchIdRefSummary(pers0.pid, pers0.name, i.corrPerson, today)(q)
|
||||
|
||||
def searchConcPersonSummary(today: LocalDate)(
|
||||
q: Query
|
||||
): ConnectionIO[List[IdRefCount]] =
|
||||
searchIdRefSummary(pers1.pid, pers1.name, i.concPerson, today)(q)
|
||||
|
||||
def searchConcEquipSummary(today: LocalDate)(
|
||||
q: Query
|
||||
): ConnectionIO[List[IdRefCount]] =
|
||||
searchIdRefSummary(equip.eid, equip.name, i.concEquipment, today)(q)
|
||||
|
||||
private def searchIdRefSummary(
|
||||
idCol: Column[Ident],
|
||||
nameCol: Column[String],
|
||||
fkCol: Column[Ident],
|
||||
today: LocalDate
|
||||
)(q: Query): ConnectionIO[List[IdRefCount]] =
|
||||
findItemsBase(q.fix, today, 0).unwrap
|
||||
.withSelect(select(idCol, nameCol).append(count(idCol).as("num")))
|
||||
.changeWhere(c =>
|
||||
c && fkCol.isNotNull && queryCondition(today, q.fix.account.collective, q.cond)
|
||||
)
|
||||
.groupBy(idCol, nameCol)
|
||||
.build
|
||||
.query[IdRefCount]
|
||||
.to[List]
|
||||
|
||||
def searchFolderSummary(today: LocalDate)(q: Query): ConnectionIO[List[FolderCount]] = {
|
||||
val fu = RUser.as("fu")
|
||||
findItemsBase(q.fix, today, 0).unwrap
|
||||
|
@ -20,7 +20,8 @@ object QUser {
|
||||
|
||||
final case class UserData(
|
||||
ownedFolders: List[Ident],
|
||||
sentMails: Int
|
||||
sentMails: Int,
|
||||
shares: Int
|
||||
)
|
||||
|
||||
def getUserData(accountId: AccountId): ConnectionIO[UserData] = {
|
||||
@ -28,6 +29,7 @@ object QUser {
|
||||
val mail = RSentMail.as("m")
|
||||
val mitem = RSentMailItem.as("mi")
|
||||
val user = RUser.as("u")
|
||||
val share = RShare.as("s")
|
||||
|
||||
for {
|
||||
uid <- loadUserId(accountId).map(_.getOrElse(Ident.unsafe("")))
|
||||
@ -43,7 +45,13 @@ object QUser {
|
||||
.innerJoin(user, user.uid === mail.uid),
|
||||
user.login === accountId.user && user.cid === accountId.collective
|
||||
).query[Int].unique
|
||||
} yield UserData(folders, mails)
|
||||
shares <- run(
|
||||
select(count(share.id)),
|
||||
from(share)
|
||||
.innerJoin(user, user.uid === share.userId),
|
||||
user.login === accountId.user && user.cid === accountId.collective
|
||||
).query[Int].unique
|
||||
} yield UserData(folders, mails, shares)
|
||||
}
|
||||
|
||||
def deleteUserAndData(accountId: AccountId): ConnectionIO[Int] =
|
||||
|
@ -11,5 +11,23 @@ case class SearchSummary(
|
||||
tags: List[TagCount],
|
||||
cats: List[CategoryCount],
|
||||
fields: List[FieldStats],
|
||||
folders: List[FolderCount]
|
||||
folders: List[FolderCount],
|
||||
corrOrgs: List[IdRefCount],
|
||||
corrPers: List[IdRefCount],
|
||||
concPers: List[IdRefCount],
|
||||
concEquip: List[IdRefCount]
|
||||
) {
|
||||
|
||||
def onlyExisting: SearchSummary =
|
||||
SearchSummary(
|
||||
count,
|
||||
tags.filter(_.count > 0),
|
||||
cats.filter(_.count > 0),
|
||||
fields.filter(_.count > 0),
|
||||
folders.filter(_.count > 0),
|
||||
corrOrgs = corrOrgs.filter(_.count > 0),
|
||||
corrPers = corrPers.filter(_.count > 0),
|
||||
concPers = concPers.filter(_.count > 0),
|
||||
concEquip = concEquip.filter(_.count > 0)
|
||||
)
|
||||
}
|
||||
|
@ -89,7 +89,8 @@ object RCollective {
|
||||
case None =>
|
||||
REmptyTrashSetting.delete(cid)
|
||||
}
|
||||
} yield n1 + n2 + n3
|
||||
n4 <- RCollectivePassword.replaceAll(cid, settings.passwords)
|
||||
} yield n1 + n2 + n3 + n4
|
||||
|
||||
// this hides categories that have been deleted in the meantime
|
||||
// they are finally removed from the json array once the learn classifier task is run
|
||||
@ -99,10 +100,12 @@ object RCollective {
|
||||
prev <- OptionT.fromOption[ConnectionIO](sett.classifier)
|
||||
cats <- OptionT.liftF(RTag.listCategories(coll))
|
||||
next = prev.copy(categories = prev.categories.intersect(cats))
|
||||
} yield sett.copy(classifier = Some(next))).value
|
||||
pws <- OptionT.liftF(RCollectivePassword.findAll(coll))
|
||||
} yield sett.copy(classifier = Some(next), passwords = pws.map(_.password))).value
|
||||
|
||||
private def getRawSettings(coll: Ident): ConnectionIO[Option[Settings]] = {
|
||||
import RClassifierSetting.stringListMeta
|
||||
|
||||
val c = RCollective.as("c")
|
||||
val cs = RClassifierSetting.as("cs")
|
||||
val es = REmptyTrashSetting.as("es")
|
||||
@ -116,7 +119,8 @@ object RCollective {
|
||||
cs.categories.s,
|
||||
cs.listType.s,
|
||||
es.schedule.s,
|
||||
es.minAge.s
|
||||
es.minAge.s,
|
||||
const(0) //dummy value to load Nil as list of passwords
|
||||
),
|
||||
from(c).leftJoin(cs, cs.cid === c.id).leftJoin(es, es.cid === c.id),
|
||||
c.id === coll
|
||||
@ -170,7 +174,11 @@ object RCollective {
|
||||
language: Language,
|
||||
integrationEnabled: Boolean,
|
||||
classifier: Option[RClassifierSetting.Classifier],
|
||||
emptyTrash: Option[REmptyTrashSetting.EmptyTrash]
|
||||
emptyTrash: Option[REmptyTrashSetting.EmptyTrash],
|
||||
passwords: List[Password]
|
||||
)
|
||||
|
||||
implicit val passwordListMeta: Read[List[Password]] =
|
||||
Read[Int].map(_ => Nil: List[Password])
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.store.records
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.store.qb.DSL._
|
||||
import docspell.store.qb._
|
||||
|
||||
import doobie._
|
||||
import doobie.implicits._
|
||||
|
||||
final case class RCollectivePassword(
|
||||
id: Ident,
|
||||
cid: Ident,
|
||||
password: Password,
|
||||
created: Timestamp
|
||||
) {}
|
||||
|
||||
object RCollectivePassword {
|
||||
final case class Table(alias: Option[String]) extends TableDef {
|
||||
val tableName: String = "collective_password"
|
||||
|
||||
val id = Column[Ident]("id", this)
|
||||
val cid = Column[Ident]("cid", this)
|
||||
val password = Column[Password]("pass", this)
|
||||
val created = Column[Timestamp]("created", this)
|
||||
|
||||
val all: NonEmptyList[Column[_]] =
|
||||
NonEmptyList.of(id, cid, password, created)
|
||||
}
|
||||
|
||||
val T = Table(None)
|
||||
def as(alias: String): Table =
|
||||
Table(Some(alias))
|
||||
|
||||
def createNew[F[_]: Sync](cid: Ident, pw: Password): F[RCollectivePassword] =
|
||||
for {
|
||||
id <- Ident.randomId[F]
|
||||
time <- Timestamp.current[F]
|
||||
} yield RCollectivePassword(id, cid, pw, time)
|
||||
|
||||
def insert(v: RCollectivePassword): ConnectionIO[Int] =
|
||||
DML.insert(
|
||||
T,
|
||||
T.all,
|
||||
fr"${v.id}, ${v.cid},${v.password},${v.created}"
|
||||
)
|
||||
|
||||
def upsert(v: RCollectivePassword): ConnectionIO[Int] =
|
||||
for {
|
||||
k <- deleteByPassword(v.cid, v.password)
|
||||
n <- insert(v)
|
||||
} yield n + k
|
||||
|
||||
def deleteById(id: Ident): ConnectionIO[Int] =
|
||||
DML.delete(T, T.id === id)
|
||||
|
||||
def deleteByPassword(cid: Ident, pw: Password): ConnectionIO[Int] =
|
||||
DML.delete(T, T.password === pw && T.cid === cid)
|
||||
|
||||
def findAll(cid: Ident): ConnectionIO[List[RCollectivePassword]] =
|
||||
Select(select(T.all), from(T), T.cid === cid).build
|
||||
.query[RCollectivePassword]
|
||||
.to[List]
|
||||
|
||||
def replaceAll(cid: Ident, pws: List[Password]): ConnectionIO[Int] =
|
||||
for {
|
||||
k <- DML.delete(T, T.cid === cid)
|
||||
pw <- pws.traverse(p => createNew[ConnectionIO](cid, p))
|
||||
n <-
|
||||
if (pws.isEmpty) 0.pure[ConnectionIO]
|
||||
else
|
||||
DML.insertMany(
|
||||
T,
|
||||
T.all,
|
||||
pw.map(p => fr"${p.id},${p.cid},${p.password},${p.created}")
|
||||
)
|
||||
} yield k + n
|
||||
}
|
167
modules/store/src/main/scala/docspell/store/records/RShare.scala
Normal file
167
modules/store/src/main/scala/docspell/store/records/RShare.scala
Normal file
@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.store.records
|
||||
|
||||
import cats.data.{NonEmptyList, OptionT}
|
||||
|
||||
import docspell.common._
|
||||
import docspell.query.ItemQuery
|
||||
import docspell.store.qb.DSL._
|
||||
import docspell.store.qb._
|
||||
|
||||
import doobie._
|
||||
import doobie.implicits._
|
||||
|
||||
final case class RShare(
|
||||
id: Ident,
|
||||
userId: Ident,
|
||||
name: Option[String],
|
||||
query: ItemQuery,
|
||||
enabled: Boolean,
|
||||
password: Option[Password],
|
||||
publishAt: Timestamp,
|
||||
publishUntil: Timestamp,
|
||||
views: Int,
|
||||
lastAccess: Option[Timestamp]
|
||||
) {}
|
||||
|
||||
object RShare {
|
||||
|
||||
final case class Table(alias: Option[String]) extends TableDef {
|
||||
val tableName = "item_share";
|
||||
|
||||
val id = Column[Ident]("id", this)
|
||||
val userId = Column[Ident]("user_id", this)
|
||||
val name = Column[String]("name", this)
|
||||
val query = Column[ItemQuery]("query", this)
|
||||
val enabled = Column[Boolean]("enabled", this)
|
||||
val password = Column[Password]("pass", this)
|
||||
val publishedAt = Column[Timestamp]("publish_at", this)
|
||||
val publishedUntil = Column[Timestamp]("publish_until", this)
|
||||
val views = Column[Int]("views", this)
|
||||
val lastAccess = Column[Timestamp]("last_access", this)
|
||||
|
||||
val all: NonEmptyList[Column[_]] =
|
||||
NonEmptyList.of(
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
query,
|
||||
enabled,
|
||||
password,
|
||||
publishedAt,
|
||||
publishedUntil,
|
||||
views,
|
||||
lastAccess
|
||||
)
|
||||
}
|
||||
|
||||
val T: Table = Table(None)
|
||||
def as(alias: String): Table = Table(Some(alias))
|
||||
|
||||
def insert(r: RShare): ConnectionIO[Int] =
|
||||
DML.insert(
|
||||
T,
|
||||
T.all,
|
||||
fr"${r.id},${r.userId},${r.name},${r.query},${r.enabled},${r.password},${r.publishAt},${r.publishUntil},${r.views},${r.lastAccess}"
|
||||
)
|
||||
|
||||
def incAccess(id: Ident): ConnectionIO[Int] =
|
||||
for {
|
||||
curTime <- Timestamp.current[ConnectionIO]
|
||||
n <- DML.update(
|
||||
T,
|
||||
T.id === id,
|
||||
DML.set(T.views.increment(1), T.lastAccess.setTo(curTime))
|
||||
)
|
||||
} yield n
|
||||
|
||||
def updateData(r: RShare, removePassword: Boolean): ConnectionIO[Int] =
|
||||
DML.update(
|
||||
T,
|
||||
T.id === r.id && T.userId === r.userId,
|
||||
DML.set(
|
||||
T.name.setTo(r.name),
|
||||
T.query.setTo(r.query),
|
||||
T.enabled.setTo(r.enabled),
|
||||
T.publishedUntil.setTo(r.publishUntil)
|
||||
) ++ (if (r.password.isDefined || removePassword)
|
||||
List(T.password.setTo(r.password))
|
||||
else Nil)
|
||||
)
|
||||
|
||||
def findOne(id: Ident, cid: Ident): OptionT[ConnectionIO, (RShare, RUser)] = {
|
||||
val s = RShare.as("s")
|
||||
val u = RUser.as("u")
|
||||
|
||||
OptionT(
|
||||
Select(
|
||||
select(s.all, u.all),
|
||||
from(s).innerJoin(u, u.uid === s.userId),
|
||||
s.id === id && u.cid === cid
|
||||
).build
|
||||
.query[(RShare, RUser)]
|
||||
.option
|
||||
)
|
||||
}
|
||||
|
||||
private def activeCondition(t: Table, id: Ident, current: Timestamp): Condition =
|
||||
t.id === id && t.enabled === true && t.publishedUntil > current
|
||||
|
||||
def findActive(
|
||||
id: Ident,
|
||||
current: Timestamp
|
||||
): OptionT[ConnectionIO, (RShare, RUser)] = {
|
||||
val s = RShare.as("s")
|
||||
val u = RUser.as("u")
|
||||
|
||||
OptionT(
|
||||
Select(
|
||||
select(s.all, u.all),
|
||||
from(s).innerJoin(u, s.userId === u.uid),
|
||||
activeCondition(s, id, current)
|
||||
).build.query[(RShare, RUser)].option
|
||||
)
|
||||
}
|
||||
|
||||
def findCurrentActive(id: Ident): OptionT[ConnectionIO, (RShare, RUser)] =
|
||||
OptionT.liftF(Timestamp.current[ConnectionIO]).flatMap(now => findActive(id, now))
|
||||
|
||||
def findActivePassword(id: Ident): OptionT[ConnectionIO, Option[Password]] =
|
||||
OptionT(Timestamp.current[ConnectionIO].flatMap { now =>
|
||||
Select(select(T.password), from(T), activeCondition(T, id, now)).build
|
||||
.query[Option[Password]]
|
||||
.option
|
||||
})
|
||||
|
||||
def findAllByCollective(
|
||||
cid: Ident,
|
||||
ownerLogin: Option[Ident],
|
||||
q: Option[String]
|
||||
): ConnectionIO[List[(RShare, RUser)]] = {
|
||||
val s = RShare.as("s")
|
||||
val u = RUser.as("u")
|
||||
|
||||
val ownerQ = ownerLogin.map(name => u.login === name)
|
||||
val nameQ = q.map(n => s.name.like(s"%$n%"))
|
||||
|
||||
Select(
|
||||
select(s.all, u.all),
|
||||
from(s).innerJoin(u, u.uid === s.userId),
|
||||
u.cid === cid &&? ownerQ &&? nameQ
|
||||
)
|
||||
.orderBy(s.publishedAt.desc)
|
||||
.build
|
||||
.query[(RShare, RUser)]
|
||||
.to[List]
|
||||
}
|
||||
|
||||
def deleteByIdAndCid(id: Ident, cid: Ident): ConnectionIO[Int] = {
|
||||
val u = RUser.T
|
||||
DML.delete(T, T.id === id && T.userId.in(Select(u.uid.s, from(u), u.cid === cid)))
|
||||
}
|
||||
}
|
@ -26,7 +26,13 @@ case class RUser(
|
||||
loginCount: Int,
|
||||
lastLogin: Option[Timestamp],
|
||||
created: Timestamp
|
||||
) {}
|
||||
) {
|
||||
def accountId: AccountId =
|
||||
AccountId(cid, login)
|
||||
|
||||
def idRef: IdRef =
|
||||
IdRef(uid, login.id)
|
||||
}
|
||||
|
||||
object RUser {
|
||||
|
||||
|
@ -16,12 +16,13 @@
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/svg": "1.0.1",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/url": "1.0.0",
|
||||
"elm-explorations/markdown": "1.0.0",
|
||||
"justinmimbs/date": "3.1.2",
|
||||
"norpan/elm-html5-drag-drop": "3.1.4",
|
||||
"pablohirafuji/elm-qrcode": "3.3.1",
|
||||
"pablohirafuji/elm-qrcode": "4.0.1",
|
||||
"ryannhg/date-format": "2.3.0",
|
||||
"truqu/elm-base64": "2.0.4",
|
||||
"ursi/elm-scroll": "1.0.0",
|
||||
@ -33,7 +34,6 @@
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/parser": "1.1.0",
|
||||
"elm/regex": "1.0.0",
|
||||
"elm/svg": "1.0.1",
|
||||
"elm/virtual-dom": "1.0.2",
|
||||
"elm-community/list-extra": "8.2.4",
|
||||
"folkertdev/elm-flate": "2.0.4",
|
||||
|
1974
modules/webapp/package-lock.json
generated
1974
modules/webapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -2,15 +2,17 @@
|
||||
"name": "docspell-css",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.3",
|
||||
"@tailwindcss/forms": "^0.3.0",
|
||||
"autoprefixer": "^10.2.5",
|
||||
"cssnano": "^5.0.0",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.4",
|
||||
"@tailwindcss/forms": "^0.3.4",
|
||||
"flag-icon-css": "^3.5.0",
|
||||
"postcss": "^8.2.9",
|
||||
"postcss-cli": "^8.3.1",
|
||||
"postcss-import": "^14.0.1",
|
||||
"tailwindcss": "^2.1.1"
|
||||
"postcss-cli": "^9.0.1",
|
||||
"postcss-import": "^14.0.2",
|
||||
"autoprefixer": "^10.3.7",
|
||||
"cssnano": "^5.0.8",
|
||||
"postcss": "^8.3.11",
|
||||
"postcss-purgecss": "^2.0.3",
|
||||
"tailwindcss": "^2.2.17"
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ const prodPlugins =
|
||||
require('postcss-import'),
|
||||
tailwindcss("./tailwind.config.js"),
|
||||
require("autoprefixer"),
|
||||
require("@fullhuman/postcss-purgecss")({
|
||||
require("postcss-purgecss")({
|
||||
content: [
|
||||
"./src/main/elm/**/*.elm",
|
||||
"./src/main/styles/keep.txt",
|
||||
|
@ -11,6 +11,7 @@ module Api exposing
|
||||
, addCorrOrg
|
||||
, addCorrPerson
|
||||
, addMember
|
||||
, addShare
|
||||
, addTag
|
||||
, addTagsMultiple
|
||||
, attachmentPreviewURL
|
||||
@ -40,6 +41,7 @@ module Api exposing
|
||||
, deleteOrg
|
||||
, deletePerson
|
||||
, deleteScanMailbox
|
||||
, deleteShare
|
||||
, deleteSource
|
||||
, deleteTag
|
||||
, deleteUser
|
||||
@ -72,6 +74,8 @@ module Api exposing
|
||||
, getPersonsLight
|
||||
, getScanMailbox
|
||||
, getSentMails
|
||||
, getShare
|
||||
, getShares
|
||||
, getSources
|
||||
, getTagCloud
|
||||
, getTags
|
||||
@ -79,6 +83,7 @@ module Api exposing
|
||||
, initOtp
|
||||
, itemBasePreviewURL
|
||||
, itemDetail
|
||||
, itemDetailShare
|
||||
, itemIndexSearch
|
||||
, itemSearch
|
||||
, itemSearchStats
|
||||
@ -109,6 +114,8 @@ module Api exposing
|
||||
, restoreAllItems
|
||||
, restoreItem
|
||||
, saveClientSettings
|
||||
, searchShare
|
||||
, searchShareStats
|
||||
, sendMail
|
||||
, setAttachmentName
|
||||
, setCollectiveSettings
|
||||
@ -136,6 +143,10 @@ module Api exposing
|
||||
, setTags
|
||||
, setTagsMultiple
|
||||
, setUnconfirmed
|
||||
, shareAttachmentPreviewURL
|
||||
, shareFileURL
|
||||
, shareItemBasePreviewURL
|
||||
, shareSendMail
|
||||
, startClassifier
|
||||
, startEmptyTrash
|
||||
, startOnceNotifyDueItems
|
||||
@ -147,9 +158,11 @@ module Api exposing
|
||||
, unconfirmMultiple
|
||||
, updateNotifyDueItems
|
||||
, updateScanMailbox
|
||||
, updateShare
|
||||
, upload
|
||||
, uploadAmend
|
||||
, uploadSingle
|
||||
, verifyShare
|
||||
, versionInfo
|
||||
)
|
||||
|
||||
@ -215,7 +228,13 @@ import Api.Model.ScanMailboxSettingsList exposing (ScanMailboxSettingsList)
|
||||
import Api.Model.SearchStats exposing (SearchStats)
|
||||
import Api.Model.SecondFactor exposing (SecondFactor)
|
||||
import Api.Model.SentMails exposing (SentMails)
|
||||
import Api.Model.ShareData exposing (ShareData)
|
||||
import Api.Model.ShareDetail exposing (ShareDetail)
|
||||
import Api.Model.ShareList exposing (ShareList)
|
||||
import Api.Model.ShareSecret exposing (ShareSecret)
|
||||
import Api.Model.ShareVerifyResult exposing (ShareVerifyResult)
|
||||
import Api.Model.SimpleMail exposing (SimpleMail)
|
||||
import Api.Model.SimpleShareMail exposing (SimpleShareMail)
|
||||
import Api.Model.SourceAndTags exposing (SourceAndTags)
|
||||
import Api.Model.SourceList exposing (SourceList)
|
||||
import Api.Model.SourceTagIn
|
||||
@ -2206,6 +2225,134 @@ disableOtp flags otp receive =
|
||||
|
||||
|
||||
|
||||
--- Share
|
||||
|
||||
|
||||
getShares : Flags -> String -> Bool -> (Result Http.Error ShareList -> msg) -> Cmd msg
|
||||
getShares flags query owning receive =
|
||||
Http2.authGet
|
||||
{ url =
|
||||
flags.config.baseUrl
|
||||
++ "/api/v1/sec/share?q="
|
||||
++ Url.percentEncode query
|
||||
++ (if owning then
|
||||
"&owning"
|
||||
|
||||
else
|
||||
""
|
||||
)
|
||||
, account = getAccount flags
|
||||
, expect = Http.expectJson receive Api.Model.ShareList.decoder
|
||||
}
|
||||
|
||||
|
||||
getShare : Flags -> String -> (Result Http.Error ShareDetail -> msg) -> Cmd msg
|
||||
getShare flags id receive =
|
||||
Http2.authGet
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/share/" ++ id
|
||||
, account = getAccount flags
|
||||
, expect = Http.expectJson receive Api.Model.ShareDetail.decoder
|
||||
}
|
||||
|
||||
|
||||
addShare : Flags -> ShareData -> (Result Http.Error IdResult -> msg) -> Cmd msg
|
||||
addShare flags share receive =
|
||||
Http2.authPost
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/share"
|
||||
, account = getAccount flags
|
||||
, body = Http.jsonBody (Api.Model.ShareData.encode share)
|
||||
, expect = Http.expectJson receive Api.Model.IdResult.decoder
|
||||
}
|
||||
|
||||
|
||||
updateShare : Flags -> String -> ShareData -> (Result Http.Error BasicResult -> msg) -> Cmd msg
|
||||
updateShare flags id share receive =
|
||||
Http2.authPut
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/share/" ++ id
|
||||
, account = getAccount flags
|
||||
, body = Http.jsonBody (Api.Model.ShareData.encode share)
|
||||
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
|
||||
}
|
||||
|
||||
|
||||
deleteShare : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
|
||||
deleteShare flags id receive =
|
||||
Http2.authDelete
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/share/" ++ id
|
||||
, account = getAccount flags
|
||||
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
|
||||
}
|
||||
|
||||
|
||||
verifyShare : Flags -> ShareSecret -> (Result Http.Error ShareVerifyResult -> msg) -> Cmd msg
|
||||
verifyShare flags secret receive =
|
||||
Http2.authPost
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/open/share/verify"
|
||||
, account = getAccount flags
|
||||
, body = Http.jsonBody (Api.Model.ShareSecret.encode secret)
|
||||
, expect = Http.expectJson receive Api.Model.ShareVerifyResult.decoder
|
||||
}
|
||||
|
||||
|
||||
searchShare : Flags -> String -> ItemQuery -> (Result Http.Error ItemLightList -> msg) -> Cmd msg
|
||||
searchShare flags token search receive =
|
||||
Http2.sharePost
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/share/search/query"
|
||||
, token = token
|
||||
, body = Http.jsonBody (Api.Model.ItemQuery.encode search)
|
||||
, expect = Http.expectJson receive Api.Model.ItemLightList.decoder
|
||||
}
|
||||
|
||||
|
||||
searchShareStats : Flags -> String -> ItemQuery -> (Result Http.Error SearchStats -> msg) -> Cmd msg
|
||||
searchShareStats flags token search receive =
|
||||
Http2.sharePost
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/share/search/stats"
|
||||
, token = token
|
||||
, body = Http.jsonBody (Api.Model.ItemQuery.encode search)
|
||||
, expect = Http.expectJson receive Api.Model.SearchStats.decoder
|
||||
}
|
||||
|
||||
|
||||
itemDetailShare : Flags -> String -> String -> (Result Http.Error ItemDetail -> msg) -> Cmd msg
|
||||
itemDetailShare flags token itemId receive =
|
||||
Http2.shareGet
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/share/item/" ++ itemId
|
||||
, token = token
|
||||
, expect = Http.expectJson receive Api.Model.ItemDetail.decoder
|
||||
}
|
||||
|
||||
|
||||
shareSendMail :
|
||||
Flags
|
||||
-> { conn : String, mail : SimpleShareMail }
|
||||
-> (Result Http.Error BasicResult -> msg)
|
||||
-> Cmd msg
|
||||
shareSendMail flags opts receive =
|
||||
Http2.authPost
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/share/email/send/" ++ opts.conn
|
||||
, account = getAccount flags
|
||||
, body = Http.jsonBody (Api.Model.SimpleShareMail.encode opts.mail)
|
||||
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
|
||||
}
|
||||
|
||||
|
||||
shareAttachmentPreviewURL : String -> String
|
||||
shareAttachmentPreviewURL id =
|
||||
"/api/v1/share/attachment/" ++ id ++ "/preview?withFallback=true"
|
||||
|
||||
|
||||
shareItemBasePreviewURL : String -> String
|
||||
shareItemBasePreviewURL itemId =
|
||||
"/api/v1/share/item/" ++ itemId ++ "/preview?withFallback=true"
|
||||
|
||||
|
||||
shareFileURL : String -> String
|
||||
shareFileURL attachId =
|
||||
"/api/v1/share/attachment/" ++ attachId
|
||||
|
||||
|
||||
|
||||
--- Helper
|
||||
|
||||
|
||||
|
@ -32,6 +32,8 @@ import Page.ManageData.Data
|
||||
import Page.NewInvite.Data
|
||||
import Page.Queue.Data
|
||||
import Page.Register.Data
|
||||
import Page.Share.Data
|
||||
import Page.ShareDetail.Data
|
||||
import Page.Upload.Data
|
||||
import Page.UserSettings.Data
|
||||
import Url exposing (Url)
|
||||
@ -52,6 +54,8 @@ type alias Model =
|
||||
, uploadModel : Page.Upload.Data.Model
|
||||
, newInviteModel : Page.NewInvite.Data.Model
|
||||
, itemDetailModel : Page.ItemDetail.Data.Model
|
||||
, shareModel : Page.Share.Data.Model
|
||||
, shareDetailModel : Page.ShareDetail.Data.Model
|
||||
, navMenuOpen : Bool
|
||||
, userMenuOpen : Bool
|
||||
, subs : Sub Msg
|
||||
@ -85,6 +89,12 @@ init key url flags_ settings =
|
||||
( loginm, loginc ) =
|
||||
Page.Login.Data.init flags (Page.loginPageReferrer page)
|
||||
|
||||
( shm, shc ) =
|
||||
Page.Share.Data.init (Page.pageShareId page) flags
|
||||
|
||||
( sdm, sdc ) =
|
||||
Page.ShareDetail.Data.init (Page.pageShareDetail page) flags
|
||||
|
||||
homeViewMode =
|
||||
if settings.searchMenuVisible then
|
||||
Page.Home.Data.SearchView
|
||||
@ -106,6 +116,8 @@ init key url flags_ settings =
|
||||
, uploadModel = Page.Upload.Data.emptyModel
|
||||
, newInviteModel = Page.NewInvite.Data.emptyModel
|
||||
, itemDetailModel = Page.ItemDetail.Data.emptyModel
|
||||
, shareModel = shm
|
||||
, shareDetailModel = sdm
|
||||
, navMenuOpen = False
|
||||
, userMenuOpen = False
|
||||
, subs = Sub.none
|
||||
@ -120,6 +132,8 @@ init key url flags_ settings =
|
||||
, Cmd.map ManageDataMsg mdc
|
||||
, Cmd.map CollSettingsMsg csc
|
||||
, Cmd.map LoginMsg loginc
|
||||
, Cmd.map ShareMsg shc
|
||||
, Cmd.map ShareDetailMsg sdc
|
||||
]
|
||||
)
|
||||
|
||||
@ -162,6 +176,8 @@ type Msg
|
||||
| UploadMsg Page.Upload.Data.Msg
|
||||
| NewInviteMsg Page.NewInvite.Data.Msg
|
||||
| ItemDetailMsg Page.ItemDetail.Data.Msg
|
||||
| ShareMsg Page.Share.Data.Msg
|
||||
| ShareDetailMsg Page.ShareDetail.Data.Msg
|
||||
| Logout
|
||||
| LogoutResp (Result Http.Error ())
|
||||
| SessionCheckResp (Result Http.Error AuthResult)
|
||||
|
@ -17,6 +17,7 @@ import Browser.Navigation as Nav
|
||||
import Data.Flags
|
||||
import Data.UiSettings exposing (UiSettings)
|
||||
import Data.UiTheme
|
||||
import Messages exposing (Messages)
|
||||
import Page exposing (Page(..))
|
||||
import Page.CollectiveSettings.Data
|
||||
import Page.CollectiveSettings.Update
|
||||
@ -34,6 +35,10 @@ import Page.Queue.Data
|
||||
import Page.Queue.Update
|
||||
import Page.Register.Data
|
||||
import Page.Register.Update
|
||||
import Page.Share.Data
|
||||
import Page.Share.Update
|
||||
import Page.ShareDetail.Data
|
||||
import Page.ShareDetail.Update
|
||||
import Page.Upload.Data
|
||||
import Page.Upload.Update
|
||||
import Page.UserSettings.Data
|
||||
@ -55,6 +60,10 @@ update msg model =
|
||||
|
||||
updateWithSub : Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
|
||||
updateWithSub msg model =
|
||||
let
|
||||
texts =
|
||||
Messages.get <| App.Data.getUiLanguage model
|
||||
in
|
||||
case msg of
|
||||
ToggleSidebar ->
|
||||
( { model | sidebarVisible = not model.sidebarVisible }, Cmd.none, Sub.none )
|
||||
@ -94,7 +103,7 @@ updateWithSub msg model =
|
||||
|
||||
ClientSettingsSaveResp settings (Ok res) ->
|
||||
if res.success then
|
||||
applyClientSettings model settings
|
||||
applyClientSettings texts model settings
|
||||
|
||||
else
|
||||
( model, Cmd.none, Sub.none )
|
||||
@ -112,7 +121,13 @@ updateWithSub msg model =
|
||||
( { model | anonymousUiLang = lang, langMenuOpen = False }, Cmd.none, Sub.none )
|
||||
|
||||
HomeMsg lm ->
|
||||
updateHome lm model
|
||||
updateHome texts lm model
|
||||
|
||||
ShareMsg lm ->
|
||||
updateShare lm model
|
||||
|
||||
ShareDetailMsg lm ->
|
||||
updateShareDetail lm model
|
||||
|
||||
LoginMsg lm ->
|
||||
updateLogin lm model
|
||||
@ -121,10 +136,10 @@ updateWithSub msg model =
|
||||
updateManageData lm model
|
||||
|
||||
CollSettingsMsg m ->
|
||||
updateCollSettings m model
|
||||
updateCollSettings texts m model
|
||||
|
||||
UserSettingsMsg m ->
|
||||
updateUserSettings m model
|
||||
updateUserSettings texts m model
|
||||
|
||||
QueueMsg m ->
|
||||
updateQueue m model
|
||||
@ -139,7 +154,7 @@ updateWithSub msg model =
|
||||
updateNewInvite m model
|
||||
|
||||
ItemDetailMsg m ->
|
||||
updateItemDetail m model
|
||||
updateItemDetail texts m model
|
||||
|
||||
VersionResp (Ok info) ->
|
||||
( { model | version = info }, Cmd.none, Sub.none )
|
||||
@ -281,7 +296,7 @@ updateWithSub msg model =
|
||||
)
|
||||
|
||||
GetUiSettings (Ok settings) ->
|
||||
applyClientSettings model settings
|
||||
applyClientSettings texts model settings
|
||||
|
||||
GetUiSettings (Err _) ->
|
||||
( model, Cmd.none, Sub.none )
|
||||
@ -291,11 +306,11 @@ updateWithSub msg model =
|
||||
lm =
|
||||
Page.UserSettings.Data.ReceiveBrowserSettings sett
|
||||
in
|
||||
updateUserSettings lm model
|
||||
updateUserSettings texts lm model
|
||||
|
||||
|
||||
applyClientSettings : Model -> UiSettings -> ( Model, Cmd Msg, Sub Msg )
|
||||
applyClientSettings model settings =
|
||||
applyClientSettings : Messages -> Model -> UiSettings -> ( Model, Cmd Msg, Sub Msg )
|
||||
applyClientSettings texts model settings =
|
||||
let
|
||||
setTheme =
|
||||
Ports.setUiTheme settings.uiTheme
|
||||
@ -306,15 +321,49 @@ applyClientSettings model settings =
|
||||
, setTheme
|
||||
, Sub.none
|
||||
)
|
||||
, updateUserSettings Page.UserSettings.Data.UpdateSettings
|
||||
, updateHome Page.Home.Data.UiSettingsUpdated
|
||||
, updateItemDetail Page.ItemDetail.Data.UiSettingsUpdated
|
||||
, updateUserSettings texts Page.UserSettings.Data.UpdateSettings
|
||||
, updateHome texts Page.Home.Data.UiSettingsUpdated
|
||||
, updateItemDetail texts Page.ItemDetail.Data.UiSettingsUpdated
|
||||
]
|
||||
{ model | uiSettings = settings }
|
||||
|
||||
|
||||
updateItemDetail : Page.ItemDetail.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
|
||||
updateItemDetail lmsg model =
|
||||
updateShareDetail : Page.ShareDetail.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
|
||||
updateShareDetail lmsg model =
|
||||
case Page.pageShareDetail model.page of
|
||||
Just ( shareId, itemId ) ->
|
||||
let
|
||||
( m, c ) =
|
||||
Page.ShareDetail.Update.update shareId itemId model.flags lmsg model.shareDetailModel
|
||||
in
|
||||
( { model | shareDetailModel = m }
|
||||
, Cmd.map ShareDetailMsg c
|
||||
, Sub.none
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none, Sub.none )
|
||||
|
||||
|
||||
updateShare : Page.Share.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
|
||||
updateShare lmsg model =
|
||||
case Page.pageShareId model.page of
|
||||
Just id ->
|
||||
let
|
||||
result =
|
||||
Page.Share.Update.update model.flags model.uiSettings id lmsg model.shareModel
|
||||
in
|
||||
( { model | shareModel = result.model }
|
||||
, Cmd.map ShareMsg result.cmd
|
||||
, Sub.map ShareMsg result.sub
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none, Sub.none )
|
||||
|
||||
|
||||
updateItemDetail : Messages -> Page.ItemDetail.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
|
||||
updateItemDetail texts lmsg model =
|
||||
let
|
||||
inav =
|
||||
Page.Home.Data.itemNav model.itemDetailModel.detail.item.id model.homeModel
|
||||
@ -334,12 +383,12 @@ updateItemDetail lmsg model =
|
||||
}
|
||||
|
||||
( hm, hc, hs ) =
|
||||
updateHome (Page.Home.Data.SetLinkTarget result.linkTarget) model_
|
||||
updateHome texts (Page.Home.Data.SetLinkTarget result.linkTarget) model_
|
||||
|
||||
( hm1, hc1, hs1 ) =
|
||||
case result.removedItem of
|
||||
Just removedId ->
|
||||
updateHome (Page.Home.Data.RemoveItem removedId) hm
|
||||
updateHome texts (Page.Home.Data.RemoveItem removedId) hm
|
||||
|
||||
Nothing ->
|
||||
( hm, hc, hs )
|
||||
@ -402,8 +451,8 @@ updateQueue lmsg model =
|
||||
)
|
||||
|
||||
|
||||
updateUserSettings : Page.UserSettings.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
|
||||
updateUserSettings lmsg model =
|
||||
updateUserSettings : Messages -> Page.UserSettings.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
|
||||
updateUserSettings texts lmsg model =
|
||||
let
|
||||
result =
|
||||
Page.UserSettings.Update.update model.flags model.uiSettings lmsg model.userSettingsModel
|
||||
@ -414,7 +463,7 @@ updateUserSettings lmsg model =
|
||||
( lm2, lc2, s2 ) =
|
||||
case result.newSettings of
|
||||
Just sett ->
|
||||
applyClientSettings model_ sett
|
||||
applyClientSettings texts model_ sett
|
||||
|
||||
Nothing ->
|
||||
( model_, Cmd.none, Sub.none )
|
||||
@ -431,17 +480,18 @@ updateUserSettings lmsg model =
|
||||
)
|
||||
|
||||
|
||||
updateCollSettings : Page.CollectiveSettings.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
|
||||
updateCollSettings lmsg model =
|
||||
updateCollSettings : Messages -> Page.CollectiveSettings.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
|
||||
updateCollSettings texts lmsg model =
|
||||
let
|
||||
( lm, lc ) =
|
||||
Page.CollectiveSettings.Update.update model.flags
|
||||
( lm, lc, ls ) =
|
||||
Page.CollectiveSettings.Update.update texts.collectiveSettings
|
||||
model.flags
|
||||
lmsg
|
||||
model.collSettingsModel
|
||||
in
|
||||
( { model | collSettingsModel = lm }
|
||||
, Cmd.map CollSettingsMsg lc
|
||||
, Sub.none
|
||||
, Sub.map CollSettingsMsg ls
|
||||
)
|
||||
|
||||
|
||||
@ -464,8 +514,8 @@ updateLogin lmsg model =
|
||||
)
|
||||
|
||||
|
||||
updateHome : Page.Home.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
|
||||
updateHome lmsg model =
|
||||
updateHome : Messages -> Page.Home.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
|
||||
updateHome texts lmsg model =
|
||||
let
|
||||
mid =
|
||||
case model.page of
|
||||
@ -476,7 +526,7 @@ updateHome lmsg model =
|
||||
Nothing
|
||||
|
||||
result =
|
||||
Page.Home.Update.update mid model.key model.flags model.uiSettings lmsg model.homeModel
|
||||
Page.Home.Update.update mid model.key model.flags texts.home model.uiSettings lmsg model.homeModel
|
||||
|
||||
model_ =
|
||||
{ model | homeModel = result.model }
|
||||
@ -484,7 +534,7 @@ updateHome lmsg model =
|
||||
( lm, lc, ls ) =
|
||||
case result.newSettings of
|
||||
Just sett ->
|
||||
applyClientSettings model_ sett
|
||||
applyClientSettings texts model_ sett
|
||||
|
||||
Nothing ->
|
||||
( model_, Cmd.none, Sub.none )
|
||||
@ -518,11 +568,14 @@ initPage model_ page =
|
||||
let
|
||||
model =
|
||||
{ model_ | page = page }
|
||||
|
||||
texts =
|
||||
Messages.get <| App.Data.getUiLanguage model
|
||||
in
|
||||
case page of
|
||||
HomePage ->
|
||||
Util.Update.andThen2
|
||||
[ updateHome Page.Home.Data.Init
|
||||
[ updateHome texts Page.Home.Data.Init
|
||||
, updateQueue Page.Queue.Data.StopRefresh
|
||||
]
|
||||
model
|
||||
@ -536,7 +589,7 @@ initPage model_ page =
|
||||
CollectiveSettingPage ->
|
||||
Util.Update.andThen2
|
||||
[ updateQueue Page.Queue.Data.StopRefresh
|
||||
, updateCollSettings Page.CollectiveSettings.Data.Init
|
||||
, updateCollSettings texts Page.CollectiveSettings.Data.Init
|
||||
]
|
||||
model
|
||||
|
||||
@ -564,7 +617,33 @@ initPage model_ page =
|
||||
|
||||
ItemDetailPage id ->
|
||||
Util.Update.andThen2
|
||||
[ updateItemDetail (Page.ItemDetail.Data.Init id)
|
||||
[ updateItemDetail texts (Page.ItemDetail.Data.Init id)
|
||||
, updateQueue Page.Queue.Data.StopRefresh
|
||||
]
|
||||
model
|
||||
|
||||
SharePage id ->
|
||||
let
|
||||
cmd =
|
||||
Cmd.map ShareMsg (Page.Share.Data.initCmd id model.flags)
|
||||
|
||||
shareModel =
|
||||
model.shareModel
|
||||
in
|
||||
if shareModel.initialized then
|
||||
( model, Cmd.none, Sub.none )
|
||||
|
||||
else
|
||||
( { model | shareModel = { shareModel | initialized = True } }, cmd, Sub.none )
|
||||
|
||||
ShareDetailPage _ _ ->
|
||||
case model_.page of
|
||||
SharePage _ ->
|
||||
let
|
||||
verifyResult =
|
||||
model.shareModel.verifyResult
|
||||
in
|
||||
updateShareDetail (Page.ShareDetail.Data.VerifyResp (Ok verifyResult)) model
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none, Sub.none )
|
||||
|
@ -27,6 +27,8 @@ import Page.ManageData.View2 as ManageData
|
||||
import Page.NewInvite.View2 as NewInvite
|
||||
import Page.Queue.View2 as Queue
|
||||
import Page.Register.View2 as Register
|
||||
import Page.Share.View as Share
|
||||
import Page.ShareDetail.View as ShareDetail
|
||||
import Page.Upload.View2 as Upload
|
||||
import Page.UserSettings.View2 as UserSettings
|
||||
import Styles as S
|
||||
@ -41,14 +43,10 @@ view model =
|
||||
|
||||
topNavbar : Model -> Html Msg
|
||||
topNavbar model =
|
||||
case model.flags.account of
|
||||
case Data.Flags.getAccount model.flags of
|
||||
Just acc ->
|
||||
if acc.success then
|
||||
topNavUser acc model
|
||||
|
||||
else
|
||||
topNavAnon model
|
||||
|
||||
Nothing ->
|
||||
topNavAnon model
|
||||
|
||||
@ -72,7 +70,7 @@ topNavUser auth model =
|
||||
, baseStyle = "font-bold inline-flex items-center px-4 py-2"
|
||||
, activeStyle = "hover:bg-blue-200 dark:hover:bg-bluegray-800 w-12"
|
||||
}
|
||||
, headerNavItem model
|
||||
, headerNavItem True model
|
||||
, div [ class "flex flex-grow justify-end" ]
|
||||
[ userMenu texts.app auth model
|
||||
, dataMenu texts.app auth model
|
||||
@ -86,7 +84,16 @@ topNavAnon model =
|
||||
[ id "top-nav"
|
||||
, class styleTopNav
|
||||
]
|
||||
[ headerNavItem model
|
||||
[ B.genericButton
|
||||
{ label = ""
|
||||
, icon = "fa fa-bars"
|
||||
, handler = onClick ToggleSidebar
|
||||
, disabled = not (Page.hasSidebar model.page)
|
||||
, attrs = [ href "#" ]
|
||||
, baseStyle = "font-bold inline-flex items-center px-4 py-2"
|
||||
, activeStyle = "hover:bg-blue-200 dark:hover:bg-bluegray-800 w-12"
|
||||
}
|
||||
, headerNavItem False model
|
||||
, div [ class "flex flex-grow justify-end" ]
|
||||
[ langMenu model
|
||||
, a
|
||||
@ -100,11 +107,24 @@ topNavAnon model =
|
||||
]
|
||||
|
||||
|
||||
headerNavItem : Model -> Html Msg
|
||||
headerNavItem model =
|
||||
headerNavItem : Bool -> Model -> Html Msg
|
||||
headerNavItem authenticated model =
|
||||
let
|
||||
tag =
|
||||
if authenticated then
|
||||
a
|
||||
[ class "inline-flex font-bold hover:bg-blue-200 dark:hover:bg-bluegray-800 items-center px-4"
|
||||
, Page.href HomePage
|
||||
|
||||
else
|
||||
div
|
||||
in
|
||||
tag
|
||||
[ class "inline-flex font-bold items-center px-4"
|
||||
, classList [ ( "hover:bg-blue-200 dark:hover:bg-bluegray-800", authenticated ) ]
|
||||
, if authenticated then
|
||||
Page.href HomePage
|
||||
|
||||
else
|
||||
href "#"
|
||||
]
|
||||
[ img
|
||||
[ src (model.flags.config.docspellAssetPath ++ "/img/logo-96.png")
|
||||
@ -157,6 +177,12 @@ mainContent model =
|
||||
|
||||
ItemDetailPage id ->
|
||||
viewItemDetail texts id model
|
||||
|
||||
SharePage id ->
|
||||
viewShare texts id model
|
||||
|
||||
ShareDetailPage shareId itemId ->
|
||||
viewShareDetail texts shareId itemId model
|
||||
)
|
||||
|
||||
|
||||
@ -411,6 +437,49 @@ dropdownMenu =
|
||||
" absolute right-0 bg-white dark:bg-bluegray-800 border dark:border-bluegray-700 dark:text-bluegray-300 shadow-lg opacity-1 transition duration-200 min-w-max "
|
||||
|
||||
|
||||
viewShare : Messages -> String -> Model -> List (Html Msg)
|
||||
viewShare texts shareId model =
|
||||
[ Html.map ShareMsg
|
||||
(Share.viewSidebar texts.share
|
||||
model.sidebarVisible
|
||||
model.flags
|
||||
model.uiSettings
|
||||
model.shareModel
|
||||
)
|
||||
, Html.map ShareMsg
|
||||
(Share.viewContent texts.share
|
||||
model.flags
|
||||
model.version
|
||||
model.uiSettings
|
||||
shareId
|
||||
model.shareModel
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
viewShareDetail : Messages -> String -> String -> Model -> List (Html Msg)
|
||||
viewShareDetail texts shareId itemId model =
|
||||
[ Html.map ShareDetailMsg
|
||||
(ShareDetail.viewSidebar texts.shareDetail
|
||||
model.sidebarVisible
|
||||
model.flags
|
||||
model.uiSettings
|
||||
shareId
|
||||
itemId
|
||||
model.shareDetailModel
|
||||
)
|
||||
, Html.map ShareDetailMsg
|
||||
(ShareDetail.viewContent texts.shareDetail
|
||||
model.flags
|
||||
model.uiSettings
|
||||
model.version
|
||||
shareId
|
||||
itemId
|
||||
model.shareDetailModel
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
viewHome : Messages -> Model -> List (Html Msg)
|
||||
viewHome texts model =
|
||||
[ Html.map HomeMsg
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user