mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-22 10:28:27 +00:00
Initial version.
Features: - Upload PDF files let them analyze - Manage meta data and items - See processing in webapp
This commit is contained in:
@ -0,0 +1,66 @@
|
||||
package docspell.backend
|
||||
|
||||
import cats.effect.{Blocker, ConcurrentEffect, ContextShift, Resource}
|
||||
import docspell.backend.auth.Login
|
||||
import docspell.backend.ops._
|
||||
import docspell.backend.signup.OSignup
|
||||
import docspell.store.Store
|
||||
import docspell.store.ops.ONode
|
||||
import docspell.store.queue.JobQueue
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
trait BackendApp[F[_]] {
|
||||
|
||||
def login: Login[F]
|
||||
def signup: OSignup[F]
|
||||
def collective: OCollective[F]
|
||||
def source: OSource[F]
|
||||
def tag: OTag[F]
|
||||
def equipment: OEquipment[F]
|
||||
def organization: OOrganization[F]
|
||||
def upload: OUpload[F]
|
||||
def node: ONode[F]
|
||||
def job: OJob[F]
|
||||
def item: OItem[F]
|
||||
}
|
||||
|
||||
object BackendApp {
|
||||
|
||||
def create[F[_]: ConcurrentEffect](cfg: Config, store: Store[F], httpClientEc: ExecutionContext): Resource[F, BackendApp[F]] =
|
||||
for {
|
||||
queue <- JobQueue(store)
|
||||
loginImpl <- Login[F](store)
|
||||
signupImpl <- OSignup[F](store)
|
||||
collImpl <- OCollective[F](store)
|
||||
sourceImpl <- OSource[F](store)
|
||||
tagImpl <- OTag[F](store)
|
||||
equipImpl <- OEquipment[F](store)
|
||||
orgImpl <- OOrganization(store)
|
||||
uploadImpl <- OUpload(store, queue, cfg, httpClientEc)
|
||||
nodeImpl <- ONode(store)
|
||||
jobImpl <- OJob(store, httpClientEc)
|
||||
itemImpl <- OItem(store)
|
||||
} yield new BackendApp[F] {
|
||||
val login: Login[F] = loginImpl
|
||||
val signup: OSignup[F] = signupImpl
|
||||
val collective: OCollective[F] = collImpl
|
||||
val source = sourceImpl
|
||||
val tag = tagImpl
|
||||
val equipment = equipImpl
|
||||
val organization = orgImpl
|
||||
val upload = uploadImpl
|
||||
val node = nodeImpl
|
||||
val job = jobImpl
|
||||
val item = itemImpl
|
||||
}
|
||||
|
||||
def apply[F[_]: ConcurrentEffect: ContextShift](cfg: Config
|
||||
, connectEC: ExecutionContext
|
||||
, httpClientEc: ExecutionContext
|
||||
, blocker: Blocker): Resource[F, BackendApp[F]] =
|
||||
for {
|
||||
store <- Store.create(cfg.jdbc, connectEC, blocker)
|
||||
backend <- create(cfg, store, httpClientEc)
|
||||
} yield backend
|
||||
}
|
10
modules/backend/src/main/scala/docspell/backend/Common.scala
Normal file
10
modules/backend/src/main/scala/docspell/backend/Common.scala
Normal file
@ -0,0 +1,10 @@
|
||||
package docspell.backend
|
||||
|
||||
import cats.effect._
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
object Common {
|
||||
|
||||
def genSaltString[F[_]: Sync]: F[String] =
|
||||
Sync[F].delay(BCrypt.gensalt())
|
||||
}
|
16
modules/backend/src/main/scala/docspell/backend/Config.scala
Normal file
16
modules/backend/src/main/scala/docspell/backend/Config.scala
Normal file
@ -0,0 +1,16 @@
|
||||
package docspell.backend
|
||||
|
||||
import docspell.backend.signup.{Config => SignupConfig}
|
||||
import docspell.common.MimeType
|
||||
import docspell.store.JdbcConfig
|
||||
|
||||
case class Config( jdbc: JdbcConfig
|
||||
, signup: SignupConfig
|
||||
, files: Config.Files) {
|
||||
|
||||
}
|
||||
|
||||
object Config {
|
||||
|
||||
case class Files(chunkSize: Int, validMimeTypes: Seq[MimeType])
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package docspell.backend
|
||||
|
||||
import docspell.common.Password
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
object PasswordCrypt {
|
||||
|
||||
def crypt(pass: Password): Password =
|
||||
Password(BCrypt.hashpw(pass.pass, BCrypt.gensalt()))
|
||||
|
||||
def check(plain: Password, hashed: Password): Boolean =
|
||||
BCrypt.checkpw(plain.pass, hashed.pass)
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
package docspell.backend.auth
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import java.time.Instant
|
||||
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
import docspell.backend.Common
|
||||
import AuthToken._
|
||||
import docspell.common._
|
||||
|
||||
case class AuthToken(millis: Long, account: AccountId, salt: String, sig: String) {
|
||||
def asString = s"$millis-${b64enc(account.asString)}-$salt-$sig"
|
||||
|
||||
def sigValid(key: ByteVector): Boolean = {
|
||||
val newSig = AuthToken.sign(this, key)
|
||||
AuthToken.constTimeEq(sig, newSig)
|
||||
}
|
||||
def sigInvalid(key: ByteVector): Boolean =
|
||||
!sigValid(key)
|
||||
|
||||
def notExpired(validity: Duration): Boolean =
|
||||
!isExpired(validity)
|
||||
|
||||
def isExpired(validity: Duration): Boolean = {
|
||||
val ends = Instant.ofEpochMilli(millis).plusMillis(validity.millis)
|
||||
Instant.now.isAfter(ends)
|
||||
}
|
||||
|
||||
def validate(key: ByteVector, validity: Duration): Boolean =
|
||||
sigValid(key) && notExpired(validity)
|
||||
}
|
||||
|
||||
object AuthToken {
|
||||
private val utf8 = java.nio.charset.StandardCharsets.UTF_8
|
||||
|
||||
def fromString(s: String): Either[String, AuthToken] =
|
||||
s.split("\\-", 4) match {
|
||||
case Array(ms, as, salt, sig) =>
|
||||
for {
|
||||
millis <- asInt(ms).toRight("Cannot read authenticator data")
|
||||
acc <- b64dec(as).toRight("Cannot read authenticator data")
|
||||
accId <- AccountId.parse(acc)
|
||||
} yield AuthToken(millis, accId, salt, sig)
|
||||
|
||||
case _ =>
|
||||
Left("Invalid authenticator")
|
||||
}
|
||||
|
||||
def user[F[_]: Sync](accountId: AccountId, key: ByteVector): F[AuthToken] = {
|
||||
for {
|
||||
salt <- Common.genSaltString[F]
|
||||
millis = Instant.now.toEpochMilli
|
||||
cd = AuthToken(millis, accountId, salt, "")
|
||||
sig = sign(cd, key)
|
||||
} yield cd.copy(sig = sig)
|
||||
}
|
||||
|
||||
private def sign(cd: AuthToken, key: ByteVector): String = {
|
||||
val raw = cd.millis.toString + cd.account.asString + cd.salt
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
mac.init(new SecretKeySpec(key.toArray, "HmacSHA1"))
|
||||
ByteVector.view(mac.doFinal(raw.getBytes(utf8))).toBase64
|
||||
}
|
||||
|
||||
private def b64enc(s: String): String =
|
||||
ByteVector.view(s.getBytes(utf8)).toBase64
|
||||
|
||||
private def b64dec(s: String): Option[String] =
|
||||
ByteVector.fromValidBase64(s).decodeUtf8.toOption
|
||||
|
||||
private def asInt(s: String): Option[Long] =
|
||||
Either.catchNonFatal(s.toLong).toOption
|
||||
|
||||
private def constTimeEq(s1: String, s2: String): Boolean =
|
||||
s1.zip(s2).foldLeft(true)({ case (r, (c1, c2)) => r & c1 == c2 }) & s1.length == s2.length
|
||||
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
package docspell.backend.auth
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import Login._
|
||||
import docspell.common._
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.QLogin
|
||||
import docspell.store.records.RUser
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
import scodec.bits.ByteVector
|
||||
import org.log4s._
|
||||
|
||||
trait Login[F[_]] {
|
||||
|
||||
def loginSession(config: Config)(sessionKey: String): F[Result]
|
||||
|
||||
def loginUserPass(config: Config)(up: UserPass): F[Result]
|
||||
|
||||
}
|
||||
|
||||
object Login {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
case class Config(serverSecret: ByteVector, sessionValid: Duration)
|
||||
|
||||
case class UserPass(user: String, pass: String) {
|
||||
def hidePass: UserPass =
|
||||
if (pass.isEmpty) copy(pass = "<none>")
|
||||
else copy(pass = "***")
|
||||
}
|
||||
|
||||
sealed trait Result {
|
||||
def toEither: Either[String, AuthToken]
|
||||
}
|
||||
object Result {
|
||||
case class Ok(session: AuthToken) extends Result {
|
||||
val toEither = Right(session)
|
||||
}
|
||||
case object InvalidAuth extends Result {
|
||||
val toEither = Left("Authentication failed.")
|
||||
}
|
||||
case object InvalidTime extends Result {
|
||||
val toEither = Left("Authentication failed.")
|
||||
}
|
||||
|
||||
def ok(session: AuthToken): Result = Ok(session)
|
||||
def invalidAuth: Result = InvalidAuth
|
||||
def invalidTime: Result = InvalidTime
|
||||
}
|
||||
|
||||
def apply[F[_]: Effect](store: Store[F]): Resource[F, Login[F]] = Resource.pure(new Login[F] {
|
||||
|
||||
def loginSession(config: Config)(sessionKey: String): F[Result] =
|
||||
AuthToken.fromString(sessionKey) match {
|
||||
case Right(at) =>
|
||||
if (at.sigInvalid(config.serverSecret)) Result.invalidAuth.pure[F]
|
||||
else if (at.isExpired(config.sessionValid)) Result.invalidTime.pure[F]
|
||||
else Result.ok(at).pure[F]
|
||||
case Left(err) =>
|
||||
Result.invalidAuth.pure[F]
|
||||
}
|
||||
|
||||
def loginUserPass(config: Config)(up: UserPass): F[Result] = {
|
||||
AccountId.parse(up.user) match {
|
||||
case Right(acc) =>
|
||||
val okResult=
|
||||
store.transact(RUser.updateLogin(acc)) *>
|
||||
AuthToken.user(acc, config.serverSecret).map(Result.ok)
|
||||
for {
|
||||
data <- store.transact(QLogin.findUser(acc))
|
||||
_ <- Sync[F].delay(logger.trace(s"Account lookup: $data"))
|
||||
res <- if (data.exists(check(up.pass))) okResult
|
||||
else Result.invalidAuth.pure[F]
|
||||
} yield res
|
||||
case Left(err) =>
|
||||
Result.invalidAuth.pure[F]
|
||||
}
|
||||
}
|
||||
|
||||
private def check(given: String)(data: QLogin.Data): Boolean = {
|
||||
val collOk = data.collectiveState == CollectiveState.Active ||
|
||||
data.collectiveState == CollectiveState.ReadOnly
|
||||
val userOk = data.userState == UserState.Active
|
||||
val passOk = BCrypt.checkpw(given, data.password.pass)
|
||||
collOk && userOk && passOk
|
||||
}
|
||||
})
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.common._
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.records.{RCollective, RUser}
|
||||
import OCollective._
|
||||
import docspell.backend.PasswordCrypt
|
||||
import docspell.store.queries.QCollective
|
||||
|
||||
trait OCollective[F[_]] {
|
||||
|
||||
def find(name: Ident): F[Option[RCollective]]
|
||||
|
||||
def updateLanguage(collective: Ident, lang: Language): F[AddResult]
|
||||
|
||||
def listUser(collective: Ident): F[Vector[RUser]]
|
||||
|
||||
def add(s: RUser): F[AddResult]
|
||||
|
||||
def update(s: RUser): F[AddResult]
|
||||
|
||||
def deleteUser(login: Ident, collective: Ident): F[AddResult]
|
||||
|
||||
def insights(collective: Ident): F[InsightData]
|
||||
|
||||
def changePassword(accountId: AccountId, current: Password, newPass: Password): F[PassChangeResult]
|
||||
}
|
||||
|
||||
object OCollective {
|
||||
|
||||
type InsightData = QCollective.InsightData
|
||||
val insightData = QCollective.InsightData
|
||||
|
||||
sealed trait PassChangeResult
|
||||
object PassChangeResult {
|
||||
case object UserNotFound extends PassChangeResult
|
||||
case object PasswordMismatch extends PassChangeResult
|
||||
case object UpdateFailed extends PassChangeResult
|
||||
case object Success extends PassChangeResult
|
||||
|
||||
def userNotFound: PassChangeResult = UserNotFound
|
||||
def passwordMismatch: PassChangeResult = PasswordMismatch
|
||||
def success: PassChangeResult = Success
|
||||
def updateFailed: PassChangeResult = UpdateFailed
|
||||
}
|
||||
|
||||
case class RegisterData(collName: Ident, login: Ident, password: Password, invite: Option[Ident])
|
||||
|
||||
sealed trait RegisterResult {
|
||||
def toEither: Either[Throwable, Unit]
|
||||
}
|
||||
object RegisterResult {
|
||||
case object Success extends RegisterResult {
|
||||
val toEither = Right(())
|
||||
}
|
||||
case class CollectiveExists(id: Ident) extends RegisterResult {
|
||||
val toEither = Left(new Exception())
|
||||
}
|
||||
case class Error(ex: Throwable) extends RegisterResult {
|
||||
val toEither = Left(ex)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def apply[F[_]:Effect](store: Store[F]): Resource[F, OCollective[F]] =
|
||||
Resource.pure(new OCollective[F] {
|
||||
def find(name: Ident): F[Option[RCollective]] =
|
||||
store.transact(RCollective.findById(name))
|
||||
|
||||
def updateLanguage(collective: Ident, lang: Language): F[AddResult] =
|
||||
store.transact(RCollective.updateLanguage(collective, lang)).
|
||||
attempt.map(AddResult.fromUpdate)
|
||||
|
||||
def listUser(collective: Ident): F[Vector[RUser]] = {
|
||||
store.transact(RUser.findAll(collective, _.login))
|
||||
}
|
||||
|
||||
def add(s: RUser): F[AddResult] =
|
||||
store.add(RUser.insert(s.copy(password = PasswordCrypt.crypt(s.password))), RUser.exists(s.login))
|
||||
|
||||
def update(s: RUser): F[AddResult] =
|
||||
store.add(RUser.update(s), RUser.exists(s.login))
|
||||
|
||||
def deleteUser(login: Ident, collective: Ident): F[AddResult] =
|
||||
store.transact(RUser.delete(login, collective)).
|
||||
attempt.map(AddResult.fromUpdate)
|
||||
|
||||
def insights(collective: Ident): F[InsightData] =
|
||||
store.transact(QCollective.getInsights(collective))
|
||||
|
||||
def changePassword(accountId: AccountId, current: Password, newPass: Password): F[PassChangeResult] = {
|
||||
val q = for {
|
||||
optUser <- RUser.findByAccount(accountId)
|
||||
check = optUser.map(_.password).map(p => PasswordCrypt.check(current, p))
|
||||
n <- check.filter(identity).traverse(_ => RUser.updatePassword(accountId, PasswordCrypt.crypt(newPass)))
|
||||
res = check match {
|
||||
case Some(true) =>
|
||||
if (n.getOrElse(0) > 0) PassChangeResult.success else PassChangeResult.updateFailed
|
||||
case Some(false) =>
|
||||
PassChangeResult.passwordMismatch
|
||||
case None =>
|
||||
PassChangeResult.userNotFound
|
||||
}
|
||||
} yield res
|
||||
|
||||
store.transact(q)
|
||||
}
|
||||
})
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.common.{AccountId, Ident}
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.records.{REquipment, RItem}
|
||||
|
||||
trait OEquipment[F[_]] {
|
||||
|
||||
def findAll(account: AccountId): F[Vector[REquipment]]
|
||||
|
||||
def add(s: REquipment): F[AddResult]
|
||||
|
||||
def update(s: REquipment): F[AddResult]
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult]
|
||||
}
|
||||
|
||||
|
||||
object OEquipment {
|
||||
|
||||
def apply[F[_]: Effect](store: Store[F]): Resource[F, OEquipment[F]] =
|
||||
Resource.pure(new OEquipment[F] {
|
||||
def findAll(account: AccountId): F[Vector[REquipment]] =
|
||||
store.transact(REquipment.findAll(account.collective, _.name))
|
||||
|
||||
def add(e: REquipment): F[AddResult] = {
|
||||
def insert = REquipment.insert(e)
|
||||
def exists = REquipment.existsByName(e.cid, e.name)
|
||||
|
||||
val msg = s"An equipment '${e.name}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def update(e: REquipment): F[AddResult] = {
|
||||
def insert = REquipment.update(e)
|
||||
def exists = REquipment.existsByName(e.cid, e.name)
|
||||
|
||||
val msg = s"An equipment '${e.name}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult] = {
|
||||
val io = for {
|
||||
n0 <- RItem.removeConcEquip(collective, id)
|
||||
n1 <- REquipment.delete(id, collective)
|
||||
} yield n0 + n1
|
||||
store.transact(io).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
}
|
||||
})
|
||||
}
|
159
modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
Normal file
159
modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
Normal file
@ -0,0 +1,159 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import fs2.Stream
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import doobie._
|
||||
import doobie.implicits._
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.queries.{QAttachment, QItem}
|
||||
import OItem.{AttachmentData, ItemData, ListItem, Query}
|
||||
import bitpeace.{FileMeta, RangeDef}
|
||||
import docspell.common.{Direction, Ident, ItemState, MetaProposalList, Timestamp}
|
||||
import docspell.store.records.{RAttachment, RAttachmentMeta, RItem, RTagItem}
|
||||
|
||||
trait OItem[F[_]] {
|
||||
|
||||
def findItem(id: Ident, collective: Ident): F[Option[ItemData]]
|
||||
|
||||
def findItems(q: Query, maxResults: Int): F[Vector[ListItem]]
|
||||
|
||||
def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]]
|
||||
|
||||
def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult]
|
||||
|
||||
def setDirection(item: Ident, direction: Direction, collective: Ident): F[AddResult]
|
||||
|
||||
def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult]
|
||||
|
||||
def setCorrPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult]
|
||||
|
||||
def setConcPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult]
|
||||
|
||||
def setConcEquip(item: Ident, equip: Option[Ident], collective: Ident): F[AddResult]
|
||||
|
||||
def setNotes(item: Ident, notes: Option[String], collective: Ident): F[AddResult]
|
||||
|
||||
def setName(item: Ident, notes: String, collective: Ident): F[AddResult]
|
||||
|
||||
def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult]
|
||||
|
||||
def setItemDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult]
|
||||
|
||||
def setItemDueDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult]
|
||||
|
||||
def getProposals(item: Ident, collective: Ident): F[MetaProposalList]
|
||||
|
||||
def delete(itemId: Ident, collective: Ident): F[Int]
|
||||
|
||||
def findAttachmentMeta(id: Ident, collective: Ident): F[Option[RAttachmentMeta]]
|
||||
}
|
||||
|
||||
object OItem {
|
||||
|
||||
type Query = QItem.Query
|
||||
val Query = QItem.Query
|
||||
|
||||
type ListItem = QItem.ListItem
|
||||
val ListItem = QItem.ListItem
|
||||
|
||||
type ItemData = QItem.ItemData
|
||||
val ItemData = QItem.ItemData
|
||||
|
||||
case class AttachmentData[F[_]](ra: RAttachment, meta: FileMeta, data: Stream[F, Byte])
|
||||
|
||||
|
||||
def apply[F[_]: Effect](store: Store[F]): Resource[F, OItem[F]] =
|
||||
Resource.pure(new OItem[F] {
|
||||
|
||||
def findItem(id: Ident, collective: Ident): F[Option[ItemData]] =
|
||||
store.transact(QItem.findItem(id)).
|
||||
map(opt => opt.flatMap(_.filterCollective(collective)))
|
||||
|
||||
def findItems(q: Query, maxResults: Int): F[Vector[ListItem]] = {
|
||||
store.transact(QItem.findItems(q).take(maxResults.toLong)).compile.toVector
|
||||
}
|
||||
|
||||
def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]] = {
|
||||
store.transact(RAttachment.findByIdAndCollective(id, collective)).
|
||||
flatMap({
|
||||
case Some(ra) =>
|
||||
store.bitpeace.get(ra.fileId.id).unNoneTerminate.compile.last.
|
||||
map(_.map(m => AttachmentData[F](ra, m, store.bitpeace.fetchData2(RangeDef.all)(Stream.emit(m)))))
|
||||
case None =>
|
||||
(None: Option[AttachmentData[F]]).pure[F]
|
||||
})
|
||||
}
|
||||
|
||||
def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] = {
|
||||
val db = for {
|
||||
cid <- RItem.getCollective(item)
|
||||
nd <- if (cid.contains(collective)) RTagItem.deleteItemTags(item) else 0.pure[ConnectionIO]
|
||||
ni <- if (tagIds.nonEmpty && cid.contains(collective)) RTagItem.insertItemTags(item, tagIds) else 0.pure[ConnectionIO]
|
||||
} yield nd + ni
|
||||
|
||||
store.transact(db).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
}
|
||||
|
||||
def setDirection(item: Ident, direction: Direction, collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateDirection(item, collective, direction)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateCorrOrg(item, collective, org)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setCorrPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateCorrPerson(item, collective, person)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setConcPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateConcPerson(item, collective, person)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setConcEquip(item: Ident, equip: Option[Ident], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateConcEquip(item, collective, equip)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setNotes(item: Ident, notes: Option[String], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateNotes(item, collective, notes)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setName(item: Ident, name: String, collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateName(item, collective, name)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateStateForCollective(item, state, collective)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setItemDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateDate(item, collective, date)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def setItemDueDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult] =
|
||||
store.transact(RItem.updateDueDate(item, collective, date)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def delete(itemId: Ident, collective: Ident): F[Int] =
|
||||
QItem.delete(store)(itemId, collective)
|
||||
|
||||
def getProposals(item: Ident, collective: Ident): F[MetaProposalList] =
|
||||
store.transact(QAttachment.getMetaProposals(item, collective))
|
||||
|
||||
def findAttachmentMeta(id: Ident, collective: Ident): F[Option[RAttachmentMeta]] =
|
||||
store.transact(QAttachment.getAttachmentMeta(id, collective))
|
||||
})
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{ConcurrentEffect, Resource}
|
||||
import docspell.backend.ops.OJob.{CollectiveQueueState, JobCancelResult}
|
||||
import docspell.common.{Ident, JobState}
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.QJob
|
||||
import docspell.store.records.{RJob, RJobLog}
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
trait OJob[F[_]] {
|
||||
|
||||
def queueState(collective: Ident, maxResults: Int): F[CollectiveQueueState]
|
||||
|
||||
def cancelJob(id: Ident, collective: Ident): F[JobCancelResult]
|
||||
}
|
||||
|
||||
object OJob {
|
||||
|
||||
sealed trait JobCancelResult
|
||||
object JobCancelResult {
|
||||
case object Removed extends JobCancelResult
|
||||
case object CancelRequested extends JobCancelResult
|
||||
case object JobNotFound extends JobCancelResult
|
||||
}
|
||||
|
||||
case class JobDetail(job: RJob, logs: Vector[RJobLog])
|
||||
case class CollectiveQueueState(jobs: Vector[JobDetail]) {
|
||||
def queued: Vector[JobDetail] =
|
||||
jobs.filter(r => JobState.queued.contains(r.job.state))
|
||||
def done: Vector[JobDetail] =
|
||||
jobs.filter(r => JobState.done.contains(r.job.state))
|
||||
def running: Vector[JobDetail] =
|
||||
jobs.filter(_.job.state == JobState.Running)
|
||||
}
|
||||
|
||||
def apply[F[_]: ConcurrentEffect](store: Store[F], clientEC: ExecutionContext): Resource[F, OJob[F]] =
|
||||
Resource.pure(new OJob[F] {
|
||||
|
||||
def queueState(collective: Ident, maxResults: Int): F[CollectiveQueueState] = {
|
||||
store.transact(QJob.queueStateSnapshot(collective).take(maxResults.toLong)).
|
||||
map(t => JobDetail(t._1, t._2)).
|
||||
compile.toVector.
|
||||
map(CollectiveQueueState)
|
||||
}
|
||||
|
||||
def cancelJob(id: Ident, collective: Ident): F[JobCancelResult] = {
|
||||
def mustCancel(job: Option[RJob]): Option[(RJob, Ident)] =
|
||||
for {
|
||||
worker <- job.flatMap(_.worker)
|
||||
job <- job.filter(j => j.state == JobState.Scheduled || j.state == JobState.Running)
|
||||
} yield (job, worker)
|
||||
|
||||
def canDelete(j: RJob): Boolean =
|
||||
mustCancel(j.some).isEmpty
|
||||
|
||||
val tryDelete = for {
|
||||
job <- RJob.findByIdAndGroup(id, collective)
|
||||
jobm = job.filter(canDelete)
|
||||
del <- jobm.traverse(j => RJob.delete(j.id))
|
||||
} yield del match {
|
||||
case Some(n) => Right(JobCancelResult.Removed: JobCancelResult)
|
||||
case None => Left(mustCancel(job))
|
||||
}
|
||||
|
||||
def tryCancel(job: RJob, worker: Ident): F[JobCancelResult] =
|
||||
OJoex.cancelJob(job.id, worker, store, clientEC).
|
||||
map(flag => if (flag) JobCancelResult.CancelRequested else JobCancelResult.JobNotFound)
|
||||
|
||||
for {
|
||||
tryDel <- store.transact(tryDelete)
|
||||
result <- tryDel match {
|
||||
case Right(r) => r.pure[F]
|
||||
case Left(Some((job, worker))) =>
|
||||
tryCancel(job, worker)
|
||||
case Left(None) =>
|
||||
(JobCancelResult.JobNotFound: OJob.JobCancelResult).pure[F]
|
||||
}
|
||||
} yield result
|
||||
}
|
||||
})
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.ConcurrentEffect
|
||||
import docspell.common.{Ident, NodeType}
|
||||
import docspell.store.Store
|
||||
import docspell.store.records.RNode
|
||||
import org.http4s.client.blaze.BlazeClientBuilder
|
||||
import org.http4s.Method._
|
||||
import org.http4s.{Request, Uri}
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
import org.log4s._
|
||||
|
||||
object OJoex {
|
||||
private [this] val logger = getLogger
|
||||
|
||||
def notifyAll[F[_]: ConcurrentEffect](store: Store[F], clientExecutionContext: ExecutionContext): F[Unit] = {
|
||||
for {
|
||||
nodes <- store.transact(RNode.findAll(NodeType.Joex))
|
||||
_ <- nodes.toList.traverse(notifyJoex[F](clientExecutionContext))
|
||||
} yield ()
|
||||
}
|
||||
|
||||
def cancelJob[F[_]: ConcurrentEffect](jobId: Ident, worker: Ident, store: Store[F], clientEc: ExecutionContext): F[Boolean] =
|
||||
for {
|
||||
node <- store.transact(RNode.findById(worker))
|
||||
cancel <- node.traverse(joexCancel(clientEc)(_, jobId))
|
||||
} yield cancel.getOrElse(false)
|
||||
|
||||
|
||||
private def joexCancel[F[_]: ConcurrentEffect](ec: ExecutionContext)(node: RNode, job: Ident): F[Boolean] = {
|
||||
val notifyUrl = node.url/"api"/"v1"/"job"/job.id/"cancel"
|
||||
BlazeClientBuilder[F](ec).resource.use { client =>
|
||||
val req = Request[F](POST, Uri.unsafeFromString(notifyUrl.asString))
|
||||
client.expect[String](req).map(_ => true)
|
||||
}
|
||||
}
|
||||
|
||||
private def notifyJoex[F[_]: ConcurrentEffect](ec: ExecutionContext)(node: RNode): F[Unit] = {
|
||||
val notifyUrl = node.url/"api"/"v1"/"notify"
|
||||
val execute = BlazeClientBuilder[F](ec).resource.use { client =>
|
||||
val req = Request[F](POST, Uri.unsafeFromString(notifyUrl.asString))
|
||||
client.expect[String](req).map(_ => ())
|
||||
}
|
||||
execute.attempt.map {
|
||||
case Right(_) =>
|
||||
()
|
||||
case Left(_) =>
|
||||
logger.warn(s"Notifying Joex instance '${node.id.id}/${node.url.asString}' failed.")
|
||||
()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.common._
|
||||
import docspell.store._
|
||||
import docspell.store.records._
|
||||
import OOrganization._
|
||||
import docspell.store.queries.QOrganization
|
||||
|
||||
trait OOrganization[F[_]] {
|
||||
def findAllOrg(account: AccountId): F[Vector[OrgAndContacts]]
|
||||
|
||||
def findAllOrgRefs(account: AccountId): F[Vector[IdRef]]
|
||||
|
||||
def addOrg(s: OrgAndContacts): F[AddResult]
|
||||
|
||||
def updateOrg(s: OrgAndContacts): F[AddResult]
|
||||
|
||||
def findAllPerson(account: AccountId): F[Vector[PersonAndContacts]]
|
||||
|
||||
def findAllPersonRefs(account: AccountId): F[Vector[IdRef]]
|
||||
|
||||
def addPerson(s: PersonAndContacts): F[AddResult]
|
||||
|
||||
def updatePerson(s: PersonAndContacts): F[AddResult]
|
||||
|
||||
def deleteOrg(orgId: Ident, collective: Ident): F[AddResult]
|
||||
|
||||
def deletePerson(personId: Ident, collective: Ident): F[AddResult]
|
||||
}
|
||||
|
||||
object OOrganization {
|
||||
|
||||
case class OrgAndContacts(org: ROrganization, contacts: Seq[RContact])
|
||||
|
||||
case class PersonAndContacts(person: RPerson, contacts: Seq[RContact])
|
||||
|
||||
def apply[F[_] : Effect](store: Store[F]): Resource[F, OOrganization[F]] =
|
||||
Resource.pure(new OOrganization[F] {
|
||||
|
||||
def findAllOrg(account: AccountId): F[Vector[OrgAndContacts]] =
|
||||
store.transact(QOrganization.findOrgAndContact(account.collective, _.name)).
|
||||
map({ case (org, cont) => OrgAndContacts(org, cont) }).
|
||||
compile.toVector
|
||||
|
||||
def findAllOrgRefs(account: AccountId): F[Vector[IdRef]] =
|
||||
store.transact(ROrganization.findAllRef(account.collective, _.name))
|
||||
|
||||
def addOrg(s: OrgAndContacts): F[AddResult] =
|
||||
QOrganization.addOrg(s.org, s.contacts, s.org.cid)(store)
|
||||
|
||||
def updateOrg(s: OrgAndContacts): F[AddResult] =
|
||||
QOrganization.updateOrg(s.org, s.contacts, s.org.cid)(store)
|
||||
|
||||
def findAllPerson(account: AccountId): F[Vector[PersonAndContacts]] =
|
||||
store.transact(QOrganization.findPersonAndContact(account.collective, _.name)).
|
||||
map({ case (person, cont) => PersonAndContacts(person, cont) }).
|
||||
compile.toVector
|
||||
|
||||
def findAllPersonRefs(account: AccountId): F[Vector[IdRef]] =
|
||||
store.transact(RPerson.findAllRef(account.collective, _.name))
|
||||
|
||||
def addPerson(s: PersonAndContacts): F[AddResult] =
|
||||
QOrganization.addPerson(s.person, s.contacts, s.person.cid)(store)
|
||||
|
||||
def updatePerson(s: PersonAndContacts): F[AddResult] =
|
||||
QOrganization.updatePerson(s.person, s.contacts, s.person.cid)(store)
|
||||
|
||||
def deleteOrg(orgId: Ident, collective: Ident): F[AddResult] =
|
||||
store.transact(QOrganization.deleteOrg(orgId, collective)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
def deletePerson(personId: Ident, collective: Ident): F[AddResult] =
|
||||
store.transact(QOrganization.deletePerson(personId, collective)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
|
||||
})
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.common.{AccountId, Ident}
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.records.RSource
|
||||
|
||||
trait OSource[F[_]] {
|
||||
|
||||
def findAll(account: AccountId): F[Vector[RSource]]
|
||||
|
||||
def add(s: RSource): F[AddResult]
|
||||
|
||||
def update(s: RSource): F[AddResult]
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult]
|
||||
}
|
||||
|
||||
object OSource {
|
||||
|
||||
def apply[F[_]: Effect](store: Store[F]): Resource[F, OSource[F]] =
|
||||
Resource.pure(new OSource[F] {
|
||||
def findAll(account: AccountId): F[Vector[RSource]] =
|
||||
store.transact(RSource.findAll(account.collective, _.abbrev))
|
||||
|
||||
def add(s: RSource): F[AddResult] = {
|
||||
def insert = RSource.insert(s)
|
||||
def exists = RSource.existsByAbbrev(s.cid, s.abbrev)
|
||||
|
||||
val msg = s"A source with abbrev '${s.abbrev}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def update(s: RSource): F[AddResult] = {
|
||||
def insert = RSource.updateNoCounter(s)
|
||||
def exists = RSource.existsByAbbrev(s.cid, s.abbrev)
|
||||
|
||||
val msg = s"A source with abbrev '${s.abbrev}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult] =
|
||||
store.transact(RSource.delete(id, collective)).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
})
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.common.{AccountId, Ident}
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.records.{RTag, RTagItem}
|
||||
|
||||
trait OTag[F[_]] {
|
||||
|
||||
def findAll(account: AccountId): F[Vector[RTag]]
|
||||
|
||||
def add(s: RTag): F[AddResult]
|
||||
|
||||
def update(s: RTag): F[AddResult]
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult]
|
||||
}
|
||||
|
||||
|
||||
object OTag {
|
||||
|
||||
def apply[F[_]: Effect](store: Store[F]): Resource[F, OTag[F]] =
|
||||
Resource.pure(new OTag[F] {
|
||||
def findAll(account: AccountId): F[Vector[RTag]] =
|
||||
store.transact(RTag.findAll(account.collective, _.name))
|
||||
|
||||
def add(t: RTag): F[AddResult] = {
|
||||
def insert = RTag.insert(t)
|
||||
def exists = RTag.existsByName(t)
|
||||
|
||||
val msg = s"A tag '${t.name}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def update(t: RTag): F[AddResult] = {
|
||||
def insert = RTag.update(t)
|
||||
def exists = RTag.existsByName(t)
|
||||
|
||||
val msg = s"A tag '${t.name}' already exists"
|
||||
store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity))
|
||||
}
|
||||
|
||||
def delete(id: Ident, collective: Ident): F[AddResult] = {
|
||||
val io = for {
|
||||
optTag <- RTag.findByIdAndCollective(id, collective)
|
||||
n0 <- optTag.traverse(t => RTagItem.deleteTag(t.tagId))
|
||||
n1 <- optTag.traverse(t => RTag.delete(t.tagId, collective))
|
||||
} yield n0.getOrElse(0) + n1.getOrElse(0)
|
||||
store.transact(io).
|
||||
attempt.
|
||||
map(AddResult.fromUpdate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -0,0 +1,103 @@
|
||||
package docspell.backend.ops
|
||||
|
||||
import bitpeace.MimetypeHint
|
||||
import cats.implicits._
|
||||
import cats.effect.{ConcurrentEffect, Effect, Resource}
|
||||
import docspell.backend.Config
|
||||
import fs2.Stream
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.store.Store
|
||||
import docspell.store.queue.JobQueue
|
||||
import docspell.store.records.{RCollective, RJob, RSource}
|
||||
import org.log4s._
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
trait OUpload[F[_]] {
|
||||
|
||||
def submit(data: OUpload.UploadData[F], account: AccountId): F[OUpload.UploadResult]
|
||||
|
||||
def submit(data: OUpload.UploadData[F], sourceId: Ident): F[OUpload.UploadResult]
|
||||
}
|
||||
|
||||
object OUpload {
|
||||
private [this] val logger = getLogger
|
||||
|
||||
case class File[F[_]](name: Option[String], advertisedMime: Option[MimeType], data: Stream[F, Byte])
|
||||
|
||||
case class UploadMeta( direction: Option[Direction]
|
||||
, sourceAbbrev: String
|
||||
, validFileTypes: Seq[MimeType])
|
||||
|
||||
case class UploadData[F[_]]( multiple: Boolean
|
||||
, meta: UploadMeta
|
||||
, files: Vector[File[F]], priority: Priority, tracker: Option[Ident])
|
||||
|
||||
sealed trait UploadResult
|
||||
object UploadResult {
|
||||
case object Success extends UploadResult
|
||||
case object NoFiles extends UploadResult
|
||||
case object NoSource extends UploadResult
|
||||
}
|
||||
|
||||
def apply[F[_]: ConcurrentEffect](store: Store[F], queue: JobQueue[F], cfg: Config, httpClientEC: ExecutionContext): Resource[F, OUpload[F]] =
|
||||
Resource.pure(new OUpload[F] {
|
||||
|
||||
def submit(data: OUpload.UploadData[F], account: AccountId): F[OUpload.UploadResult] = {
|
||||
for {
|
||||
files <- data.files.traverse(saveFile).map(_.flatten)
|
||||
pred <- checkFileList(files)
|
||||
lang <- store.transact(RCollective.findLanguage(account.collective))
|
||||
meta = ProcessItemArgs.ProcessMeta(account.collective, lang.getOrElse(Language.German), data.meta.direction, data.meta.sourceAbbrev, data.meta.validFileTypes)
|
||||
args = if (data.multiple) files.map(f => ProcessItemArgs(meta, List(f))) else Vector(ProcessItemArgs(meta, files.toList))
|
||||
job <- pred.traverse(_ => makeJobs(args, account, data.priority, data.tracker))
|
||||
_ <- logger.fdebug(s"Storing jobs: $job")
|
||||
res <- job.traverse(submitJobs)
|
||||
_ <- store.transact(RSource.incrementCounter(data.meta.sourceAbbrev, account.collective))
|
||||
} yield res.fold(identity, identity)
|
||||
}
|
||||
|
||||
def submit(data: OUpload.UploadData[F], sourceId: Ident): F[OUpload.UploadResult] =
|
||||
for {
|
||||
sOpt <- store.transact(RSource.find(sourceId)).map(_.toRight(UploadResult.NoSource))
|
||||
abbrev = sOpt.map(_.abbrev).toOption.getOrElse(data.meta.sourceAbbrev)
|
||||
updata = data.copy(meta = data.meta.copy(sourceAbbrev = abbrev))
|
||||
accId = sOpt.map(source => AccountId(source.cid, source.sid))
|
||||
result <- accId.traverse(acc => submit(updata, acc))
|
||||
} yield result.fold(identity, identity)
|
||||
|
||||
private def submitJobs(jobs: Vector[RJob]): F[OUpload.UploadResult] = {
|
||||
for {
|
||||
_ <- logger.fdebug(s"Storing jobs: $jobs")
|
||||
_ <- queue.insertAll(jobs)
|
||||
_ <- OJoex.notifyAll(store, httpClientEC)
|
||||
} yield UploadResult.Success
|
||||
}
|
||||
|
||||
private def saveFile(file: File[F]): F[Option[ProcessItemArgs.File]] = {
|
||||
logger.finfo(s"Receiving file $file") *>
|
||||
store.bitpeace.saveNew(file.data, cfg.files.chunkSize, MimetypeHint(file.name, None), None).
|
||||
compile.lastOrError.map(fm => Ident.unsafe(fm.id)).attempt.
|
||||
map(_.fold(ex => {
|
||||
logger.warn(ex)(s"Could not store file for processing!")
|
||||
None
|
||||
}, id => Some(ProcessItemArgs.File(file.name, id))))
|
||||
}
|
||||
|
||||
private def checkFileList(files: Seq[ProcessItemArgs.File]): F[Either[UploadResult, Unit]] =
|
||||
Effect[F].pure(if (files.isEmpty) Left(UploadResult.NoFiles) else Right(()))
|
||||
|
||||
private def makeJobs(args: Vector[ProcessItemArgs], account: AccountId, prio: Priority, tracker: Option[Ident]): F[Vector[RJob]] = {
|
||||
def create(id: Ident, now: Timestamp, arg: ProcessItemArgs): RJob =
|
||||
RJob.newJob(id, ProcessItemArgs.taskName, account.collective, arg, arg.makeSubject, now, account.user, prio, tracker)
|
||||
|
||||
for {
|
||||
id <- Ident.randomId[F]
|
||||
now <- Timestamp.current[F]
|
||||
jobs = args.map(a => create(id, now, a))
|
||||
} yield jobs
|
||||
|
||||
}
|
||||
})
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package docspell.backend.signup
|
||||
|
||||
import docspell.common.{Duration, Password}
|
||||
import io.circe._
|
||||
|
||||
case class Config(mode: Config.Mode, newInvitePassword: Password, inviteTime: Duration)
|
||||
|
||||
object Config {
|
||||
sealed trait Mode { self: Product =>
|
||||
final def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
object Mode {
|
||||
|
||||
case object Open extends Mode
|
||||
|
||||
case object Invite extends Mode
|
||||
|
||||
case object Closed extends Mode
|
||||
|
||||
def fromString(str: String): Either[String, Mode] =
|
||||
str.toLowerCase match {
|
||||
case "open" => Right(Open)
|
||||
case "invite" => Right(Invite)
|
||||
case "closed" => Right(Closed)
|
||||
case _ => Left(s"Invalid signup mode: $str")
|
||||
}
|
||||
def unsafe(str: String): Mode =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
implicit val jsonEncoder: Encoder[Mode] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
implicit val jsonDecoder: Decoder[Mode] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
}
|
||||
|
||||
def open: Mode = Mode.Open
|
||||
def invite: Mode = Mode.Invite
|
||||
def closed: Mode = Mode.Closed
|
||||
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package docspell.backend.signup
|
||||
|
||||
import docspell.common.Ident
|
||||
|
||||
sealed trait NewInviteResult { self: Product =>
|
||||
|
||||
final def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
|
||||
object NewInviteResult {
|
||||
case class Success(id: Ident) extends NewInviteResult
|
||||
case object InvitationDisabled extends NewInviteResult
|
||||
case object PasswordMismatch extends NewInviteResult
|
||||
|
||||
def passwordMismatch: NewInviteResult = PasswordMismatch
|
||||
def invitationClosed: NewInviteResult = InvitationDisabled
|
||||
def success(id: Ident): NewInviteResult = Success(id)
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
package docspell.backend.signup
|
||||
|
||||
import cats.implicits._
|
||||
import cats.effect.{Effect, Resource}
|
||||
import docspell.backend.PasswordCrypt
|
||||
import docspell.backend.ops.OCollective.RegisterData
|
||||
import docspell.common._
|
||||
import docspell.store.{AddResult, Store}
|
||||
import docspell.store.records.{RCollective, RInvitation, RUser}
|
||||
import doobie.free.connection.ConnectionIO
|
||||
|
||||
trait OSignup[F[_]] {
|
||||
|
||||
def register(cfg: Config)(data: RegisterData): F[SignupResult]
|
||||
|
||||
def newInvite(cfg: Config)(password: Password): F[NewInviteResult]
|
||||
}
|
||||
|
||||
object OSignup {
|
||||
|
||||
def apply[F[_]:Effect](store: Store[F]): Resource[F, OSignup[F]] =
|
||||
Resource.pure(new OSignup[F] {
|
||||
|
||||
def newInvite(cfg: Config)(password: Password): F[NewInviteResult] = {
|
||||
if (cfg.mode == Config.Mode.Invite) {
|
||||
if (cfg.newInvitePassword.isEmpty || cfg.newInvitePassword != password) NewInviteResult.passwordMismatch.pure[F]
|
||||
else store.transact(RInvitation.insertNew).map(ri => NewInviteResult.success(ri.id))
|
||||
} else {
|
||||
Effect[F].pure(NewInviteResult.invitationClosed)
|
||||
}
|
||||
}
|
||||
|
||||
def register(cfg: Config)(data: RegisterData): F[SignupResult] = {
|
||||
cfg.mode match {
|
||||
case Config.Mode.Open =>
|
||||
addUser(data).map(SignupResult.fromAddResult)
|
||||
|
||||
case Config.Mode.Closed =>
|
||||
SignupResult.signupClosed.pure[F]
|
||||
|
||||
case Config.Mode.Invite =>
|
||||
data.invite match {
|
||||
case Some(inv) =>
|
||||
for {
|
||||
now <- Timestamp.current[F]
|
||||
min = now.minus(cfg.inviteTime)
|
||||
ok <- store.transact(RInvitation.useInvite(inv, min))
|
||||
res <- if (ok) addUser(data).map(SignupResult.fromAddResult)
|
||||
else SignupResult.invalidInvitationKey.pure[F]
|
||||
} yield res
|
||||
case None =>
|
||||
SignupResult.invalidInvitationKey.pure[F]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def addUser(data: RegisterData): F[AddResult] = {
|
||||
def toRecords: F[(RCollective, RUser)] =
|
||||
for {
|
||||
id2 <- Ident.randomId[F]
|
||||
now <- Timestamp.current[F]
|
||||
c = RCollective(data.collName, CollectiveState.Active, Language.German, now)
|
||||
u = RUser(id2, data.login, data.collName, PasswordCrypt.crypt(data.password), UserState.Active, None, 0, None, now)
|
||||
} yield (c, u)
|
||||
|
||||
def insert(coll: RCollective, user: RUser): ConnectionIO[Int] = {
|
||||
for {
|
||||
n1 <- RCollective.insert(coll)
|
||||
n2 <- RUser.insert(user)
|
||||
} yield n1 + n2
|
||||
}
|
||||
|
||||
def collectiveExists: ConnectionIO[Boolean] =
|
||||
RCollective.existsById(data.collName)
|
||||
|
||||
val msg = s"The collective '${data.collName}' already exists."
|
||||
for {
|
||||
cu <- toRecords
|
||||
save <- store.add(insert(cu._1, cu._2), collectiveExists)
|
||||
} yield save.fold(identity, _.withMsg(msg), identity)
|
||||
}
|
||||
})
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package docspell.backend.signup
|
||||
|
||||
import docspell.store.AddResult
|
||||
|
||||
sealed trait SignupResult {
|
||||
|
||||
}
|
||||
|
||||
object SignupResult {
|
||||
|
||||
case object CollectiveExists extends SignupResult
|
||||
case object InvalidInvitationKey extends SignupResult
|
||||
case object SignupClosed extends SignupResult
|
||||
case class Failure(ex: Throwable) extends SignupResult
|
||||
case object Success extends SignupResult
|
||||
|
||||
def collectiveExists: SignupResult = CollectiveExists
|
||||
def invalidInvitationKey: SignupResult = InvalidInvitationKey
|
||||
def signupClosed: SignupResult = SignupClosed
|
||||
def failure(ex: Throwable): SignupResult = Failure(ex)
|
||||
def success: SignupResult = Success
|
||||
|
||||
def fromAddResult(ar: AddResult): SignupResult = ar match {
|
||||
case AddResult.Success => Success
|
||||
case AddResult.Failure(ex) => Failure(ex)
|
||||
case AddResult.EntityExists(_) => CollectiveExists
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user