From f74624485f498a6c2d82cbf1dcad29cbde68386b Mon Sep 17 00:00:00 2001 From: eikek Date: Thu, 30 Sep 2021 10:35:38 +0200 Subject: [PATCH] Allow to manage passwords for a collective --- docker/docker-compose/docker-compose.yml | 1 + .../docspell/backend/ops/OCollective.scala | 20 ++++- .../src/main/resources/docspell-openapi.yml | 6 ++ .../restserver/routes/CollectiveRoutes.scala | 9 ++- .../h2/V1.27.0__collective_passwords.sql | 7 ++ .../mariadb/V1.27.0__collective_passwords.sql | 7 ++ .../V1.27.0__collective_passwords.sql | 7 ++ .../docspell/store/records/RCollective.scala | 16 +++- .../store/records/RCollectivePassword.scala | 79 +++++++++++++++++++ .../main/elm/Comp/CollectiveSettingsForm.elm | 41 ++++++++++ .../Messages/Comp/CollectiveSettingsForm.elm | 6 ++ 11 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 modules/store/src/main/resources/db/migration/h2/V1.27.0__collective_passwords.sql create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.27.0__collective_passwords.sql create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.27.0__collective_passwords.sql create mode 100644 modules/store/src/main/scala/docspell/store/records/RCollectivePassword.scala diff --git a/docker/docker-compose/docker-compose.yml b/docker/docker-compose/docker-compose.yml index 18712b4c..d63ed86d 100644 --- a/docker/docker-compose/docker-compose.yml +++ b/docker/docker-compose/docker-compose.yml @@ -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 diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index 1b2825fe..907bfcef 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -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] diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 4c6b31ee..c5e95d29 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -5635,6 +5635,7 @@ components: - integrationEnabled - classifier - emptyTrash + - passwords properties: language: type: string @@ -5648,6 +5649,11 @@ components: $ref: "#/components/schemas/ClassifierSetting" emptyTrash: $ref: "#/components/schemas/EmptyTrashSetting" + passwords: + type: array + items: + type: string + format: password EmptyTrashSetting: description: | diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala index 75690d64..f1cd03d8 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala @@ -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() diff --git a/modules/store/src/main/resources/db/migration/h2/V1.27.0__collective_passwords.sql b/modules/store/src/main/resources/db/migration/h2/V1.27.0__collective_passwords.sql new file mode 100644 index 00000000..223fa8d5 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.27.0__collective_passwords.sql @@ -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 +) diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.27.0__collective_passwords.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.27.0__collective_passwords.sql new file mode 100644 index 00000000..2224e560 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.27.0__collective_passwords.sql @@ -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 +) diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.27.0__collective_passwords.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.27.0__collective_passwords.sql new file mode 100644 index 00000000..223fa8d5 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.27.0__collective_passwords.sql @@ -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 +) diff --git a/modules/store/src/main/scala/docspell/store/records/RCollective.scala b/modules/store/src/main/scala/docspell/store/records/RCollective.scala index dd0afce2..906277c6 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCollective.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCollective.scala @@ -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]) + } diff --git a/modules/store/src/main/scala/docspell/store/records/RCollectivePassword.scala b/modules/store/src/main/scala/docspell/store/records/RCollectivePassword.scala new file mode 100644 index 00000000..53726572 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RCollectivePassword.scala @@ -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 +} diff --git a/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm index fee15790..f7994b4a 100644 --- a/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm @@ -22,6 +22,7 @@ import Comp.ClassifierSettingsForm import Comp.Dropdown import Comp.EmptyTrashForm import Comp.MenuBar as MB +import Comp.StringListInput import Data.DropdownStyle as DS import Data.Flags exposing (Flags) import Data.Language exposing (Language) @@ -30,6 +31,7 @@ import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onCheck, onClick, onInput) import Http +import Markdown import Messages.Comp.CollectiveSettingsForm exposing (Texts) import Styles as S @@ -44,6 +46,8 @@ type alias Model = , startClassifierResult : ClassifierResult , emptyTrashModel : Comp.EmptyTrashForm.Model , startEmptyTrashResult : EmptyTrashResult + , passwordModel : Comp.StringListInput.Model + , passwords : List String } @@ -96,6 +100,8 @@ init flags settings = , startClassifierResult = ClassifierResultInitial , emptyTrashModel = em , startEmptyTrashResult = EmptyTrashResultInitial + , passwordModel = Comp.StringListInput.init + , passwords = settings.passwords } , Cmd.batch [ Cmd.map ClassifierSettingMsg cc, Cmd.map EmptyTrashMsg ec ] ) @@ -114,6 +120,7 @@ getSettings model = , integrationEnabled = model.intEnabled , classifier = cls , emptyTrash = trash + , passwords = model.passwords } ) (Comp.ClassifierSettingsForm.getSettings model.classifierModel) @@ -133,6 +140,7 @@ type Msg | StartEmptyTrashTask | StartClassifierResp (Result Http.Error BasicResult) | StartEmptyTrashResp (Result Http.Error BasicResult) + | PasswordMsg Comp.StringListInput.Msg update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings ) @@ -285,6 +293,27 @@ update flags msg model = , 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 @@ -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) + ] + ] ] diff --git a/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm b/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm index 79de30cc..ea6248f1 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm @@ -44,6 +44,8 @@ type alias Texts = , fulltextReindexSubmitted : String , fulltextReindexOkMissing : String , emptyTrash : String + , passwords : String + , passwordsInfo : String } @@ -77,6 +79,8 @@ gb = , fulltextReindexOkMissing = "Please type OK in the field if you really want to start re-indexing your data." , 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 = "Bitte tippe OK in das Feld ein, wenn Du wirklich den Index neu erzeugen möchtest." , 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!" }