Allow to manage passwords for a collective

This commit is contained in:
eikek 2021-09-30 10:35:38 +02:00
parent 3c93b63c8a
commit f74624485f
11 changed files with 190 additions and 9 deletions

View File

@ -19,6 +19,7 @@ services:
image: docspell/joex:latest image: docspell/joex:latest
container_name: docspell-joex container_name: docspell-joex
command: command:
- -J-Xmx3G
- /opt/docspell.conf - /opt/docspell.conf
restart: unless-stopped restart: unless-stopped
env_file: ./.env env_file: ./.env

View File

@ -63,6 +63,12 @@ trait OCollective[F[_]] {
def findEnabledSource(sourceId: Ident): F[Option[RSource]] 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 startLearnClassifier(collective: Ident): F[Unit]
def startEmptyTrash(args: EmptyTrashArgs): F[Unit] def startEmptyTrash(args: EmptyTrashArgs): F[Unit]
@ -149,7 +155,7 @@ object OCollective {
private def updateLearnClassifierTask(coll: Ident, sett: Settings): F[Unit] = private def updateLearnClassifierTask(coll: Ident, sett: Settings): F[Unit] =
for { for {
id <- Ident.randomId[F] id <- Ident.randomId[F]
on = sett.classifier.map(_.enabled).getOrElse(false) on = sett.classifier.exists(_.enabled)
timer = sett.classifier.map(_.schedule).getOrElse(CalEvent.unsafe("")) timer = sett.classifier.map(_.schedule).getOrElse(CalEvent.unsafe(""))
args = LearnClassifierArgs(coll) args = LearnClassifierArgs(coll)
ut = UserTask( ut = UserTask(
@ -174,6 +180,18 @@ object OCollective {
_ <- joex.notifyAllNodes _ <- joex.notifyAllNodes
} yield () } 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] = def startLearnClassifier(collective: Ident): F[Unit] =
for { for {
id <- Ident.randomId[F] id <- Ident.randomId[F]

View File

@ -5635,6 +5635,7 @@ components:
- integrationEnabled - integrationEnabled
- classifier - classifier
- emptyTrash - emptyTrash
- passwords
properties: properties:
language: language:
type: string type: string
@ -5648,6 +5649,11 @@ components:
$ref: "#/components/schemas/ClassifierSetting" $ref: "#/components/schemas/ClassifierSetting"
emptyTrash: emptyTrash:
$ref: "#/components/schemas/EmptyTrashSetting" $ref: "#/components/schemas/EmptyTrashSetting"
passwords:
type: array
items:
type: string
format: password
EmptyTrashSetting: EmptyTrashSetting:
description: | description: |

View File

@ -12,8 +12,7 @@ import cats.implicits._
import docspell.backend.BackendApp import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken import docspell.backend.auth.AuthToken
import docspell.backend.ops.OCollective import docspell.backend.ops.OCollective
import docspell.common.EmptyTrashArgs import docspell.common._
import docspell.common.ListType
import docspell.restapi.model._ import docspell.restapi.model._
import docspell.restserver.conv.Conversions import docspell.restserver.conv.Conversions
import docspell.restserver.http4s._ import docspell.restserver.http4s._
@ -62,7 +61,8 @@ object CollectiveRoutes {
settings.emptyTrash.schedule, settings.emptyTrash.schedule,
settings.emptyTrash.minAge settings.emptyTrash.minAge
) )
) ),
settings.passwords.map(Password.apply)
) )
res <- res <-
backend.collective backend.collective
@ -89,7 +89,8 @@ object CollectiveRoutes {
EmptyTrashSetting( EmptyTrashSetting(
trash.schedule, trash.schedule,
trash.minAge trash.minAge
) ),
settDb.map(_.passwords).getOrElse(Nil).map(_.pass)
) )
) )
resp <- sett.toResponse() resp <- sett.toResponse()

View File

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

View File

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

View File

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

View File

@ -89,7 +89,8 @@ object RCollective {
case None => case None =>
REmptyTrashSetting.delete(cid) 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 // 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 // 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) prev <- OptionT.fromOption[ConnectionIO](sett.classifier)
cats <- OptionT.liftF(RTag.listCategories(coll)) cats <- OptionT.liftF(RTag.listCategories(coll))
next = prev.copy(categories = prev.categories.intersect(cats)) 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]] = { private def getRawSettings(coll: Ident): ConnectionIO[Option[Settings]] = {
import RClassifierSetting.stringListMeta import RClassifierSetting.stringListMeta
val c = RCollective.as("c") val c = RCollective.as("c")
val cs = RClassifierSetting.as("cs") val cs = RClassifierSetting.as("cs")
val es = REmptyTrashSetting.as("es") val es = REmptyTrashSetting.as("es")
@ -116,7 +119,8 @@ object RCollective {
cs.categories.s, cs.categories.s,
cs.listType.s, cs.listType.s,
es.schedule.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), from(c).leftJoin(cs, cs.cid === c.id).leftJoin(es, es.cid === c.id),
c.id === coll c.id === coll
@ -170,7 +174,11 @@ object RCollective {
language: Language, language: Language,
integrationEnabled: Boolean, integrationEnabled: Boolean,
classifier: Option[RClassifierSetting.Classifier], 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])
} }

View File

@ -0,0 +1,79 @@
package docspell.store.records
import cats.data.NonEmptyList
import docspell.common._
import docspell.store.qb._
import docspell.store.qb.DSL._
import doobie._
import doobie.implicits._
import cats.effect._
import cats.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
}

