MiniDigger/Hangar

View on GitHub
orePlayCommon/app/db/impl/access/ProjectBase.scala

Summary

Maintainability
A
2 hrs
Test Coverage
package db.impl.access

import scala.language.higherKinds

import java.io.IOException
import java.nio.file.Files
import java.nio.file.Files._
import java.time.Instant

import db.impl.query.SharedQueries
import discourse.OreDiscourseApi
import ore.db.access.ModelView
import ore.db.impl.OrePostgresDriver.api._
import ore.db.impl.schema.{PageTable, ProjectTableMain, VersionTable}
import ore.db.{Model, ModelService}
import ore.models.project._
import ore.models.project.io.ProjectFiles
import ore.util.{FileUtils, OreMDC}
import ore.{OreConfig, OreEnv}
import ore.util.StringUtils._
import util.syntax._
import util.IOUtils

import cats.Parallel
import cats.data.OptionT
import cats.effect.syntax.all._
import cats.syntax.all._
import cats.instances.vector._
import cats.instances.option._
import com.google.common.base.Preconditions._
import com.typesafe.scalalogging.LoggerTakingImplicit

trait ProjectBase[F[_]] {

  def missingFile: F[Seq[Model[Version]]]

  def refreshHomePage(logger: LoggerTakingImplicit[OreMDC])(implicit mdc: OreMDC): F[Unit]

  /**
    * Returns projects that have not beein updated in a while.
    *
    * @return Stale projects
    */
  def stale: F[Seq[Model[Project]]]

  /**
    * Returns the Project with the specified owner name and name.
    *
    * @param owner  Owner name
    * @param name   Project name
    * @return       Project with name
    */
  def withName(owner: String, name: String): OptionT[F, Model[Project]]

  /**
    * Returns the Project with the specified owner name and URL slug, if any.
    *
    * @param owner  Owner name
    * @param slug   URL slug
    * @return       Project if found, None otherwise
    */
  def withSlug(owner: String, slug: String): OptionT[F, Model[Project]]

  /**
    * Returns the Project with the specified plugin ID, if any.
    *
    * @param pluginId Plugin ID
    * @return         Project if found, None otherwise
    */
  def withPluginId(pluginId: String): OptionT[F, Model[Project]]

  /**
    * Returns true if the Project's desired slug is available.
    *
    * @return True if slug is available
    */
  def isNamespaceAvailable(owner: String, slug: String): F[Boolean]

  /**
    * Returns true if the specified project exists.
    *
    * @return True if exists
    */
  def exists(owner: String, name: String): F[Boolean]

  /**
    * Saves any pending icon that has been uploaded for the specified [[Project]].
    *
    * FIXME: Weird behavior
    *
    * @param project Project to save icon for
    */
  def savePendingIcon(project: Project)(implicit mdc: OreMDC): F[Unit]

  /**
    * Renames the specified [[Project]].
    *
    * @param project  Project to rename
    * @param name     New name to assign Project
    */
  def rename(project: Model[Project], name: String): F[Boolean]

  /**
    * Irreversibly deletes this channel and all version associated with it.
    */
  def deleteChannel(project: Model[Project], channel: Model[Channel]): F[Unit]

  def prepareDeleteVersion(version: Model[Version]): F[Model[Project]]

  /**
    * Irreversibly deletes this version.
    */
  def deleteVersion(version: Model[Version])(implicit mdc: OreMDC): F[Model[Project]]

  /**
    * Irreversibly deletes this project.
    *
    * @param project Project to delete
    */
  def delete(project: Model[Project])(implicit mdc: OreMDC): F[Int]

  def queryProjectPages(project: Model[Project]): F[Seq[(Model[Page], Seq[Model[Page]])]]
}

object ProjectBase {

