mirror of
				https://github.com/TheAnachronism/docspell.git
				synced 2025-11-03 18:00:11 +00:00 
			
		
		
		
	Add routes for storing/retrieving client settings
This commit is contained in:
		@@ -38,6 +38,7 @@ trait BackendApp[F[_]] {
 | 
			
		||||
  def folder: OFolder[F]
 | 
			
		||||
  def customFields: OCustomFields[F]
 | 
			
		||||
  def simpleSearch: OSimpleSearch[F]
 | 
			
		||||
  def clientSettings: OClientSettings[F]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
object BackendApp {
 | 
			
		||||
@@ -73,26 +74,28 @@ object BackendApp {
 | 
			
		||||
      folderImpl       <- OFolder(store)
 | 
			
		||||
      customFieldsImpl <- OCustomFields(store)
 | 
			
		||||
      simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl)
 | 
			
		||||
      clientSettingsImpl <- OClientSettings(store)
 | 
			
		||||
    } yield new BackendApp[F] {
 | 
			
		||||
      val login        = loginImpl
 | 
			
		||||
      val signup       = signupImpl
 | 
			
		||||
      val collective   = 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
 | 
			
		||||
      val itemSearch   = itemSearchImpl
 | 
			
		||||
      val fulltext     = fulltextImpl
 | 
			
		||||
      val mail         = mailImpl
 | 
			
		||||
      val joex         = joexImpl
 | 
			
		||||
      val userTask     = userTaskImpl
 | 
			
		||||
      val folder       = folderImpl
 | 
			
		||||
      val customFields = customFieldsImpl
 | 
			
		||||
      val simpleSearch = simpleSearchImpl
 | 
			
		||||
      val login          = loginImpl
 | 
			
		||||
      val signup         = signupImpl
 | 
			
		||||
      val collective     = 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
 | 
			
		||||
      val itemSearch     = itemSearchImpl
 | 
			
		||||
      val fulltext       = fulltextImpl
 | 
			
		||||
      val mail           = mailImpl
 | 
			
		||||
      val joex           = joexImpl
 | 
			
		||||
      val userTask       = userTaskImpl
 | 
			
		||||
      val folder         = folderImpl
 | 
			
		||||
      val customFields   = customFieldsImpl
 | 
			
		||||
      val simpleSearch   = simpleSearchImpl
 | 
			
		||||
      val clientSettings = clientSettingsImpl
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  def apply[F[_]: ConcurrentEffect: ContextShift](
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,78 @@
 | 
			
		||||
package docspell.backend.ops
 | 
			
		||||
 | 
			
		||||
import cats.data.OptionT
 | 
			
		||||
import cats.effect.{Effect, Resource}
 | 
			
		||||
import cats.implicits._
 | 
			
		||||
 | 
			
		||||
import docspell.common.AccountId
 | 
			
		||||
import docspell.common._
 | 
			
		||||
import docspell.common.syntax.all._
 | 
			
		||||
import docspell.store.Store
 | 
			
		||||
import docspell.store.records.RClientSettings
 | 
			
		||||
import docspell.store.records.RUser
 | 
			
		||||
 | 
			
		||||
import io.circe.Json
 | 
			
		||||
import org.log4s._
 | 
			
		||||
 | 
			
		||||
trait OClientSettings[F[_]] {
 | 
			
		||||
 | 
			
		||||
  def delete(clientId: Ident, account: AccountId): F[Boolean]
 | 
			
		||||
  def save(clientId: Ident, account: AccountId, data: Json): F[Unit]
 | 
			
		||||
  def load(clientId: Ident, account: AccountId): F[Option[RClientSettings]]
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
object OClientSettings {
 | 
			
		||||
  private[this] val logger = getLogger
 | 
			
		||||
 | 
			
		||||
  def apply[F[_]: Effect](store: Store[F]): Resource[F, OClientSettings[F]] =
 | 
			
		||||
    Resource.pure[F, OClientSettings[F]](new OClientSettings[F] {
 | 
			
		||||
 | 
			
		||||
      private def getUserId(account: AccountId): OptionT[F, Ident] =
 | 
			
		||||
        OptionT(store.transact(RUser.findByAccount(account))).map(_.uid)
 | 
			
		||||
 | 
			
		||||
      def delete(clientId: Ident, account: AccountId): F[Boolean] =
 | 
			
		||||
        (for {
 | 
			
		||||
          _ <- OptionT.liftF(
 | 
			
		||||
            logger.fdebug(
 | 
			
		||||
              s"Deleting client settings for client ${clientId.id} and account $account"
 | 
			
		||||
            )
 | 
			
		||||
          )
 | 
			
		||||
          userId <- getUserId(account)
 | 
			
		||||
          n <- OptionT.liftF(
 | 
			
		||||
            store.transact(
 | 
			
		||||
              RClientSettings.delete(clientId, userId)
 | 
			
		||||
            )
 | 
			
		||||
          )
 | 
			
		||||
        } yield n > 0).getOrElse(false)
 | 
			
		||||
 | 
			
		||||
      def save(clientId: Ident, account: AccountId, data: Json): F[Unit] =
 | 
			
		||||
        (for {
 | 
			
		||||
          _ <- OptionT.liftF(
 | 
			
		||||
            logger.fdebug(
 | 
			
		||||
              s"Storing client settings for client ${clientId.id} and account $account"
 | 
			
		||||
            )
 | 
			
		||||
          )
 | 
			
		||||
          userId <- getUserId(account)
 | 
			
		||||
          n <- OptionT.liftF(
 | 
			
		||||
            store.transact(RClientSettings.upsert(clientId, userId, data))
 | 
			
		||||
          )
 | 
			
		||||
          _ <- OptionT.liftF(
 | 
			
		||||
            if (n <= 0) Effect[F].raiseError(new Exception("No rows updated!"))
 | 
			
		||||
            else ().pure[F]
 | 
			
		||||
          )
 | 
			
		||||
        } yield ()).getOrElse(())
 | 
			
		||||
 | 
			
		||||
      def load(clientId: Ident, account: AccountId): F[Option[RClientSettings]] =
 | 
			
		||||
        (for {
 | 
			
		||||
          _ <- OptionT.liftF(
 | 
			
		||||
            logger.fdebug(
 | 
			
		||||
              s"Loading client settings for client ${clientId.id} and account $account"
 | 
			
		||||
            )
 | 
			
		||||
          )
 | 
			
		||||
          userId <- getUserId(account)
 | 
			
		||||
          data   <- OptionT(store.transact(RClientSettings.find(clientId, userId)))
 | 
			
		||||
        } yield data).value
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
@@ -1185,6 +1185,68 @@ paths:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: "#/components/schemas/BasicResult"
 | 
			
		||||
 | 
			
		||||
  /sec/clientSettings/{clientId}:
 | 
			
		||||
    parameters:
 | 
			
		||||
      - $ref: "#/components/parameters/clientId"
 | 
			
		||||
    get:
 | 
			
		||||
      tags: [ Client Settings ]
 | 
			
		||||
      summary: Return the current user settings
 | 
			
		||||
      description: |
 | 
			
		||||
        Returns the settings for the current user. The `clientId` is
 | 
			
		||||
        an identifier to a client application. It returns a JSON
 | 
			
		||||
        structure. The server doesn't care about the actual data,
 | 
			
		||||
        since it is meant to be interpreted by clients.
 | 
			
		||||
      security:
 | 
			
		||||
        - authTokenHeader: []
 | 
			
		||||
      responses:
 | 
			
		||||
        200:
 | 
			
		||||
          description: Ok
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema: {}
 | 
			
		||||
    put:
 | 
			
		||||
      tags: [ Client Settings ]
 | 
			
		||||
      summary: Update current user settings
 | 
			
		||||
      description: |
 | 
			
		||||
        Updates (replaces or creates) the current user's settings with
 | 
			
		||||
        the given data. The `clientId` is an identifier to a client
 | 
			
		||||
        application. The request body is expected to be JSON, the
 | 
			
		||||
        structure is not important to the server.
 | 
			
		||||
 | 
			
		||||
        The data is stored for the current user and given `clientId`.
 | 
			
		||||
 | 
			
		||||
        The data is only saved without being checked in any way
 | 
			
		||||
        (besides being valid JSON). It is returned "as is" to the
 | 
			
		||||
        client in the corresponding GET endpoint.
 | 
			
		||||
      security:
 | 
			
		||||
        - authTokenHeader: []
 | 
			
		||||
      requestBody:
 | 
			
		||||
        content:
 | 
			
		||||
          application/json:
 | 
			
		||||
            schema: {}
 | 
			
		||||
      responses:
 | 
			
		||||
        200:
 | 
			
		||||
          description: Ok
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: "#/components/schemas/BasicResult"
 | 
			
		||||
    delete:
 | 
			
		||||
      tags: [ Client Settings ]
 | 
			
		||||
      summary: Clears the current user settings
 | 
			
		||||
      description: |
 | 
			
		||||
        Removes all stored user settings for the client identified by
 | 
			
		||||
        `clientId`.
 | 
			
		||||
      security:
 | 
			
		||||
        - authTokenHeader: []
 | 
			
		||||
      responses:
 | 
			
		||||
        200:
 | 
			
		||||
          description: Ok
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: "#/components/schemas/BasicResult"
 | 
			
		||||
 | 
			
		||||
  /admin/user/resetPassword:
 | 
			
		||||
    post:
 | 
			
		||||
      tags: [ Collective, Admin ]
 | 
			
		||||
@@ -5571,3 +5633,11 @@ components:
 | 
			
		||||
      required: false
 | 
			
		||||
      schema:
 | 
			
		||||
        type: boolean
 | 
			
		||||
    clientId:
 | 
			
		||||
      name: clientId
 | 
			
		||||
      in: path
 | 
			
		||||
      required: true
 | 
			
		||||
      description: |
 | 
			
		||||
        some identifier for a client application
 | 
			
		||||
      schema:
 | 
			
		||||
        type: string
 | 
			
		||||
 
 | 
			
		||||
@@ -91,7 +91,8 @@ object RestServer {
 | 
			
		||||
      "calevent/check"          -> CalEventCheckRoutes(),
 | 
			
		||||
      "fts"                     -> FullTextIndexRoutes.secured(cfg, restApp.backend, token),
 | 
			
		||||
      "folder"                  -> FolderRoutes(restApp.backend, token),
 | 
			
		||||
      "customfield"             -> CustomFieldRoutes(restApp.backend, token)
 | 
			
		||||
      "customfield"             -> CustomFieldRoutes(restApp.backend, token),
 | 
			
		||||
      "clientSettings"          -> ClientSettingsRoutes(restApp.backend, token)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
  def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,52 @@
 | 
			
		||||
package docspell.restserver.routes
 | 
			
		||||
 | 
			
		||||
import cats.effect._
 | 
			
		||||
import cats.implicits._
 | 
			
		||||
 | 
			
		||||
import docspell.backend.BackendApp
 | 
			
		||||
import docspell.backend.auth.AuthToken
 | 
			
		||||
import docspell.common._
 | 
			
		||||
import docspell.restapi.model._
 | 
			
		||||
 | 
			
		||||
import io.circe.Json
 | 
			
		||||
import org.http4s.HttpRoutes
 | 
			
		||||
import org.http4s.circe.CirceEntityDecoder._
 | 
			
		||||
import org.http4s.circe.CirceEntityEncoder._
 | 
			
		||||
import org.http4s.dsl.Http4sDsl
 | 
			
		||||
 | 
			
		||||
object ClientSettingsRoutes {
 | 
			
		||||
 | 
			
		||||
  def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = {
 | 
			
		||||
    val dsl = new Http4sDsl[F] {}
 | 
			
		||||
    import dsl._
 | 
			
		||||
 | 
			
		||||
    HttpRoutes.of {
 | 
			
		||||
      case req @ PUT -> Root / Ident(clientId) =>
 | 
			
		||||
        for {
 | 
			
		||||
          data <- req.as[Json]
 | 
			
		||||
          _    <- backend.clientSettings.save(clientId, user.account, data)
 | 
			
		||||
          res  <- Ok(BasicResult(true, "Settings stored"))
 | 
			
		||||
        } yield res
 | 
			
		||||
 | 
			
		||||
      case GET -> Root / Ident(clientId) =>
 | 
			
		||||
        for {
 | 
			
		||||
          data <- backend.clientSettings.load(clientId, user.account)
 | 
			
		||||
          res <- data match {
 | 
			
		||||
            case Some(d) => Ok(d.settingsData)
 | 
			
		||||
            case None    => NotFound()
 | 
			
		||||
          }
 | 
			
		||||
        } yield res
 | 
			
		||||
 | 
			
		||||
      case DELETE -> Root / Ident(clientId) =>
 | 
			
		||||
        for {
 | 
			
		||||
          flag <- backend.clientSettings.delete(clientId, user.account)
 | 
			
		||||
          res <- Ok(
 | 
			
		||||
            BasicResult(
 | 
			
		||||
              flag,
 | 
			
		||||
              if (flag) "Settings deleted" else "Deleting settings failed"
 | 
			
		||||
            )
 | 
			
		||||
          )
 | 
			
		||||
        } yield res
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,10 @@
 | 
			
		||||
CREATE TABLE "client_settings" (
 | 
			
		||||
  "id" varchar(254) not null primary key,
 | 
			
		||||
  "client_id" varchar(254) not null,
 | 
			
		||||
  "user_id" varchar(254) not null,
 | 
			
		||||
  "settings_data" text not null,
 | 
			
		||||
  "created" timestamp not null,
 | 
			
		||||
  "updated" timestamp not null,
 | 
			
		||||
  foreign key ("user_id") references "user_"("uid") on delete cascade,
 | 
			
		||||
  unique ("client_id", "user_id")
 | 
			
		||||
);
 | 
			
		||||
@@ -0,0 +1,10 @@
 | 
			
		||||
CREATE TABLE `client_settings` (
 | 
			
		||||
  `id` varchar(254) not null primary key,
 | 
			
		||||
  `client_id` varchar(254) not null,
 | 
			
		||||
  `user_id` varchar(254) not null,
 | 
			
		||||
  `settings_data` longtext not null,
 | 
			
		||||
  `created` timestamp not null,
 | 
			
		||||
  `updated` timestamp not null,
 | 
			
		||||
  foreign key (`user_id`) references `user_`(`uid`) on delete cascade,
 | 
			
		||||
  unique (`client_id`, `user_id`)
 | 
			
		||||
);
 | 
			
		||||
@@ -0,0 +1,10 @@
 | 
			
		||||
CREATE TABLE "client_settings" (
 | 
			
		||||
  "id" varchar(254) not null primary key,
 | 
			
		||||
  "client_id" varchar(254) not null,
 | 
			
		||||
  "user_id" varchar(254) not null,
 | 
			
		||||
  "settings_data" text not null,
 | 
			
		||||
  "created" timestamp not null,
 | 
			
		||||
  "updated" timestamp not null,
 | 
			
		||||
  foreign key ("user_id") references "user_"("uid") on delete cascade,
 | 
			
		||||
  unique ("client_id", "user_id")
 | 
			
		||||
);
 | 
			
		||||
@@ -11,6 +11,7 @@ import doobie._
 | 
			
		||||
import doobie.implicits.legacy.instant._
 | 
			
		||||
import doobie.util.log.Success
 | 
			
		||||
import emil.doobie.EmilDoobieMeta
 | 
			
		||||
import io.circe.Json
 | 
			
		||||
import io.circe.{Decoder, Encoder}
 | 
			
		||||
 | 
			
		||||
trait DoobieMeta extends EmilDoobieMeta {
 | 
			
		||||
@@ -112,10 +113,18 @@ trait DoobieMeta extends EmilDoobieMeta {
 | 
			
		||||
 | 
			
		||||
  implicit val metaOrgUse: Meta[OrgUse] =
 | 
			
		||||
    Meta[String].timap(OrgUse.unsafeFromString)(_.name)
 | 
			
		||||
 | 
			
		||||
  implicit val metaJsonString: Meta[Json] =
 | 
			
		||||
    Meta[String].timap(DoobieMeta.parseJsonUnsafe)(_.noSpaces)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
object DoobieMeta extends DoobieMeta {
 | 
			
		||||
  import org.log4s._
 | 
			
		||||
  private val logger = getLogger
 | 
			
		||||
 | 
			
		||||
  private def parseJsonUnsafe(str: String): Json =
 | 
			
		||||
    io.circe.parser
 | 
			
		||||
      .parse(str)
 | 
			
		||||
      .fold(throw _, identity)
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,79 @@
 | 
			
		||||
package docspell.store.records
 | 
			
		||||
 | 
			
		||||
import cats.data.NonEmptyList
 | 
			
		||||
import cats.implicits._
 | 
			
		||||
 | 
			
		||||
import docspell.common._
 | 
			
		||||
import docspell.store.qb.DSL._
 | 
			
		||||
import docspell.store.qb._
 | 
			
		||||
 | 
			
		||||
import doobie._
 | 
			
		||||
import doobie.implicits._
 | 
			
		||||
import io.circe.Json
 | 
			
		||||
 | 
			
		||||
case class RClientSettings(
 | 
			
		||||
    id: Ident,
 | 
			
		||||
    clientId: Ident,
 | 
			
		||||
    userId: Ident,
 | 
			
		||||
    settingsData: Json,
 | 
			
		||||
    updated: Timestamp,
 | 
			
		||||
    created: Timestamp
 | 
			
		||||
) {}
 | 
			
		||||
 | 
			
		||||
object RClientSettings {
 | 
			
		||||
 | 
			
		||||
  final case class Table(alias: Option[String]) extends TableDef {
 | 
			
		||||
    val tableName = "client_settings"
 | 
			
		||||
 | 
			
		||||
    val id           = Column[Ident]("id", this)
 | 
			
		||||
    val clientId     = Column[Ident]("client_id", this)
 | 
			
		||||
    val userId       = Column[Ident]("user_id", this)
 | 
			
		||||
    val settingsData = Column[Json]("settings_data", this)
 | 
			
		||||
    val updated      = Column[Timestamp]("updated", this)
 | 
			
		||||
    val created      = Column[Timestamp]("created", this)
 | 
			
		||||
    val all =
 | 
			
		||||
      NonEmptyList.of[Column[_]](id, clientId, userId, settingsData, updated, created)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  def as(alias: String): Table = Table(Some(alias))
 | 
			
		||||
  val T                        = Table(None)
 | 
			
		||||
 | 
			
		||||
  def insert(v: RClientSettings): ConnectionIO[Int] = {
 | 
			
		||||
    val t = Table(None)
 | 
			
		||||
    DML.insert(
 | 
			
		||||
      t,
 | 
			
		||||
      t.all,
 | 
			
		||||
      fr"${v.id},${v.clientId},${v.userId},${v.settingsData},${v.updated},${v.created}"
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  def updateSettings(
 | 
			
		||||
      clientId: Ident,
 | 
			
		||||
      userId: Ident,
 | 
			
		||||
      data: Json,
 | 
			
		||||
      updateTs: Timestamp
 | 
			
		||||
  ): ConnectionIO[Int] =
 | 
			
		||||
    DML.update(
 | 
			
		||||
      T,
 | 
			
		||||
      T.clientId === clientId && T.userId === userId,
 | 
			
		||||
      DML.set(T.settingsData.setTo(data), T.updated.setTo(updateTs))
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
  def upsert(clientId: Ident, userId: Ident, data: Json): ConnectionIO[Int] =
 | 
			
		||||
    for {
 | 
			
		||||
      id  <- Ident.randomId[ConnectionIO]
 | 
			
		||||
      now <- Timestamp.current[ConnectionIO]
 | 
			
		||||
      nup <- updateSettings(clientId, userId, data, now)
 | 
			
		||||
      nin <-
 | 
			
		||||
        if (nup <= 0) insert(RClientSettings(id, clientId, userId, data, now, now))
 | 
			
		||||
        else 0.pure[ConnectionIO]
 | 
			
		||||
    } yield nup + nin
 | 
			
		||||
 | 
			
		||||
  def delete(clientId: Ident, userId: Ident): ConnectionIO[Int] =
 | 
			
		||||
    DML.delete(T, T.clientId === clientId && T.userId === userId)
 | 
			
		||||
 | 
			
		||||
  def find(clientId: Ident, userId: Ident): ConnectionIO[Option[RClientSettings]] =
 | 
			
		||||
    run(select(T.all), from(T), T.clientId === clientId && T.userId === userId)
 | 
			
		||||
      .query[RClientSettings]
 | 
			
		||||
      .option
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user