View File

@ -22,6 +22,7 @@ import Comp.ClassifierSettingsForm
import Comp.Dropdown import Comp.Dropdown
import Comp.EmptyTrashForm import Comp.EmptyTrashForm
import Comp.MenuBar as MB import Comp.MenuBar as MB
import Comp.StringListInput
import Data.DropdownStyle as DS import Data.DropdownStyle as DS
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
import Data.Language exposing (Language) import Data.Language exposing (Language)
@ -30,6 +31,7 @@ import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Html.Events exposing (onCheck, onClick, onInput) import Html.Events exposing (onCheck, onClick, onInput)
import Http import Http
import Markdown
import Messages.Comp.CollectiveSettingsForm exposing (Texts) import Messages.Comp.CollectiveSettingsForm exposing (Texts)
import Styles as S import Styles as S
@ -44,6 +46,8 @@ type alias Model =
, startClassifierResult : ClassifierResult , startClassifierResult : ClassifierResult
, emptyTrashModel : Comp.EmptyTrashForm.Model , emptyTrashModel : Comp.EmptyTrashForm.Model
, startEmptyTrashResult : EmptyTrashResult , startEmptyTrashResult : EmptyTrashResult
, passwordModel : Comp.StringListInput.Model
, passwords : List String
} }
@ -96,6 +100,8 @@ init flags settings =
, startClassifierResult = ClassifierResultInitial , startClassifierResult = ClassifierResultInitial
, emptyTrashModel = em , emptyTrashModel = em
, startEmptyTrashResult = EmptyTrashResultInitial , startEmptyTrashResult = EmptyTrashResultInitial
, passwordModel = Comp.StringListInput.init
, passwords = settings.passwords
} }
, Cmd.batch [ Cmd.map ClassifierSettingMsg cc, Cmd.map EmptyTrashMsg ec ] , Cmd.batch [ Cmd.map ClassifierSettingMsg cc, Cmd.map EmptyTrashMsg ec ]
) )
@ -114,6 +120,7 @@ getSettings model =
, integrationEnabled = model.intEnabled , integrationEnabled = model.intEnabled
, classifier = cls , classifier = cls
, emptyTrash = trash , emptyTrash = trash
, passwords = model.passwords
} }
) )
(Comp.ClassifierSettingsForm.getSettings model.classifierModel) (Comp.ClassifierSettingsForm.getSettings model.classifierModel)
@ -133,6 +140,7 @@ type Msg
| StartEmptyTrashTask | StartEmptyTrashTask
| StartClassifierResp (Result Http.Error BasicResult) | StartClassifierResp (Result Http.Error BasicResult)
| StartEmptyTrashResp (Result Http.Error BasicResult) | StartEmptyTrashResp (Result Http.Error BasicResult)
| PasswordMsg Comp.StringListInput.Msg
update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings ) update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings )
@ -285,6 +293,27 @@ update flags msg model =
, Nothing , Nothing
) )
PasswordMsg lm ->
let
( pm, action ) =
Comp.StringListInput.update lm model.passwordModel
pws =
case action of
Comp.StringListInput.AddAction pw ->
pw :: model.passwords
Comp.StringListInput.RemoveAction pw ->
List.filter (\e -> e /= pw) model.passwords
Comp.StringListInput.NoAction ->
model.passwords
in
( { model | passwordModel = pm, passwords = pws }
, Cmd.none
, Nothing
)
--- View2 --- View2
@ -460,6 +489,18 @@ view2 flags texts settings model =
] ]
] ]
] ]
, div []
[ h2 [ class S.header2 ]
[ text texts.passwords
]
, div [ class "mb-4" ]
[ div [ class "opacity-50 text-sm" ]
[ Markdown.toHtml [] texts.passwordsInfo
]
, Html.map PasswordMsg
(Comp.StringListInput.view2 model.passwords model.passwordModel)
]
]
] ]

View File

@ -44,6 +44,8 @@ type alias Texts =
, fulltextReindexSubmitted : String , fulltextReindexSubmitted : String
, fulltextReindexOkMissing : String , fulltextReindexOkMissing : String
, emptyTrash : String , emptyTrash : String
, passwords : String
, passwordsInfo : String
} }
@ -77,6 +79,8 @@ gb =
, fulltextReindexOkMissing = , fulltextReindexOkMissing =
"Please type OK in the field if you really want to start re-indexing your data." "Please type OK in the field if you really want to start re-indexing your data."
, emptyTrash = "Empty Trash" , emptyTrash = "Empty Trash"
, passwords = "Passwords"
, passwordsInfo = "These passwords are used when encrypted PDFs are being processed. Please note, that they are stored in the database as **plain text**!"
} }
@ -110,4 +114,6 @@ de =
, fulltextReindexOkMissing = , fulltextReindexOkMissing =
"Bitte tippe OK in das Feld ein, wenn Du wirklich den Index neu erzeugen möchtest." "Bitte tippe OK in das Feld ein, wenn Du wirklich den Index neu erzeugen möchtest."
, emptyTrash = "Papierkorb löschen" , emptyTrash = "Papierkorb löschen"
, passwords = "Passwörter"
, passwordsInfo = "Diese Passwörter werden zum Lesen von verschlüsselten PDFs verwendet. Diese Passwörter werden in der Datanbank **in Klartext** gespeichert!"
} }