From 8df235e9db83be808c2e4796d5d5c735f208176c Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 6 Sep 2021 21:54:30 +0200 Subject: [PATCH] Delete the user along its data --- .../docspell/backend/ops/OCollective.scala | 20 ++- .../restserver/routes/UserRoutes.scala | 8 ++ .../scala/docspell/store/queries/QUser.scala | 131 ++++++++++++++++++ .../store/records/RFolderMember.scala | 3 + .../docspell/store/records/RRememberMe.scala | 2 +- .../scala/docspell/store/records/RUser.scala | 3 + 6 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/queries/QUser.scala 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 3e93217b..b2479e3e 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -15,7 +15,7 @@ import docspell.backend.PasswordCrypt import docspell.backend.ops.OCollective._ import docspell.common._ import docspell.store.UpdateResult -import docspell.store.queries.QCollective +import docspell.store.queries.{QCollective, QUser} import docspell.store.queue.JobQueue import docspell.store.records._ import docspell.store.usertask.{UserTask, UserTaskScope, UserTaskStore} @@ -37,7 +37,11 @@ trait OCollective[F[_]] { def update(s: RUser): F[AddResult] - def deleteUser(login: Ident, collective: Ident): F[AddResult] + /** Deletes the user and all its data. */ + def deleteUser(login: Ident, collective: Ident): F[UpdateResult] + + /** Return an excerpt of what would be deleted, when the user is deleted. */ + def getDeleteUserData(accountId: AccountId): F[DeleteUserData] def insights(collective: Ident): F[InsightData] @@ -91,6 +95,9 @@ object OCollective { type EmptyTrash = REmptyTrashSetting.EmptyTrash val EmptyTrash = REmptyTrashSetting.EmptyTrash + type DeleteUserData = QUser.UserData + val DeleteUserData = QUser.UserData + sealed trait PassResetResult object PassResetResult { case class Success(newPw: Password) extends PassResetResult @@ -215,8 +222,13 @@ object OCollective { 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 getDeleteUserData(accountId: AccountId): F[DeleteUserData] = + store.transact(QUser.getUserData(accountId)) + + def deleteUser(login: Ident, collective: Ident): F[UpdateResult] = + UpdateResult.fromUpdate( + store.transact(QUser.deleteUserAndData(AccountId(collective, login))) + ) def insights(collective: Ident): F[InsightData] = store.transact(QCollective.getInsights(collective)) diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala index 756829b1..cfb0cf36 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala @@ -66,6 +66,14 @@ object UserRoutes { ar <- backend.collective.deleteUser(id, user.account.collective) resp <- Ok(basicResult(ar, "User deleted.")) } yield resp + + case GET -> Root / Ident(username) / "deleteData" => + for { + data <- backend.collective.getDeleteUserData( + AccountId(user.account.collective, username) + ) + resp <- Ok(DeleteUserData(data.ownedFolders.map(_.id), data.sentMails)) + } yield resp } } diff --git a/modules/store/src/main/scala/docspell/store/queries/QUser.scala b/modules/store/src/main/scala/docspell/store/queries/QUser.scala new file mode 100644 index 00000000..a666fe8f --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QUser.scala @@ -0,0 +1,131 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.store.queries + +import cats.implicits._ + +import docspell.common._ +import docspell.store.qb.DML +import docspell.store.qb.DSL._ +import docspell.store.records._ + +import doobie._ + +object QUser { + private val logger = Logger.log4s[ConnectionIO](org.log4s.getLogger) + + final case class UserData( + ownedFolders: List[Ident], + sentMails: Int + ) + + def getUserData(accountId: AccountId): ConnectionIO[UserData] = { + val folder = RFolder.as("f") + val mail = RSentMail.as("m") + val mitem = RSentMailItem.as("mi") + val user = RUser.as("u") + + for { + uid <- loadUserId(accountId).map(_.getOrElse(Ident.unsafe(""))) + folders <- run( + select(folder.name), + from(folder), + folder.owner === uid && folder.collective === accountId.collective + ).query[Ident].to[List] + mails <- run( + select(count(mail.id)), + from(mail) + .innerJoin(mitem, mail.id === mitem.sentMailId) + .innerJoin(user, user.uid === mail.uid), + user.login === accountId.user && user.cid === accountId.collective + ).query[Int].unique + } yield UserData(folders, mails) + } + + def deleteUserAndData(accountId: AccountId): ConnectionIO[Int] = + for { + uid <- loadUserId(accountId).map(_.getOrElse(Ident.unsafe(""))) + _ <- logger.info(s"Remove user ${accountId.asString} (uid=${uid.id})") + + n1 <- deleteUserFolders(uid) + + n2 <- deleteUserSentMails(uid) + _ <- logger.info(s"Removed $n2 sent mails") + + n3 <- deleteRememberMe(accountId) + _ <- logger.info(s"Removed $n3 remember me tokens") + + n4 <- deleteTotp(uid) + _ <- logger.info(s"Removed $n4 totp secrets") + + n5 <- deleteMailSettings(uid) + _ <- logger.info(s"Removed $n5 mail settings") + + nu <- RUser.deleteById(uid) + } yield nu + n1 + n2 + n3 + n4 + n5 + + def deleteUserFolders(uid: Ident): ConnectionIO[Int] = { + val folder = RFolder.as("f") + val member = RFolderMember.as("fm") + for { + folders <- run( + select(folder.id), + from(folder), + folder.owner === uid + ).query[Ident].to[List] + _ <- logger.info(s"Removing folders: ${folders.map(_.id)}") + + ri <- folders.traverse(RItem.removeFolder) + _ <- logger.info(s"Removed folders from items: $ri") + rs <- folders.traverse(RSource.removeFolder) + _ <- logger.info(s"Removed folders from sources: $rs") + rf <- folders.traverse(RFolderMember.deleteAll) + _ <- logger.info(s"Removed folders from members: $rf") + + n1 <- DML.delete(member, member.user === uid) + _ <- logger.info(s"Removed $n1 members for owning folders.") + n2 <- DML.delete(folder, folder.owner === uid) + _ <- logger.info(s"Removed $n2 folders.") + + } yield n1 + n2 + ri.sum + rs.sum + rf.sum + } + + def deleteUserSentMails(uid: Ident): ConnectionIO[Int] = { + val mail = RSentMail.as("m") + for { + ids <- run(select(mail.id), from(mail), mail.uid === uid).query[Ident].to[List] + n1 <- ids.traverse(RSentMailItem.deleteMail) + n2 <- ids.traverse(RSentMail.delete) + } yield n1.sum + n2.sum + } + + def deleteRememberMe(id: AccountId): ConnectionIO[Int] = + DML.delete( + RRememberMe.T, + RRememberMe.T.cid === id.collective && RRememberMe.T.username === id.user + ) + + def deleteTotp(uid: Ident): ConnectionIO[Int] = + DML.delete(RTotp.T, RTotp.T.userId === uid) + + def deleteMailSettings(uid: Ident): ConnectionIO[Int] = { + val smtp = RUserEmail.as("ms") + val imap = RUserImap.as("mi") + for { + n1 <- DML.delete(smtp, smtp.uid === uid) + n2 <- DML.delete(imap, imap.uid === uid) + } yield n1 + n2 + } + + private def loadUserId(id: AccountId): ConnectionIO[Option[Ident]] = + run( + select(RUser.T.uid), + from(RUser.T), + RUser.T.cid === id.collective && RUser.T.login === id.user + ).query[Ident].option + +} diff --git a/modules/store/src/main/scala/docspell/store/records/RFolderMember.scala b/modules/store/src/main/scala/docspell/store/records/RFolderMember.scala index 9452b4f4..71577715 100644 --- a/modules/store/src/main/scala/docspell/store/records/RFolderMember.scala +++ b/modules/store/src/main/scala/docspell/store/records/RFolderMember.scala @@ -64,4 +64,7 @@ object RFolderMember { def deleteAll(folderId: Ident): ConnectionIO[Int] = DML.delete(T, T.folder === folderId) + + def deleteMemberships(userId: Ident): ConnectionIO[Int] = + DML.delete(T, T.user === userId) } diff --git a/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala b/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala index 8938d06c..e9480655 100644 --- a/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala +++ b/modules/store/src/main/scala/docspell/store/records/RRememberMe.scala @@ -31,7 +31,7 @@ object RRememberMe { val all = NonEmptyList.of[Column[_]](id, cid, username, created, uses) } - private val T = Table(None) + val T = Table(None) def as(alias: String): Table = Table(Some(alias)) diff --git a/modules/store/src/main/scala/docspell/store/records/RUser.scala b/modules/store/src/main/scala/docspell/store/records/RUser.scala index 57b23ad8..a5e34779 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUser.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUser.scala @@ -168,4 +168,7 @@ object RUser { val t = Table(None) DML.delete(t, t.cid === coll && t.login === user) } + + def deleteById(uid: Ident): ConnectionIO[Int] = + DML.delete(T, T.uid === uid) }