  /**
    * Default live implementation of [[ProjectBase]]
    */
  class ProjectBaseF[F[_], G[_]](
      implicit service: ModelService[F],
      config: OreConfig,
      forums: OreDiscourseApi[F],
      fileManager: ProjectFiles,
      F: cats.effect.Effect[F],
      par: Parallel[F, G]
  ) extends ProjectBase[F] {

    def missingFile: F[Seq[Model[Version]]] = {
      def allVersions =
        for {
          v <- TableQuery[VersionTable]
          p <- TableQuery[ProjectTableMain] if v.projectId === p.id
        } yield (p.ownerName, p.name, v)

      service.runDBIO(allVersions.result).map { versions =>
        versions
          .filter {
            case (ownerNamer, name, version) =>
              try {
                val versionDir = this.fileManager.getVersionDir(ownerNamer, name, version.name)
                Files.notExists(versionDir.resolve(version.fileName))
              } catch {
                case _: IOException =>
                  //Invalid file name
                  false
              }
          }
          .map(_._3)
      }
    }

    def refreshHomePage(logger: LoggerTakingImplicit[OreMDC])(implicit mdc: OreMDC): F[Unit] =
      service
        .runDbCon(SharedQueries.refreshHomeView.run)
        .runAsync(IOUtils.logCallback("Failed to refresh home page", logger))
        .to[F]

    def stale: F[Seq[Model[Project]]] =
      service.runDBIO(
        ModelView
          .raw(Project)
          .filter(_.lastUpdated > Instant.now().minusMillis(config.ore.projects.staleAge.toMillis))
          .result
      )

    def withName(owner: String, name: String): OptionT[F, Model[Project]] =
      ModelView
        .now(Project)
        .find(p => p.ownerName.toLowerCase === owner.toLowerCase && p.name.toLowerCase === name.toLowerCase)

    def withSlug(owner: String, slug: String): OptionT[F, Model[Project]] =
      ModelView
        .now(Project)
        .find(p => p.ownerName.toLowerCase === owner.toLowerCase && p.slug.toLowerCase === slug.toLowerCase)

    def withPluginId(pluginId: String): OptionT[F, Model[Project]] =
      ModelView.now(Project).find(equalsIgnoreCase(_.pluginId, pluginId))

    def isNamespaceAvailable(owner: String, slug: String): F[Boolean] =
      withSlug(owner, slug).isEmpty

    def exists(owner: String, name: String): F[Boolean] =
      withName(owner, name).isDefined

    def savePendingIcon(project: Project)(implicit mdc: OreMDC): F[Unit] = F.delay {
      this.fileManager.getPendingIconPath(project).foreach { iconPath =>
        val iconDir = this.fileManager.getIconDir(project.ownerName, project.name)
        if (notExists(iconDir))
          createDirectories(iconDir)
        FileUtils.cleanDirectory(iconDir)
        move(iconPath, iconDir.resolve(iconPath.getFileName))
      }
    }

    def rename(
        project: Model[Project],
        name: String
    ): F[Boolean] = {
      val newName = compact(name)
      val newSlug = slugify(newName)
      checkArgument(config.isValidProjectName(name), "invalid name", "")
      for {
        isAvailable <- this.isNamespaceAvailable(project.ownerName, newSlug)
        _ = checkArgument(isAvailable, "slug not available", "")
        res <- {
          val fileOp      = this.fileManager.renameProject(project.ownerName, project.name, newName)
          val renameModel = service.update(project)(_.copy(name = newName, slug = newSlug))

          // Project's name alter's the topic title, update it
          val dbOp =
            if (project.topicId.isDefined)
              forums.updateProjectTopic(project) <* renameModel
            else
              renameModel.as(false)

          dbOp <* fileOp
        }
      } yield res
    }

    def deleteChannel(project: Model[Project], channel: Model[Channel]): F[Unit] = {
      for {
        channels  <- service.runDBIO(project.channels(ModelView.raw(Channel)).result)
        noVersion <- channel.versions(ModelView.now(Version)).isEmpty
        nonEmptyChannels <- channels.toVector
          .parTraverse(_.versions(ModelView.now(Version)).nonEmpty)
          .map(_.count(identity))
        _                = checkArgument(channels.size > 1, "only one channel", "")
        _                = checkArgument(noVersion || nonEmptyChannels > 1, "last non-empty channel", "")
        reviewedChannels = channels.filter(!_.isNonReviewed)
        _ = checkArgument(
          channel.isNonReviewed || reviewedChannels.size > 1 || !reviewedChannels.contains(channel),
          "last reviewed channel",
          ""
        )
        versions <- service.runDBIO(channel.versions(ModelView.raw(Version)).result)
        _ <- versions.toVector.parTraverse { version =>
          val otherChannels = channels.filter(_ != channel)
          val newChannel =
            if (channel.isNonReviewed) otherChannels.find(_.isNonReviewed).getOrElse(otherChannels.head)
            else otherChannels.head
          service.update(version)(_.copy(channelId = newChannel.id))
        }
        _ <- service.delete(channel)
      } yield ()
    }

    def prepareDeleteVersion(version: Model[Version]): F[Model[Project]] = {
      for {
        proj <- version.project
        size <- proj.versions(ModelView.now(Version)).count(_.visibility === (Visibility.Public: Visibility))
        _ = checkArgument(size > 1, "only one public version", "")
        rv       <- proj.recommendedVersion(ModelView.now(Version)).sequence.subflatMap(identity).value
        projects <- service.runDBIO(proj.versions(ModelView.raw(Version)).sortBy(_.createdAt.desc).result) // TODO optimize: only query one version
        res <- {
          if (rv.contains(version))
            service.update(proj)(
              _.copy(recommendedVersionId = Some(projects.filter(v => v != version && !v.obj.isDeleted).head.id))
            )
          else F.pure(proj)
        }
      } yield res
    }

    /**
      * Irreversibly deletes this version.
      */
    def deleteVersion(version: Model[Version])(implicit mdc: OreMDC): F[Model[Project]] = {
      for {
        proj       <- prepareDeleteVersion(version)
        channel    <- version.channel
        noVersions <- channel.versions(ModelView.now(Version)).isEmpty
        _ <- {
          val versionDir = this.fileManager.getVersionDir(proj.ownerName, proj.name, version.name)
          FileUtils.deleteDirectory(versionDir)
          service.delete(version)
        }
        // Delete channel if now empty
        _ <- if (noVersions) this.deleteChannel(proj, channel) else F.unit
      } yield proj
    }

    /**
      * Irreversibly deletes this project.
      *
      * @param project Project to delete
      */
    def delete(project: Model[Project])(implicit mdc: OreMDC): F[Int] = {
      val fileEff = F.delay(FileUtils.deleteDirectory(this.fileManager.getProjectDir(project.ownerName, project.name)))
      val eff =
        if (project.topicId.isDefined)
          forums.deleteProjectTopic(project).void
        else F.unit
      // TODO: Instead, move to the "projects_deleted" table just in case we couldn't delete the topic
      eff *> service.delete(project) <* fileEff
    }

    def queryProjectPages(project: Model[Project]): F[Seq[(Model[Page], Seq[Model[Page]])]] = {
      val tablePage = TableQuery[PageTable]
      val pagesQuery = for {
        (pp, p) <- tablePage.joinLeft(tablePage).on(_.id === _.parentId)
        if pp.projectId === project.id.value && pp.parentId.isEmpty
      } yield (pp, p)

      service.runDBIO(pagesQuery.result).map(_.groupBy(_._1)).map { grouped => // group by parent page
        // Sort by key then lists too
        grouped.toSeq.sortBy(_._1.name).map {
          case (pp, p) =>
            (pp, p.flatMap(_._2).sortBy(_.name))
        }
      }
    }
  }

  def apply[F[_]](implicit projectBase: ProjectBase[F]): ProjectBase[F] = projectBase
}