diff --git a/app/Bootstrap.scala b/app/Bootstrap.scala index fffb71cf1..4d3f0947d 100644 --- a/app/Bootstrap.scala +++ b/app/Bootstrap.scala @@ -5,6 +5,7 @@ import db.ModelService import db.impl.access.ProjectBase import discourse.OreDiscourseApi import ore.OreConfig +import ore.project.ProjectTask import ore.user.UserSyncTask import org.bouncycastle.jce.provider.BouncyCastleProvider @@ -17,6 +18,7 @@ trait Bootstrap { val forums: OreDiscourseApi val config: OreConfig val userSync: UserSyncTask + val projectTask: ProjectTask val Logger = play.api.Logger("Bootstrap") @@ -30,6 +32,8 @@ trait Bootstrap { this.userSync.start() + this.projectTask.start() + if (this.config.security.getBoolean("requirePgp").get) Security.addProvider(new BouncyCastleProvider) @@ -41,4 +45,5 @@ trait Bootstrap { class BootstrapImpl @Inject()(override val modelService: ModelService, override val forums: OreDiscourseApi, override val config: OreConfig, - override val userSync: UserSyncTask) extends Bootstrap + override val userSync: UserSyncTask, + override val projectTask: ProjectTask) extends Bootstrap diff --git a/app/ErrorHandler.scala b/app/ErrorHandler.scala index 5fc4ec13f..54473c771 100755 --- a/app/ErrorHandler.scala +++ b/app/ErrorHandler.scala @@ -34,7 +34,7 @@ class ErrorHandler @Inject()(env: Environment, if (exception.cause.isInstanceOf[TimeoutException]) GatewayTimeout(views.html.errors.timeout()) else - InternalServerError(views.html.errors.error(exception.getMessage)) + InternalServerError(views.html.errors.error()) } } diff --git a/app/assets/stylesheets/_creator.scss b/app/assets/stylesheets/_creator.scss index 6ce5df4f9..9606be660 100644 --- a/app/assets/stylesheets/_creator.scss +++ b/app/assets/stylesheets/_creator.scss @@ -2,7 +2,6 @@ @import 'pallette'; .project-create-steps { - @include rounded-corners(); background-color: #fafafa; color: #ccc; border: 1px solid $lighter; @@ -118,7 +117,7 @@ .table-members { border-top: none; - border-bottom: 1px solid #ddd; + border-bottom: 1px solid $lighter; .input-group { max-width: 35%; } tbody > tr > td { padding: 10px; } tr > td:last-child { text-align: right; } @@ -131,7 +130,7 @@ } .release-bulletin { - border-bottom: 1px solid #ddd; + border-bottom: 1px solid $lighter; > div { width: 90%; margin: 10px auto 0; diff --git a/app/assets/stylesheets/_editor.scss b/app/assets/stylesheets/_editor.scss index 2dae98c18..3386875f9 100644 --- a/app/assets/stylesheets/_editor.scss +++ b/app/assets/stylesheets/_editor.scss @@ -2,7 +2,6 @@ @import 'utils'; .page-rendered, .page-edit textarea { - @include rounded-corners(); min-height: 350px; margin-bottom: 20px; width: 100%; @@ -89,7 +88,7 @@ button.open:hover { } .page-editor-tabs > li > a:hover { - border-bottom: 1px solid #ddd; + border-bottom: 1px solid $lighter; } .page-editor-tabs > li > a:hover, diff --git a/app/assets/stylesheets/_nav.scss b/app/assets/stylesheets/_nav.scss index d8427668a..76a97f6e1 100644 --- a/app/assets/stylesheets/_nav.scss +++ b/app/assets/stylesheets/_nav.scss @@ -31,7 +31,7 @@ .navbar-nav > li > .main-dropdown, .user-dropdown { @include no-box-shadow(); - border-radius: 0 0 4px 4px; + border-radius: 0; border: 1px solid #e4e4e4; } @@ -46,6 +46,7 @@ .user-dropdown > li { position: relative; + .unread { bottom: 6px; margin-left: 5px; @@ -54,25 +55,17 @@ .navbar-main { .navbar-right > li > a { padding: 0; } - .navbar-right { - @include padding-vert(9px, 9px); - padding-right: 20px; - } -} - -.project-search { - @include size(0, 40px); - padding: 3px; - overflow: hidden; } .nav-icon { @include transition2(background-color, 0.5s); cursor: pointer; - padding: 7px; - margin-right: 5px; text-align: center; + &:not(:last-child) { + margin-right: 20px; + } + .icon { @include transition2(color, 0.5s); cursor: pointer; @@ -81,30 +74,43 @@ } } -.nav-icon:hover { - background-color: white; - .icon { color: black; } +.nav-icon:hover, .nav-icon:hover a { + .icon { color: white; } + .user-avatar { + background: white; + } } -.new-icon { - .caret { padding-bottom: 10px; } +.authors-icon { + margin-top: 19px; } -.new-icon:hover { - background-color: transparent; - .icon { color: #F6CF17; } +.no-caret { + .caret { + display: none; + } } -.user-controls { - @include padding-vert(5px, 17px); -} +li.user-controls { + margin-top: 15.5px; -.new-controls { - @include padding-vert(10px, 19px); + > .dropdown-menu { + margin-top: 15px; + margin-right: -5px; + } } -.user-avatar:hover { - background-color: white; +li.new-controls { + margin-top: 19.5px; + + > .dropdown-menu { + margin-top: 17px; + margin-right: -5px; + } + + .caret { + margin-bottom: 10px; + } } .user-dropdown > li > a { @@ -116,6 +122,7 @@ } .btn-group-login { + margin-top: 10.5px; padding: 4px; } @@ -127,10 +134,13 @@ @include size(12px, 12px); @include circle(); position: absolute; - background-image: linear-gradient(#e6b800, #FFD21A); + background-image: linear-gradient(#CC0000, #B70000); } .user-toggle { position: relative; - .unread { right: 10px; } + .unread { + right: 10px; + top: -4px; + } } diff --git a/app/assets/stylesheets/_panel.scss b/app/assets/stylesheets/_panel.scss new file mode 100644 index 000000000..06515b5a5 --- /dev/null +++ b/app/assets/stylesheets/_panel.scss @@ -0,0 +1,12 @@ +.panel.panel-default { + border: 1px solid $lighter; +} + +.panel > .panel-pagination { + background: $sponge_grey; + text-align: center; + border-top: 1px solid $lighter; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/app/assets/stylesheets/_project.scss b/app/assets/stylesheets/_project.scss index ea35d7a68..002c67acb 100755 --- a/app/assets/stylesheets/_project.scss +++ b/app/assets/stylesheets/_project.scss @@ -147,9 +147,8 @@ } .channel { - border-radius: 6px; - padding: 4px 6px; font-weight: bold; + padding: 2px 4px; text-align: center; color: white; cursor: default; diff --git a/app/assets/stylesheets/_user.scss b/app/assets/stylesheets/_user.scss index 582e019cf..cdb5da297 100644 --- a/app/assets/stylesheets/_user.scss +++ b/app/assets/stylesheets/_user.scss @@ -147,8 +147,6 @@ textarea[name="pgp-pub-key"] { text-align: center; font-size: 10px; bottom: 9px; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; } #form-avatar input[name="avatar-url"] { @@ -158,7 +156,7 @@ textarea[name="pgp-pub-key"] { .user-header { @include size(100%, 150px); padding: 20px; - border-bottom: 1px solid #ddd; + border-bottom: 1px solid $lighter; margin-bottom: 40px; .user-badge { @@ -264,7 +262,6 @@ textarea[name="pgp-pub-key"] { height: 25px; top: 30%; right: 1%; - @include rounded-corners(); } .btn-mark-read:hover { @@ -299,7 +296,7 @@ textarea[name="pgp-pub-key"] { } .user-cancel:hover { - background-color: #ddd; + background-color: $lighter; } .invite { @@ -333,7 +330,6 @@ textarea[name="pgp-pub-key"] { } .prompt { - @include rounded-corners(); z-index: 0; display: block; @@ -355,3 +351,9 @@ textarea[name="pgp-pub-key"] { top: -5px; left: 275px; } + +#new-page { + #new-page-label { + color: $sponge_grey; + } +} diff --git a/app/assets/stylesheets/_version-list.scss b/app/assets/stylesheets/_version-list.scss new file mode 100644 index 000000000..edadb1fe7 --- /dev/null +++ b/app/assets/stylesheets/_version-list.scss @@ -0,0 +1,117 @@ +.version-panel { + .loading { + text-align: center; + padding: 0.5em 0; + font-size: 4em; + } + + .version-list { + display: none; + + tr > td { + cursor: default; + + &.base-info { + vertical-align: middle; + + .channel { + font-size: 0.75em; + } + + .name a { + font-weight: 600; + color: $sponge_dark_grey; + } + + width: 50%; + padding-left: 15px; + } + + &.version-tags { + width: 50%; + padding-right: 15px; + text-align: right; + vertical-align: middle; + + .tags { + display: inline-flex; + flex-wrap: nowrap; + } + } + + &.information-one, &.information-two { + vertical-align: middle; + display: none; + } + + &.download { + vertical-align: middle; + display: none; + + width: 5%; + text-align: right; + padding-right: 15px; + + a { + color: $sponge_grey; + } + + .download-link { + position: relative; + + .fa-download { + margin-top: 8px; + } + + .fa-exclamation-circle { + position: absolute; + top: -21px; + left: -10px; + font-size: 1.4em; + color: #FFA500; + } + } + } + + @media (min-width: 768px) { + &.information-one, &.information-two, &.download { + display: table-cell; + } + + &.base-info { + width: 25%; + } + + &.version-tags { + text-align: left; + width: 25%; + } + + &.information-one { + width: 20%; + } + + &.information-two { + width: 20%; + + .author { + display: none; + } + } + + &.download { + width: 10%; + } + } + } + } + + > .panel-heading h4 { + font-size: 1.3em; + font-weight: 400; + } + + > .panel-pagination { + display: none; + } +} diff --git a/app/assets/stylesheets/main.scss b/app/assets/stylesheets/main.scss index a42cd597f..0116f8f7d 100644 --- a/app/assets/stylesheets/main.scss +++ b/app/assets/stylesheets/main.scss @@ -6,6 +6,8 @@ @import 'nav'; @import 'project'; @import 'user'; +@import "panel"; +@import "version-list"; @import 'utils'; html { @@ -99,10 +101,6 @@ form { overflow: hidden; } - .list-group-item.project-hidden { - background: repeating-linear-gradient(45deg, $lighter, $lighter 10px, #f5f5f5 10px, #f5f5f5 20px); - } - .list-group-item.project-list-footer { padding: 0; .btn { border: none; } @@ -209,6 +207,9 @@ form { &.selected { box-shadow: inset -10px 0px 0px 0px $sponge_yellow; } + &.selected td:nth-child(2) { + box-shadow: inset -10px 0px $sponge_yellow; + } } } @@ -226,51 +227,209 @@ form { } } - .btn { - border-radius: 4px; + &:focus, &:active:focus, &.active:focus, &.focus, &:active.focus, &.active.focus { + outline: none; + } &.dark { background: $sponge_dark_grey; border: 1px solid $sponge_light_gray; + color: #ffffff; + + &:hover { + background: darken($sponge_dark_grey, 1); + } + } + + &.yellow { + background: $sponge_yellow; + border: 1px solid darken($sponge_yellow, 7); + color: darken($sponge_yellow, 30); + font-weight: 600; + + &:hover { + background: darken($sponge_yellow, 2); + } } } -.panel .panel-heading { +.panel > .panel-heading { + display: flex; + align-items: center; + justify-content: space-between; background: $sponge_grey; color: #ffffff; font-weight: 600; border: none; - a, i { + &:last-child { + margin-left: auto; + } + + .btn i { + color: inherit; + } + + i { color: #ffffff; } +} - .btn { - color: $sponge_yellow; - background: $sponge_grey; - border: none; - outline: none; +select.form-control, input.form-control { + box-shadow: none; + border: 1px solid $lighter; + + &:focus { + box-shadow: none; + border: 1px solid $lighter; } } -.panel.panel-default { - border: 1px solid $sponge_grey; - border-radius: 4px; +.btn-group .form-control { + z-index: 4; } -.panel>.list-group:last-child .list-group-item:last-child, .panel>.panel-collapse>.list-group:last-child .list-group-item:last-child { - border-bottom-right-radius: 4px; - border-bottom-left-radius: 4px; +.template { + display: none; } -select.form-control { - border: 1px solid $sponge_grey; - border-radius: 4px; +.pagination { + margin: 10px 0; - &:focus { - box-shadow: none; - border: 1px solid $sponge_grey; + li { + &:first-child a { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + + &:last-child a { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } + + a { + background: $sponge_light_gray; + color: white; + cursor: pointer; + border: 1px solid lighten($sponge_light_gray, 10); + + &:hover, &:focus, &:active { + border-color: $sponge_yellow; + background: $sponge_yellow; + color: darken($sponge_yellow, 30); + } + } + + &.disabled a, &.disabled a:hover, &.disabled a:focus { + background: $sponge_light_gray; + color: lighten($sponge_light_gray, 10); + border: 1px solid lighten($sponge_light_gray, 10); + } + + &.active > a { + cursor: pointer; + border-color: $sponge_yellow; + background: $sponge_yellow; + color: darken($sponge_yellow, 30); + + &:hover { + cursor: pointer; + color: darken($sponge_yellow, 30); + } + } } } +.tags { + align-items: center; + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + + .tag { + border: 1px solid darken(whitesmoke, 10); + align-items: center; + display: flex; + background-color: whitesmoke; + border-radius: 3px; + font-size: 0.75em; + height: 2em; + padding-left: 0.75em; + padding-right: 0.75em; + white-space: nowrap; + margin: 5px; + } + + &.has-addons .tag { + &:first-child { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + margin-right: 0; + } + + &:nth-child(2) { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-left: none; + margin-left: 0; + } + } +} + +.channel { + align-items: center; + display: inline-flex; + border-radius: 3px; + font-size: 0.75em; + height: 2em; + padding-left: 0.75em; + padding-right: 0.75em; + white-space: nowrap; +} + +.table tbody > tr > td { + vertical-align: middle; +} + +.table.centered { + text-align: center; +} + +.project-search { + margin-bottom: 1rem; +} + +.text-sponge-yellow { + color: $sponge_yellow !important; +} + +.table-review-log tr td:first-child { + width: 10em; + font-weight: bold; + vertical-align: top; +} + +.table-notes-log tr .note-fixed-with { + width: 10em; +} + +.input-group .input-group-btn { + z-index: 999; +} + +.striped { + background: repeating-linear-gradient(45deg, $lighter, $lighter 15px, #f5f5f5 15px, #f5f5f5 35px); +} + +.project-new { + box-shadow: inset -10px 0px cadetblue; +} + +.project-needsChanges { + box-shadow: inset -10px 0px gold; +} + +.project-hidden { + box-shadow: inset -10px 0px red; +} diff --git a/app/controllers/Application.scala b/app/controllers/Application.scala old mode 100755 new mode 100644 index a2ea7a02f..b7ca050cc --- a/app/controllers/Application.scala +++ b/app/controllers/Application.scala @@ -1,15 +1,22 @@ package controllers +import java.sql.Timestamp +import java.time.Instant import javax.inject.Inject import controllers.sugar.Bakery import controllers.sugar.Requests.AuthRequest +import db.access.ModelAccess import db.impl.OrePostgresDriver.api._ import db.impl.schema.ProjectSchema import db.{ModelFilter, ModelService} -import models.project.{Flag, Project, Version} +import form.OreForms +import models.admin.Review +import models.project.{Flag, Project, Version, VisibilityTypes} +import models.user.role._ import ore.Platforms.Platform import ore.permission._ +import ore.permission.role.{RoleTypes, GlobalRole, Role} import ore.permission.scope.GlobalScope import ore.project.Categories.Category import ore.project.{Categories, ProjectSortingStrategies} @@ -20,10 +27,13 @@ import security.spauth.SingleSignOnConsumer import util.DataHelper import views.{html => views} +import scala.concurrent.Future + /** * Main entry point for application. */ final class Application @Inject()(data: DataHelper, + forms: OreForms, implicit override val bakery: Bakery, implicit override val sso: SingleSignOnConsumer, implicit override val messagesApi: MessagesApi, @@ -50,10 +60,15 @@ final class Application @Inject()(data: DataHelper, val actions = this.service.getSchema(classOf[ProjectSchema]) val canHideProjects = this.users.current.isDefined && (this.users.current.get can HideProjects in GlobalScope) - val visibleFilter: ModelFilter[Project] = if (!canHideProjects) - ModelFilter[Project](_.isVisible) + var visibleFilter: ModelFilter[Project] = if (!canHideProjects) + ModelFilter[Project](_.visibility === VisibilityTypes.Public) +|| ModelFilter[Project](_.visibility === VisibilityTypes.New) else ModelFilter.Empty + if (this.users.current.isDefined) { + val currentUser = this.users.current.get + visibleFilter = visibleFilter +|| (ModelFilter[Project](_.userId === currentUser.id.get) + +&& ModelFilter[Project](_.visibility =!= VisibilityTypes.SoftDelete)) + } val pform = platform.flatMap(p => Platforms.values.find(_.name.equalsIgnoreCase(p)).map(_.asInstanceOf[Platform])) val platformFilter = pform.map(actions.platformFilter).getOrElse(ModelFilter.Empty) @@ -105,6 +120,12 @@ final class Application @Inject()(data: DataHelper, this.service.access[Version](classOf[Version]) .filterNot(_.isReviewed) .filterNot(_.channel.isNonReviewed) + .filterNot(_.reviewEntries.isEmpty) + .map(v => (v.project, v)), + this.service.access[Version](classOf[Version]) + .filterNot(_.isReviewed) + .filterNot(_.channel.isNonReviewed) + .filter(_.reviewEntries.isEmpty) .map(v => (v.project, v)))) } } @@ -131,7 +152,7 @@ final class Application @Inject()(data: DataHelper, case None => notFound case Some(flag) => - flag.setResolved(resolved) + flag.setResolved(resolved, users.current) Ok } } @@ -181,12 +202,169 @@ final class Application @Inject()(data: DataHelper, } /** - * Shows the offline error page. - * - * @return Offline error page + * Show the activities page for a user */ - def showOffline() = Action { implicit request => - Ok(views.errors.offline()) + def showActivities(user: String) = (Authenticated andThen PermissionAction[AuthRequest](ReviewProjects)) { implicit request => + val u = this.users.withName(user).get + var activities: Seq[(Object, Option[Project])] = Seq.empty + if (u.id.isDefined) { + val reviews: Seq[(Review, Option[Project])] = this.service.access[Review](classOf[Review]) + .filter(_.userId === u.id.get) + .take(20) + .map(review => review -> this.projects.find(_.id === this.service.access[Version](classOf[Version]).filter(_.id === review.versionId).head.projectId)) + val flags: Seq[(Flag, Option[Project])] = this.service.access[Flag](classOf[Flag]) + .filter(_.resolvedBy === u.id.get) + .take(20) + .map(flag => flag -> this.projects.find(_.id === flag.projectId)) + activities = reviews ++ flags + activities.sortWith(sortActivities) + } + Ok(views.users.admin.activity(u, activities)) + } + + /** + * Compares 2 activities (supply a [[Review]] or [[Flag]]) to decide which came first + * @param o1 Review / Flag + * @param o2 Review / Flag + * @return Boolean + */ + def sortActivities(o1: Object, o2: Object): Boolean = { + var o1Time: Long = 0 + var o2Time: Long = 0 + if (o1.isInstanceOf[Review]) { + o1Time = o1.asInstanceOf[Review].endedAt.getOrElse(Timestamp.from(Instant.EPOCH)).getTime + } + if (o2.isInstanceOf[Flag]) { + o2Time = o2.asInstanceOf[Flag].resolvedAt.getOrElse(Timestamp.from(Instant.EPOCH)).getTime + } + return o1Time > o2Time + } + /** + * Show stats + * @return + */ + def showStats() = (Authenticated andThen PermissionAction[AuthRequest](ViewStats)) { implicit request => + + /** + * Query to get a count where columnDate is equal to the date + */ + def last10DaysCountQuery(table: String, columnDate: String): Seq[(String,String)] = { + val query = this.service.DB.db.run(sql""" + SELECT + (SELECT COUNT(*) FROM #$table WHERE CAST(#$columnDate AS DATE) = day), + CAST(day AS DATE) + FROM (SELECT current_date - (INTERVAL '1 day' * generate_series(0, 9)) AS day) dates + ORDER BY day ASC""".as[(String, String)]) + this.service.await(query).getOrElse(Seq()) + } + + /** + * Query to get a count of open record in last 10 days + */ + def last10DaysTotalOpen(table: String, columnStartDate: String, columnEndDate: String): Seq[(String,String)] = { + val query = this.service.DB.db.run(sql""" + SELECT + (SELECT COUNT(*) FROM #$table WHERE CAST(#$columnStartDate AS DATE) <= date.when AND (CAST(#$columnEndDate AS DATE) >= date.when OR #$columnEndDate IS NULL)) count, + CAST(date.when AS DATE) AS days + FROM (SELECT current_date - (INTERVAL '1 day' * generate_series(0, 9)) AS when) date + ORDER BY days ASC""".as[(String, String)]) + this.service.await(query).getOrElse(Seq()) + } + + val reviews: Seq[(String, String)] = last10DaysCountQuery("project_version_reviews", "ended_at") + val uploads: Seq[(String, String)] = last10DaysCountQuery("project_versions", "created_at") + val totalDownloads: Seq[(String, String)] = last10DaysCountQuery("project_version_downloads", "created_at") + val unsafeDownloads: Seq[(String, String)] = last10DaysCountQuery("project_version_unsafe_downloads", "created_at") + val flagsOpen: Seq[(String, String)] = last10DaysTotalOpen("project_flags", "created_at", "resolved_at") + val flagsClosed: Seq[(String, String)] = last10DaysCountQuery("project_flags", "resolved_at") + + Ok(views.users.admin.stats(reviews, uploads, totalDownloads, unsafeDownloads, flagsOpen, flagsClosed)) } + def UserAdminAction = Authenticated andThen PermissionAction[AuthRequest](UserAdmin) + + def userAdmin(user: String) = UserAdminAction { implicit request => + this.users.withName(user).map { u => + Ok(views.users.admin.userAdmin(u)) + } getOrElse { + notFound + } + } + + def updateUser(user: String) = UserAdminAction { implicit request => + this.users.withName(user).map { user => + this.forms.UserAdminUpdate.bindFromRequest.fold( + hasErrors => BadRequest, + { case (thing, action, data) => + + import play.api.libs.json._ + val json = Json.parse(data) + + def updateRoleTable[M <: RoleModel](modelAccess: ModelAccess[M], allowedType: Class[_ <: Role], ownerType: RoleTypes.RoleType, transferOwner: M => Unit) = { + val id = (json \ "id").as[Int] + action match { + case "setRole" => modelAccess.get(id).map { role => + val roleType = RoleTypes.withId((json \ "role").as[Int]) + if (roleType == ownerType) { + transferOwner(role) + Ok + } else if (roleType.roleClass == allowedType && roleType.isAssignable) { + role.roleType = roleType + Ok + } else BadRequest + } getOrElse BadRequest + case "setAccepted" => modelAccess.get(id).map { role => + role.setAccepted((json \ "accepted").as[Boolean]) + Ok + } getOrElse BadRequest + case "deleteRole" => modelAccess.get(id).map { role => + if (role.roleType.isAssignable) { + role.remove() + Ok + } else BadRequest + } getOrElse BadRequest + } + } + + def transferOrgOwner(r: OrganizationRole) = r.organization.transferOwner(r.organization.memberships.newMember(r.userId)) + + thing match { + case "orgRole" => + if (user.isOrganization) { + BadRequest + } else updateRoleTable(user.organizationRoles, classOf[OrganizationRole], RoleTypes.OrganizationOwner, transferOrgOwner) + case "memberRole" => + if (!user.isOrganization) { + BadRequest + } else updateRoleTable(user.toOrganization.memberships.roles, classOf[OrganizationRole], RoleTypes.OrganizationOwner, transferOrgOwner) + case "projectRole" => + if (user.isOrganization) { + BadRequest + } else updateRoleTable(user.projectRoles, classOf[ProjectRole], RoleTypes.ProjectOwner, + (r:ProjectRole) => r.project.transferOwner(r.project.memberships.newMember(r.userId))) + + case _ => BadRequest + } + + }) + } getOrElse { + notFound + } + } + + /** + * + * @return Show page + */ + def showProjectVisibility() = (Authenticated andThen PermissionAction[AuthRequest](ReviewVisibility)) { implicit request => + val projectSchema = this.service.getSchema(classOf[ProjectSchema]) + + val futureApproval = projectSchema.collect(ModelFilter[Project](_.visibility === VisibilityTypes.NeedsApproval).fn, ProjectSortingStrategies.Default, -1, 0) + val projectApprovals = this.service.await(futureApproval).get + + val futureChanges = projectSchema.collect(ModelFilter[Project](_.visibility === VisibilityTypes.NeedsChanges).fn, ProjectSortingStrategies.Default, -1, 0) + val projectChanges = this.service.await(futureChanges).get + + Ok(views.users.admin.visibility(projectApprovals.seq, projectChanges.seq)) + } } diff --git a/app/controllers/Reviews.scala b/app/controllers/Reviews.scala new file mode 100644 index 000000000..68e99fb7a --- /dev/null +++ b/app/controllers/Reviews.scala @@ -0,0 +1,153 @@ +package controllers + +import java.sql.Timestamp +import java.time.Instant +import javax.inject.Inject + +import controllers.BaseController +import controllers.sugar.Bakery +import controllers.sugar.Requests.AuthRequest +import db.ModelService +import db.impl.{ReviewTable, VersionTable} +import form.OreForms +import models.admin.{Message, Review} +import models.user.{Notification, User} +import ore.permission.ReviewProjects +import ore.permission.role.Lifted +import ore.user.notification.NotificationTypes +import ore.{OreConfig, OreEnv} +import play.api.i18n.MessagesApi +import security.spauth.SingleSignOnConsumer +import util.{DataHelper, StringUtils} +import views.{html => views} + +/** + * Controller for handling Review related actions. + */ +final class Reviews @Inject()(data: DataHelper, + forms: OreForms, + implicit override val bakery: Bakery, + implicit override val sso: SingleSignOnConsumer, + implicit override val messagesApi: MessagesApi, + implicit override val env: OreEnv, + implicit override val config: OreConfig, + implicit override val service: ModelService) + extends BaseController { + + + def showReviews(author: String, slug: String, versionString: String) = { + (Authenticated andThen PermissionAction[AuthRequest](ReviewProjects)) { implicit request => + withProject(author, slug) { implicit project => + withVersion(versionString) { implicit version => + Ok(views.users.admin.reviews(version.mostRecentReviews)) + } + } + } + } + + def createReview(author: String, slug: String, versionString: String) = { + (Authenticated andThen PermissionAction[AuthRequest](ReviewProjects)) { implicit request => + withProject(author, slug) { implicit project => + withVersion(versionString) { implicit version => + val review = new Review(Some(1), Some(Timestamp.from(Instant.now())), version.id.get, users.current.get.id.get, None, "") + this.service.insert(review) + Redirect(routes.Reviews.showReviews(author, slug, versionString)) + } + } + } + } + + def stopReview(author: String, slug: String, versionString: String) = { + (Authenticated andThen PermissionAction[AuthRequest](ReviewProjects)) { implicit request => + withProject(author, slug) { implicit project => + withVersion(versionString) { version => + val review = version.mostRecentUnfinishedReview.get + review.addMessage(Message(this.forms.ReviewDescription.bindFromRequest.get.trim, System.currentTimeMillis(), "stop")) + review.setEnded(Timestamp.from(Instant.now())) + Redirect(routes.Reviews.showReviews(author, slug, versionString)) + } + } + } + } + + def approveReview(author: String, slug: String, versionString: String) = { + (Authenticated andThen PermissionAction[AuthRequest](ReviewProjects)) { implicit request => + withProject(author, slug) { implicit project => + withVersion(versionString) { version => + val review = version.mostRecentUnfinishedReview.get + review.setEnded(Timestamp.from(Instant.now())) + + // send notification that review happened + val organization = this.organizations.get(project.ownerId) + + if (organization.isDefined) { + val users: List[User] = organization.get.memberships.members.toList.filter(_.headRole.roleType.trust.level >= Lifted.level).map(_.user) + + if (!users.contains(version.author.get)) + users :+ version.author.get + + users.foreach(user => user.sendNotification(Notification( + originId = request.user.id.get, + notificationType = NotificationTypes.ProjectInvite, + message = messagesApi("notification.project.reviewed", slug, versionString) + ))) + } else { + version.author.get.sendNotification(Notification( + originId = request.user.id.get, + notificationType = NotificationTypes.ProjectInvite, + message = messagesApi("notification.project.reviewed", slug, versionString) + )) + } + + Redirect(routes.Reviews.showReviews(author, slug, versionString)) + } + } + } + } + + def takeoverReview(author: String, slug: String, versionString: String) = { + (Authenticated andThen PermissionAction[AuthRequest](ReviewProjects)) { implicit request => + withProject(author, slug) { implicit project => + withVersion(versionString) { version => + // Close old review + val oldreview = version.mostRecentUnfinishedReview.get + oldreview.addMessage(Message(this.forms.ReviewDescription.bindFromRequest.get.trim, System.currentTimeMillis(), "takeover")) + oldreview.setEnded(Timestamp.from(Instant.now())) + // Make new one + val review = new Review(Some(1), Some(Timestamp.from(Instant.now())), version.id.get, users.current.get.id.get, None, "") + this.service.insert(review) + Redirect(routes.Reviews.showReviews(author, slug, versionString)) + } + } + } + } + + def editReview(author: String, slug: String, versionString: String, reviewId: Int) = { + (Authenticated andThen PermissionAction[AuthRequest](ReviewProjects)) { implicit request => + withProject(author, slug) { implicit project => + withVersion(versionString) { version => + val review = version.reviewById(reviewId) + review.get.addMessage(Message(this.forms.ReviewDescription.bindFromRequest.get.trim)) + Ok("Review" + review) + } + } + } + } + + def addMessage(author: String, slug: String, versionString: String) = { + (Authenticated andThen PermissionAction[AuthRequest](ReviewProjects)) { implicit request => + withProject(author, slug) { implicit project => + withVersion(versionString) { version => + val recentReview = version.mostRecentUnfinishedReview + if (recentReview.isDefined) { + val currentUser = users.current + if (recentReview.get.userId == currentUser.get.userId) { + recentReview.get.addMessage(Message(this.forms.ReviewDescription.bindFromRequest.get.trim)) + } + } + Ok("Review") + } + } + } + } +} diff --git a/app/controllers/project/Pages.scala b/app/controllers/project/Pages.scala index d250eef7a..dce447981 100755 --- a/app/controllers/project/Pages.scala +++ b/app/controllers/project/Pages.scala @@ -4,9 +4,10 @@ import javax.inject.Inject import controllers.BaseController import controllers.sugar.Bakery -import db.ModelService +import db.impl.OrePostgresDriver.api._ +import db.{ModelFilter, ModelService} import form.OreForms -import models.project.Page +import models.project.{Page, Project} import ore.permission.EditPages import ore.{OreConfig, OreEnv, StatTracker} import play.api.i18n.MessagesApi @@ -33,6 +34,29 @@ class Pages @Inject()(forms: OreForms, private def PageEditAction(author: String, slug: String) = AuthedProjectAction(author, slug, requireUnlock = true) andThen ProjectPermissionAction(EditPages) + /** + * Return the best guess of the page + * + * @param project + * @param page + * @return Tuple: Optional Page, true if using legacy fallback + */ + def withPage(project: Project, page: String): (Option[Page], Boolean) = { + val parts = page.split("/") + if (parts.size == 2) { + val parentId = project.pages.find(equalsIgnoreCase(_.slug, parts(0))).map(_.id.getOrElse(-1)).getOrElse(-1) + val pages: Seq[Page] = project.pages.filter(equalsIgnoreCase(_.slug, parts(1))).seq + (pages.find(_.parentId == parentId), false) + } else { + val result = project.pages.find((ModelFilter[Page](_.slug === parts(0)) +&& ModelFilter[Page](_.parentId === -1)).fn) + if (result.isEmpty) { + (project.pages.find(ModelFilter[Page](_.slug === parts(0)).fn), true) + } else { + (result, false) + } + } + } + /** * Displays the specified page. * @@ -43,9 +67,11 @@ class Pages @Inject()(forms: OreForms, */ def show(author: String, slug: String, page: String) = ProjectAction(author, slug) { implicit request => val project = request.project - project.pages.find(equalsIgnoreCase(_.name, page)) match { - case None => notFound - case Some(p) => this.stats.projectViewed(implicit request => Ok(views.view(project, p))) + val optionPage = withPage(project, page) + if (optionPage._1.isDefined) { + this.stats.projectViewed(implicit request => Ok(views.view(project, optionPage._1.get, optionPage._2))) + } else { + notFound } } @@ -60,7 +86,16 @@ class Pages @Inject()(forms: OreForms, */ def showEditor(author: String, slug: String, page: String) = PageEditAction(author, slug) { implicit request => val project = request.project - Ok(views.view(project, project.getOrCreatePage(page), editorOpen = true)) + val parts = page.split("/") + var pageName = parts(0) + var parentId = -1 + if (parts.size == 2) { + pageName = parts(1) + parentId = project.pages.find(equalsIgnoreCase(_.slug, parts(0))).map(_.id.getOrElse(-1)).getOrElse(-1) + } + val optionPage = project.pages.find(equalsIgnoreCase(_.slug, pageName)) + val pageModel = optionPage.getOrElse(project.getOrCreatePage(pageName, parentId)) + Ok(views.view(project, pageModel, editorOpen = true)) } /** @@ -86,7 +121,7 @@ class Pages @Inject()(forms: OreForms, Redirect(self.show(author, slug, page)).withError(hasErrors.errors.head.message), pageData => { val project = request.project - val parentId = pageData.parentId.getOrElse(-1) + var parentId = pageData.parentId.getOrElse(-1) //noinspection ComparingUnrelatedTypes if (parentId != -1 && !project.rootPages.filterNot(_.name.equals(Page.HomeName)).exists(_.id.get == parentId)) { BadRequest("Invalid parent ID.") @@ -95,7 +130,13 @@ class Pages @Inject()(forms: OreForms, if (page.equals(Page.HomeName) && (content.isEmpty || content.get.length < Page.MinLength)) { Redirect(self.show(author, slug, page)).withError("error.minLength") } else { - val pageModel = project.getOrCreatePage(page, parentId) + val parts = page.split("/") + var pageName = pageData.name.getOrElse(parts(0)) + if (parts.size == 2) { + pageName = pageData.name.getOrElse(parts(1)) + parentId = project.pages.find(equalsIgnoreCase(_.slug, parts(0))).map(_.id.getOrElse(-1)).getOrElse(-1) + } + val pageModel = project.getOrCreatePage(pageName, parentId) pageData.content.map(pageModel.contents = _) Redirect(self.show(author, slug, page)) } @@ -114,7 +155,10 @@ class Pages @Inject()(forms: OreForms, */ def delete(author: String, slug: String, page: String) = PageEditAction(author, slug) { implicit request => val project = request.project - this.service.access[Page](classOf[Page]).remove(project.pages.find(equalsIgnoreCase(_.name, page)).get) + val optionPage = withPage(project, page) + if (optionPage._1.isDefined) + this.service.access[Page](classOf[Page]).remove(optionPage._1.get) + Redirect(routes.Projects.show(author, slug)) } diff --git a/app/controllers/project/Projects.scala b/app/controllers/project/Projects.scala index 90c2e3095..095878ad6 100755 --- a/app/controllers/project/Projects.scala +++ b/app/controllers/project/Projects.scala @@ -1,14 +1,20 @@ package controllers.project import java.nio.file.{Files, Path} +import java.sql.Timestamp +import java.time.Instant import javax.inject.Inject import controllers.BaseController import controllers.sugar.Bakery +import controllers.sugar.Requests.AuthRequest import db.ModelService import discourse.OreDiscourseApi import form.OreForms -import ore.permission.{EditSettings, HideProjects, PostAsOrganization, ViewLogs} +import ore.permission._ +import models.admin.Message +import models.project.{Note, Page, VisibilityTypes} +import ore.permission._ import ore.project.FlagReasons import ore.project.factory.ProjectFactory import ore.project.io.{InvalidPluginFileException, PluginUpload} @@ -16,9 +22,14 @@ import ore.user.MembershipDossier._ import ore.{OreConfig, OreEnv, StatTracker} import play.api.cache.CacheApi import play.api.i18n.MessagesApi +import play.api.libs.json._ import play.api.mvc._ +import play.twirl.api.Html import security.spauth.SingleSignOnConsumer -import util.StringUtils._ +import _root_.util.StringUtils +import _root_.util.StringUtils._ +import models.project.VisibilityTypes.Visibility +import ore.permission.scope.GlobalScope import views.html.{projects => views} import scala.collection.JavaConverters._ @@ -108,11 +119,13 @@ class Projects @Inject()(stats: StatTracker, * @return View of members config */ def showInvitationForm(author: String, slug: String) = UserLock() { implicit request => + val organisationUserCanUploadTo = request.user.organizations.all + .filter(request.user can CreateProject in _).map(_.id.get).toSeq :+ request.user.id.get this.factory.getPendingProject(author, slug) match { case None => Redirect(self.showCreator()) case Some(pendingProject) => - this.forms.ProjectSave.bindFromRequest().fold( + this.forms.ProjectSave(organisationUserCanUploadTo).bindFromRequest().fold( hasErrors => FormError(self.showCreator(), hasErrors), formData => { @@ -289,7 +302,7 @@ class Projects @Inject()(stats: StatTracker, hasErrors => FormError(ShowProject(project), hasErrors), formData => { - project.flagFor(user, formData.reason, formData.comment.orNull) + project.flagFor(user, formData.reason, formData.comment) Redirect(self.show(author, slug)).flashing("reported" -> "true") } ) @@ -449,8 +462,10 @@ class Projects @Inject()(stats: StatTracker, * @return View of project */ def save(author: String, slug: String) = SettingsEditAction(author, slug) { implicit request => + val organisationUserCanUploadTo = request.user.organizations.all + .filter(request.user can CreateProject in _).map(_.id.get).toSeq :+ request.user.id.get val project = request.project - this.forms.ProjectSave.bindFromRequest().fold( + this.forms.ProjectSave(organisationUserCanUploadTo).bindFromRequest().fold( hasErrors => FormError(self.showSettings(author, slug), hasErrors), formData => { @@ -481,22 +496,60 @@ class Projects @Inject()(stats: StatTracker, /** * Sets the visible state of the specified Project. * - * @param author Project owner - * @param slug Project slug - * @param visible Project visibility + * @param author Project owner + * @param slug Project slug + * @param visibility Project visibility * @return Ok */ - def setVisible(author: String, slug: String, visible: Boolean) = { + def setVisible(author: String, slug: String, visibility: Int) = { (AuthedProjectAction(author, slug, requireUnlock = true) andThen ProjectPermissionAction(HideProjects)) { implicit request => - request.project.setVisible(visible) + val newVisibility = VisibilityTypes.withId(visibility) + if (request.user can newVisibility.permission in GlobalScope) { + if (newVisibility.showModal) { + val comment = this.forms.NeedsChanges.bindFromRequest.get.trim + request.project.setVisibility(newVisibility, comment, request.user) + } else { + request.project.setVisibility(newVisibility, "", request.user) + } + } Ok } } + /** + * Set a project that is in new to public + * @param author Project owner + * @param slug Project slug + * @return Redirect home + */ + def publish(author: String, slug: String) = SettingsEditAction(author, slug) { implicit request => + val project = request.project + if (project.visibility == VisibilityTypes.New) { + project.setVisibility(VisibilityTypes.Public, "", request.user) + } + Redirect(self.show(project.ownerName, project.slug)) + } + + /** + * Set a project that needed changes to the approval state + * @param author Project owner + * @param slug Project slug + * @return Redirect home + */ + def sendForApproval(author: String, slug: String) = SettingsEditAction(author, slug) { implicit request => + val project = request.project + if (project.visibility == VisibilityTypes.NeedsChanges) { + project.setVisibility(VisibilityTypes.NeedsApproval, "", request.user) + } + Redirect(self.show(project.ownerName, project.slug)) + } + def showLog(author: String, slug: String) = { - (AuthedProjectAction(author, slug) andThen ProjectPermissionAction(ViewLogs)) { implicit request => - Ok(views.log(request.project)) + (Authenticated andThen PermissionAction[AuthRequest](ViewLogs)) { implicit request => + withProject(author, slug) { project => + Ok(views.log(project)) + } } } @@ -507,10 +560,63 @@ class Projects @Inject()(stats: StatTracker, * @param slug Project slug * @return Home page */ - def delete(author: String, slug: String) = SettingsEditAction(author, slug) { implicit request => + def delete(author: String, slug: String) = { + (Authenticated andThen PermissionAction[AuthRequest](HardRemoveProject)) { implicit request => + withProject(author, slug) { project => + this.projects.delete(project) + Redirect(ShowHome).withSuccess(this.messagesApi("project.deleted", project.name)) + } + } + } + + /** + * Soft deletes the specified project. + * + * @param author Project owner + * @param slug Project slug + * @return Home page + */ + def softDelete(author: String, slug: String) = SettingsEditAction(author, slug) { implicit request => val project = request.project - this.projects.delete(project) + val comment = this.forms.NeedsChanges.bindFromRequest.get.trim + project.setVisibility(VisibilityTypes.SoftDelete, comment, request.user) Redirect(ShowHome).withSuccess(this.messagesApi("project.deleted", project.name)) } -} + /** + * Show the flags that have been made on this project + * + * @param author Project owner + * @param slug Project slug + */ + def showFlags(author: String, slug: String) = { + (Authenticated andThen PermissionAction[AuthRequest](ReviewFlags)) { implicit request => + withProject(author, slug) { project => + Ok(views.admin.flags(project)) + } + } + } + + /** + * Show the notes that have been made on this project + * + * @param author Project owner + * @param slug Project slug + */ + def showNotes(author: String, slug: String) = { + (Authenticated andThen PermissionAction[AuthRequest](ReviewFlags)) { implicit request => + withProject(author, slug) { project => + Ok(views.admin.notes(project)) + } + } + } + + def addMessage(author: String, slug: String) = { + (Authenticated andThen PermissionAction[AuthRequest](ReviewProjects)) { implicit request => + withProject(author, slug) { project => + project.addNote(Note(this.forms.NoteDescription.bindFromRequest.get.trim, request.user.userId)) + Ok("Review") + } + } + } +} \ No newline at end of file diff --git a/app/controllers/project/Versions.scala b/app/controllers/project/Versions.scala index 3fc7b667d..39bde3a65 100755 --- a/app/controllers/project/Versions.scala +++ b/app/controllers/project/Versions.scala @@ -45,7 +45,7 @@ class Versions @Inject()(stats: StatTracker, implicit override val env: OreEnv, implicit override val config: OreConfig, implicit override val service: ModelService) - extends BaseController { + extends BaseController { private val fileManager = this.projects.fileManager private val self = controllers.project.routes.Versions @@ -380,10 +380,10 @@ class Versions @Inject()(stats: StatTracker, */ def download(author: String, slug: String, versionString: String, token: Option[String]) = { ProjectAction(author, slug) { implicit request => - implicit val project = request.project - withVersion(versionString) { version => - sendVersion(project, version, token) - } + implicit val project = request.project + withVersion(versionString) { version => + sendVersion(project, version, token) + } } } @@ -412,9 +412,9 @@ class Versions @Inject()(stats: StatTracker, case Some(tkn) => this.warnings.find { warn => (warn.token === tkn) && - (warn.versionId === version.id.get) && - (warn.address === InetString(StatTracker.remoteAddress)) && - warn.isConfirmed + (warn.versionId === version.id.get) && + (warn.address === InetString(StatTracker.remoteAddress)) && + warn.isConfirmed } map { warn => if (warn.hasExpired) { warn.remove() @@ -517,10 +517,10 @@ class Versions @Inject()(stats: StatTracker, // find warning this.warnings.find { warn => (warn.address === addr) && - (warn.token === token) && - (warn.versionId === version.id.get) && - !warn.isConfirmed && - (warn.downloadId === -1) + (warn.token === token) && + (warn.versionId === version.id.get) && + !warn.isConfirmed && + (warn.downloadId === -1) } map { warn => if (warn.hasExpired) { // warning has expired @@ -594,6 +594,9 @@ class Versions @Inject()(stats: StatTracker, token: Option[String], api: Boolean = false) (implicit request: ProjectRequest[_]): Result = { + if (project.visibility == VisibilityTypes.SoftDelete) { + return notFound + } if (!checkConfirmation(project, version, token)) Redirect(self.showDownloadConfirm( project.ownerName, project.slug, version.name, Some(JarFile.id), api = Some(api))) @@ -720,6 +723,9 @@ class Versions @Inject()(stats: StatTracker, private def sendSignatureFile(version: Version)(implicit request: Request[_]): Result = { val project = version.project + if (project.visibility == VisibilityTypes.SoftDelete) { + return notFound + } val path = this.fileManager.getVersionDir(project.ownerName, project.name, version.name).resolve(version.signatureFileName) if (notExists(path)) { Logger.warn("project version missing signature file") diff --git a/app/controllers/sugar/Actions.scala b/app/controllers/sugar/Actions.scala index 84fd2b42d..c93d365b6 100644 --- a/app/controllers/sugar/Actions.scala +++ b/app/controllers/sugar/Actions.scala @@ -7,10 +7,10 @@ import controllers.sugar.Requests._ import db.access.ModelAccess import db.impl.OrePostgresDriver.api._ import db.impl.access.{OrganizationBase, ProjectBase, UserBase} -import models.project.Project +import models.project.{Project, VisibilityTypes} import models.user.{SignOn, User} import ore.permission.scope.GlobalScope -import ore.permission.{EditSettings, HideProjects, Permission} +import ore.permission.{EditPages, EditSettings, HideProjects, Permission} import play.api.mvc.Results.{Redirect, Unauthorized} import play.api.mvc._ import security.spauth.SingleSignOnConsumer @@ -281,7 +281,12 @@ trait Actions extends Calls with ActionHelpers { } private def processProject(project: Project, user: Option[User]): Option[Project] = { - if (project.isVisible || (user.isDefined && (user.get can HideProjects in GlobalScope))) + if (project.visibility == VisibilityTypes.Public || project.visibility == VisibilityTypes.New + || (user.isDefined && (user.get can EditPages in project) + && (project.visibility == VisibilityTypes.NeedsChanges + || project.visibility == VisibilityTypes.NeedsApproval )) + || (user.isDefined && (user.get can HideProjects in GlobalScope) + )) Some(project) else None diff --git a/app/db/ModelService.scala b/app/db/ModelService.scala index af41dcce1..e89171278 100644 --- a/app/db/ModelService.scala +++ b/app/db/ModelService.scala @@ -8,8 +8,8 @@ import db.ModelFilter.IdFilter import db.access.ModelAccess import db.table.{MappedType, ModelTable} import slick.ast.{AnonSymbol, Ref, SortBy} -import slick.backend.DatabaseConfig -import slick.driver.{JdbcDriver, JdbcProfile} +import slick.basic.DatabaseConfig +import slick.jdbc.JdbcProfile import slick.jdbc.JdbcType import slick.lifted.{ColumnOrdered, WrappingQuery} import slick.util.ConstArray @@ -31,7 +31,7 @@ trait ModelService { val registry: ModelRegistry /** The base JDBC driver */ - val driver: JdbcDriver + val driver: JdbcProfile import driver.api._ /** diff --git a/app/db/impl/OrePostgresDriver.scala b/app/db/impl/OrePostgresDriver.scala index 0e3f54c87..dae90ad88 100644 --- a/app/db/impl/OrePostgresDriver.scala +++ b/app/db/impl/OrePostgresDriver.scala @@ -2,8 +2,9 @@ package db.impl import com.github.tminglei.slickpg._ import db.table.key.Aliases -import models.project.TagColors +import models.project.{TagColors, VisibilityTypes} import models.project.TagColors.TagColor +import models.project.VisibilityTypes.Visibility import ore.Colors import ore.Colors.Color import ore.permission.role.RoleTypes @@ -23,7 +24,7 @@ import ore.user.notification.NotificationTypes.NotificationType /** * Custom Postgres driver to support array data and custom type mappings. */ -trait OrePostgresDriver extends ExPostgresDriver with PgArraySupport with PgNetSupport { +trait OrePostgresDriver extends ExPostgresProfile with PgArraySupport with PgNetSupport { override val api = OreDriver @@ -45,6 +46,7 @@ trait OrePostgresDriver extends ExPostgresDriver with PgArraySupport with PgNetS ).to(_.toList) implicit val downloadTypeTypeMapper = MappedJdbcType.base[DownloadType, Int](_.id, DownloadTypes.apply) implicit val projectApiKeyTypeTypeMapper = MappedJdbcType.base[ProjectApiKeyType, Int](_.id, ProjectApiKeyTypes.apply) + implicit val visibiltyTypeMapper = MappedJdbcType.base[Visibility, Int](_.id, VisibilityTypes.withId) } } diff --git a/app/db/impl/access/ProjectBase.scala b/app/db/impl/access/ProjectBase.scala index 66ea3b0b2..72d4f7c29 100644 --- a/app/db/impl/access/ProjectBase.scala +++ b/app/db/impl/access/ProjectBase.scala @@ -151,7 +151,7 @@ class ProjectBase(override val service: ModelService, channel.versions.all.foreach { version: Version => val versionFolder = this.fileManager.getVersionDir(project.ownerName, project.name, version.name) - Files.deleteIfExists(versionFolder) + FileUtils.deleteDirectory(versionFolder) version.remove() } } @@ -181,7 +181,7 @@ class ProjectBase(override val service: ModelService, this.deleteChannel(channel) val versionDir = this.fileManager.getVersionDir(proj.ownerName, project.name, version.name) - Files.deleteIfExists(versionDir) + FileUtils.deleteDirectory(versionDir) } /** diff --git a/app/db/impl/access/UserBase.scala b/app/db/impl/access/UserBase.scala index 48e4d0c42..e99dd3899 100755 --- a/app/db/impl/access/UserBase.scala +++ b/app/db/impl/access/UserBase.scala @@ -66,6 +66,7 @@ class UserBase(override val service: ModelService, case ORDERING_PROJECTS => users = users.sortBy(u => (u.projects.size, u.username)) case ORDERING_JOIN_DATE => users = users.sortBy(u => (u.joinDate.getOrElse(u.createdAt.get), u.username)) case ORDERING_USERNAME => users = users.sortBy(_.username) + case ORDERING_ROLE => users = users.sortBy(_.globalRoles.toList.sortBy(_.trust).headOption.map(_.trust.level).getOrElse(-1)) case _ => users.sortBy(u => (u.projects.size, u.username)) } @@ -137,5 +138,6 @@ object UserBase { val ORDERING_PROJECTS = "projects" val ORDERING_USERNAME = "username" val ORDERING_JOIN_DATE = "joined" + val ORDERING_ROLE = "roles" } diff --git a/app/db/impl/model/common/Hideable.scala b/app/db/impl/model/common/Hideable.scala index 2d906f2c7..f1b2ffb1e 100644 --- a/app/db/impl/model/common/Hideable.scala +++ b/app/db/impl/model/common/Hideable.scala @@ -2,6 +2,7 @@ package db.impl.model.common import db.Model import db.impl.table.common.VisibilityColumn +import models.project.VisibilityTypes.Visibility /** * Represents a [[Model]] that has a toggleable visibility. @@ -16,6 +17,6 @@ trait Hideable extends Model { self => * * @return True if model is visible */ - def isVisible: Boolean + def visibility: Visibility } diff --git a/app/db/impl/schema.scala b/app/db/impl/schema.scala index 1b4e8cc54..d0f7f927f 100755 --- a/app/db/impl/schema.scala +++ b/app/db/impl/schema.scala @@ -8,7 +8,7 @@ import db.impl.schema._ import db.impl.table.common.{DescriptionColumn, DownloadsColumn, VisibilityColumn} import db.impl.table.StatTable import db.table.{AssociativeTable, ModelTable, NameColumn} -import models.admin.{ProjectLog, ProjectLogEntry} +import models.admin.{ProjectLog, ProjectLogEntry, Review, VisibilityChange} import models.api.ProjectApiKey import models.project.TagColors.TagColor import models.project._ @@ -23,6 +23,7 @@ import ore.project.io.DownloadTypes.DownloadType import ore.rest.ProjectApiKeyTypes.ProjectApiKeyType import ore.user.Prompts.Prompt import ore.user.notification.NotificationTypes.NotificationType +import slick.lifted.ProvenShape /* * Database schema definitions. Changes must be first applied as an evolutions @@ -54,10 +55,11 @@ trait ProjectTable extends ModelTable[Project] def postId = column[Int]("post_id") def isTopicDirty = column[Boolean]("is_topic_dirty") def lastUpdated = column[Timestamp]("last_updated") + def notes = column[String]("notes") override def * = (id.?, createdAt.?, pluginId, ownerName, userId, name, slug, recommendedVersionId.?, category, description.?, stars, views, downloads, topicId, postId, isTopicDirty, - isVisible, lastUpdated) <> ((Project.apply _).tupled, Project.unapply) + visibility, lastUpdated, notes) <> ((Project.apply _).tupled, Project.unapply) } @@ -335,8 +337,10 @@ class FlagTable(tag: RowTag) extends ModelTable[Flag](tag, "project_flags") { def reason = column[FlagReason]("reason") def comment = column[String]("comment") def isResolved = column[Boolean]("is_resolved") + def resolvedAt = column[Timestamp]("resolved_at") + def resolvedBy = column[Int]("resolved_by") - override def * = (id.?, createdAt.?, projectId, userId, reason, comment, isResolved) <> (Flag.tupled, Flag.unapply) + override def * = (id.?, createdAt.?, projectId, userId, reason, comment, isResolved, resolvedAt.?, resolvedBy.?) <> (Flag.tupled, Flag.unapply) } @@ -349,3 +353,25 @@ class ProjectApiKeyTable(tag: RowTag) extends ModelTable[ProjectApiKey](tag, "pr override def * = (id.?, createdAt.?, projectId, keyType, value) <> (ProjectApiKey.tupled, ProjectApiKey.unapply) } + +class ReviewTable(tag: RowTag) extends ModelTable[Review](tag, "project_version_reviews") { + + def versionId = column[Int]("version_id") + def userId = column[Int]("user_id") + def endedAt = column[Timestamp]("ended_at") + def comment = column[String]("comment") + + override def * = (id.?, createdAt.?, versionId, userId, endedAt.?, comment) <> ((Review.apply _).tupled, Review.unapply) +} + +class VisibilityChangeTable(tag: RowTag) extends ModelTable[VisibilityChange](tag, "project_visibility_changes") { + + def createdBy = column[Int]("created_by") + def projectId = column[Int]("project_id") + def comment = column[String]("comment") + def resolvedAt = column[Timestamp]("resolved_at") + def resolvedBy = column[Int]("resolved_by") + def visibility = column[Int]("visibility") + + override def * = (id.?, createdAt.?, createdBy.?, projectId, comment, resolvedAt.?, resolvedBy.?, visibility) <> (VisibilityChange.tupled, VisibilityChange.unapply) +} diff --git a/app/db/impl/schema/PageSchema.scala b/app/db/impl/schema/PageSchema.scala index 8953ddde8..8b63a3388 100644 --- a/app/db/impl/schema/PageSchema.scala +++ b/app/db/impl/schema/PageSchema.scala @@ -15,7 +15,7 @@ class PageSchema(override val service: ModelService) override def like(page: Page): Future[Option[Page]] = { this.service.find[Page](this.modelClass, p => - p.projectId === page.projectId && p.name.toLowerCase === page.name.toLowerCase + p.projectId === page.projectId && p.name.toLowerCase === page.name.toLowerCase && p.parentId === page.parentId ) } diff --git a/app/db/impl/schema/ReviewSchema.scala b/app/db/impl/schema/ReviewSchema.scala new file mode 100644 index 000000000..d9672aabc --- /dev/null +++ b/app/db/impl/schema/ReviewSchema.scala @@ -0,0 +1,14 @@ +package db.impl.schema + +import db.impl.OrePostgresDriver.api._ +import db.impl.{ReviewTable} +import db.{ModelSchema, ModelService} +import models.admin.Review + +/** + * Version related queries. + */ +class ReviewSchema(override val service: ModelService) + extends ModelSchema[Review](service, classOf[Review], TableQuery[ReviewTable]) { + +} diff --git a/app/db/impl/service/OreModelConfig.scala b/app/db/impl/service/OreModelConfig.scala index c1e77ec91..77ffce0fb 100644 --- a/app/db/impl/service/OreModelConfig.scala +++ b/app/db/impl/service/OreModelConfig.scala @@ -5,7 +5,7 @@ import db.impl._ import db.impl.schema._ import db.table.ModelAssociation import db.{ModelSchema, ModelService} -import models.admin.{ProjectLog, ProjectLogEntry} +import models.admin.{ProjectLog, ProjectLogEntry, Review, VisibilityChange} import models.api.ProjectApiKey import models.project._ import models.statistic.{ProjectView, VersionDownload} @@ -66,6 +66,8 @@ trait OreModelConfig extends ModelService with OreDBOs { val ProjectRolesSchema = new ModelSchema[ProjectRole](this, classOf[ProjectRole], TableQuery[ProjectRoleTable]) + val VisibilityChangeSchema = new ModelSchema[VisibilityChange](this, classOf[VisibilityChange], TableQuery[VisibilityChangeTable]) + val ProjectSchema = new ProjectSchema(this, Users) .withChildren[Channel](classOf[Channel], _.projectId) .withChildren[Version](classOf[Version], _.projectId) @@ -74,6 +76,7 @@ trait OreModelConfig extends ModelService with OreDBOs { .withChildren[ProjectRole](classOf[ProjectRole], _.projectId) .withChildren[ProjectView](classOf[ProjectView], _.modelId) .withChildren[ProjectApiKey](classOf[ProjectApiKey], _.projectId) + .withChildren[VisibilityChange](classOf[VisibilityChange], _.projectId) .withAssociation[ProjectWatchersTable, User]( association = this.projectWatchers, selfReference = _.projectId, @@ -104,7 +107,11 @@ trait OreModelConfig extends ModelService with OreDBOs { case object ViewSchema extends ModelSchema[ProjectView](this, classOf[ProjectView], TableQuery[ProjectViewsTable]) with StatSchema[ProjectView] - val VersionSchema = new VersionSchema(this).withChildren[VersionDownload](classOf[VersionDownload], _.modelId) + val ReviewSchema = new ModelSchema[Review](this, classOf[Review], TableQuery[ReviewTable]) + + val VersionSchema = new VersionSchema(this) + .withChildren[VersionDownload](classOf[VersionDownload], _.modelId) + .withChildren[Review](classOf[Review], _.versionId) val DownloadWarningSchema = new ModelSchema[DownloadWarning]( this, classOf[DownloadWarning], TableQuery[DownloadWarningsTable]) diff --git a/app/db/impl/service/OreModelService.scala b/app/db/impl/service/OreModelService.scala index 3914d7669..901505bd3 100644 --- a/app/db/impl/service/OreModelService.scala +++ b/app/db/impl/service/OreModelService.scala @@ -2,7 +2,6 @@ package db.impl.service import javax.inject.{Inject, Singleton} -import db.impl.OrePostgresDriver.api._ import db.impl.{OreModelProcessor, OrePostgresDriver} import db.{ModelRegistry, ModelService} import discourse.OreDiscourseApi @@ -10,7 +9,7 @@ import ore.{OreConfig, OreEnv} import play.api.db.slick.DatabaseConfigProvider import play.api.i18n.MessagesApi import security.spauth.SpongeAuthApi -import slick.driver.JdbcProfile +import slick.jdbc.JdbcProfile import scala.concurrent.duration._ @@ -60,6 +59,7 @@ class OreModelService @Inject()(override val env: OreEnv, registerSchema(ProjectLogEntrySchema) registerSchema(FlagSchema) registerSchema(ViewSchema) + registerSchema(ReviewSchema) registerSchema(VersionSchema) registerSchema(TagSchema) registerSchema(DownloadWarningSchema) @@ -71,6 +71,7 @@ class OreModelService @Inject()(override val env: OreEnv, registerSchema(OrganizationSchema) registerSchema(OrganizationRoleSchema) registerSchema(ProjectApiKeySchema) + registerSchema(VisibilityChangeSchema) Logger.info( "Database initialized:\n" + diff --git a/app/db/impl/table/ModelKeys.scala b/app/db/impl/table/ModelKeys.scala index b0eb2d763..6cd02a0ce 100755 --- a/app/db/impl/table/ModelKeys.scala +++ b/app/db/impl/table/ModelKeys.scala @@ -4,11 +4,12 @@ import db.Named import db.impl.OrePostgresDriver.api._ import db.impl.model.common.{Describable, Downloadable, Hideable} import db.table.key._ -import models.admin.ProjectLogEntry +import models.admin.{ProjectLogEntry, Review, VisibilityChange} +import models.project.VisibilityTypes.Visibility import models.project._ import models.statistic.StatEntry import models.user.role.RoleModel -import models.user.{Notification, SignOn, User} +import models.user.{Notification, Organization, SignOn, User} import ore.Colors.Color import ore.permission.role.RoleTypes.RoleType import ore.project.Categories.Category @@ -23,7 +24,7 @@ object ModelKeys { val Name = new StringKey[Named](_.name, _.name) val Downloads = new IntKey[Downloadable](_.downloads, _.downloadCount) val Description = new StringKey[Describable](_.description, _.description.orNull) - val IsVisible = new BooleanKey[Hideable](_.isVisible, _.isVisible) + val Visibility = new MappedTypeKey[Project, Visibility](_.visibility, _.visibility) // Project val OwnerId = new IntKey[Project](_.userId, _.ownerId) @@ -38,6 +39,7 @@ object ModelKeys { val RecommendedVersionId = new IntKey[Project]( _.recommendedVersionId, _.recommendedVersion.id.getOrElse(-1)) val LastUpdated = new TimestampKey[Project](_.lastUpdated, _.lastUpdated) + val Notes = new StringKey[Project](_.notes, _._notes) // ProjectSettings val Issues = new StringKey[ProjectSettings](_.issues, _.issues.orNull) @@ -61,6 +63,9 @@ object ModelKeys { val AvatarUrl = new StringKey[User](_.avatarUrl, _.avatarUrl.orNull) val ReadPrompts = new Key[User, List[Prompt]](_.readPrompts, _.readPrompts.toList) + // Organization + val OrgOwnerId = new IntKey[Organization](_.userId, _.owner.userId) + // SignOn val IsCompleted = new BooleanKey[SignOn](_.isCompleted, _.isCompleted) @@ -92,6 +97,8 @@ object ModelKeys { // Flag val IsResolved = new BooleanKey[Flag](_.isResolved, _.isResolved) + val ResolvedAt = new TimestampKey[Flag](_.resolvedAt, _.resolvedAt.orNull) + val ResolvedBy = new IntKey[Flag](_.resolvedBy, _.resolvedBy.getOrElse(-1)) // StatEntry val UserId = new IntKey[StatEntry[_]](_.userId, _.user.flatMap(_.id).getOrElse(-1)) @@ -99,4 +106,11 @@ object ModelKeys { // Notification val Read = new BooleanKey[Notification](_.read, _.isRead) + // Review + val Comment = new StringKey[Review](_.comment, _.message) + val EndedAt = new TimestampKey[Review](_.endedAt, _.endedAt.get) + + // VisibilityChange + val ResolvedByVC = new IntKey[VisibilityChange](_.resolvedBy, _.resolvedBy.get) + val ResolvedAtVC = new TimestampKey[VisibilityChange](_.resolvedAt, _.resolvedAt.get) } diff --git a/app/db/impl/table/common/VisibilityColumn.scala b/app/db/impl/table/common/VisibilityColumn.scala index c352bcbbb..065f35de9 100644 --- a/app/db/impl/table/common/VisibilityColumn.scala +++ b/app/db/impl/table/common/VisibilityColumn.scala @@ -3,6 +3,7 @@ package db.impl.table.common import db.impl.OrePostgresDriver.api._ import db.impl.model.common.Hideable import db.table.ModelTable +import models.project.VisibilityTypes.Visibility /** * Represents a column in a [[ModelTable]] representing the visibility of the @@ -17,6 +18,6 @@ trait VisibilityColumn[M <: Hideable] extends ModelTable[M] { * * @return Visibility column */ - def isVisible = column[Boolean]("is_visible") + def visibility = column[Visibility]("visibility") } diff --git a/app/filters/MimeKeyedCspFilter.scala b/app/filters/MimeKeyedCspFilter.scala new file mode 100644 index 000000000..94c83c851 --- /dev/null +++ b/app/filters/MimeKeyedCspFilter.scala @@ -0,0 +1,68 @@ +package filters + +import javax.inject.{Inject, Singleton} + +import akka.stream.Materializer +import play.api.Configuration +import play.api.http.HeaderNames.CONTENT_TYPE +import play.api.mvc.{Filter, RequestHeader, Result} +import play.filters.headers.SecurityHeadersFilter.CONTENT_SECURITY_POLICY_HEADER + +import scala.collection.JavaConverters._ +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class MimeKeyedCspFilter @Inject()(implicit val mat: Materializer, ec: ExecutionContext, conf: Configuration) extends Filter { + + private type CspSpec = Map[String, Seq[String]] + + private val keyWords = Set("self", "unsafe-inline", "none") + private val defaultMime: String = compileHeader(readCspSpec(conf.getConfig("filters.csp.default"))) + private val mimeLookup: Map[String, String] = { + conf.getConfig("filters.csp.per-mime") match { + case Some(perMime) => perMime.keys.map(k => (k, compileHeader(readCspSpec(perMime.getConfig(k))))).toMap + case None => Map.empty + } + } + + private def readCspSpec(conf: Option[Configuration]): CspSpec = { + conf match { + case Some(defaults) => + defaults.keys.map { k => + (k, defaults.getStringList(k).map(_.asScala).getOrElse(Seq.empty)) + }.toMap.filter(_._2.nonEmpty) + case None => Map.empty + } + } + + private def compileHeader(section: CspSpec): String = { + val parts = for ((key, values) <- section) yield { + val combinedValue = values.map { + case v if keyWords.contains(v) => s"'$v'" + case v => v + }.mkString(" ") + s"${key.toLowerCase} $combinedValue" + } + + parts.mkString("; ") + } + + private def detectMime(request: RequestHeader): Option[String] = { + request.path match { + case p if p.endsWith(".svg") => Some("image/svg") + case _ => None + } + } + + override def apply(next: (RequestHeader) => Future[Result])(request: RequestHeader): Future[Result] = { + next(request) map { result => + val contentType = result.header.headers.get(CONTENT_TYPE) match { + case ct @ Some(_) => ct + case _ => detectMime(request) + } + val csp = contentType.flatMap(mimeLookup.get).getOrElse(defaultMime) + result.withHeaders(CONTENT_SECURITY_POLICY_HEADER -> csp) + } + } + +} diff --git a/app/form/OreForms.scala b/app/form/OreForms.scala index 1e255abc8..9d2dec28e 100755 --- a/app/form/OreForms.scala +++ b/app/form/OreForms.scala @@ -19,6 +19,7 @@ import ore.rest.ProjectApiKeyTypes.ProjectApiKeyType import play.api.data.{Form, FormError} import play.api.data.Forms._ import play.api.data.format.Formatter +import play.api.data.validation.{Constraint, Invalid, Valid, ValidationError} import scala.util.Try @@ -61,13 +62,34 @@ class OreForms @Inject()(implicit config: OreConfig, factory: ProjectFactory, se */ lazy val ProjectFlag = Form(mapping( "flag-reason" -> number, - "comment" -> optional(nonEmptyText)) + "comment" -> nonEmptyText) (FlagForm.apply)(FlagForm.unapply)) + + /** + * This is a Constraint checker for the ownerId that will search the list allowedIds to see if the number is in it. + * @param allowedIds number that are allowed as ownerId + * @return Constraint + */ + def ownerIdInList(allowedIds: Seq[Int]): Constraint[Option[Int]] = Constraint("constraints.check")({ + ownerId => + var errors: Seq[ValidationError] = Seq() + if (ownerId.isDefined) { + if (!allowedIds.contains(ownerId.get)) { + errors = Seq(ValidationError("error.plugin")) + } + } + if (errors.isEmpty) { + Valid + } else { + Invalid(errors) + } + }) + /** * Submits settings changes for a Project. */ - lazy val ProjectSave = Form(mapping( + def ProjectSave(organisationUserCanUploadTo: Seq[Int]) = Form(mapping( "category" -> text, "issues" -> url, "source" -> url, @@ -79,7 +101,7 @@ class OreForms @Inject()(implicit config: OreConfig, factory: ProjectFactory, se "userUps" -> list(text), "roleUps" -> list(text), "update-icon" -> boolean, - "owner" -> optional(number) + "owner" -> optional(number).verifying(ownerIdInList(organisationUserCanUploadTo)) )(ProjectSettingsForm.apply)(ProjectSettingsForm.unapply)) /** @@ -146,6 +168,7 @@ class OreForms @Inject()(implicit config: OreConfig, factory: ProjectFactory, se */ lazy val PageEdit = Form(mapping( "parent-id" -> optional(number), + "name" -> optional(text), "content" -> optional(text( maxLength = MaxLength )))(PageSaveForm.apply)(PageSaveForm.unapply)) @@ -218,4 +241,16 @@ class OreForms @Inject()(implicit config: OreConfig, factory: ProjectFactory, se "recommended" -> default(boolean, true)) (VersionDeployForm.apply)(VersionDeployForm.unapply)) + + lazy val ReviewDescription = Form(single("content" -> text)) + + lazy val UserAdminUpdate = Form(tuple( + "thing" -> text, + "action" -> text, + "data" -> text + )) + + lazy val NoteDescription = Form(single("content" -> text)) + + lazy val NeedsChanges = Form(single("comment" -> text)) } diff --git a/app/form/project/FlagForm.scala b/app/form/project/FlagForm.scala index 2f727730c..1083c9a69 100644 --- a/app/form/project/FlagForm.scala +++ b/app/form/project/FlagForm.scala @@ -3,7 +3,7 @@ package form.project import ore.project.FlagReasons import ore.project.FlagReasons.FlagReason -case class FlagForm(reasonId: Int, comment: Option[String]) { +case class FlagForm(reasonId: Int, comment: String) { val reason: FlagReason = FlagReasons.values.find(_.id == reasonId).getOrElse(FlagReasons.Other) diff --git a/app/form/project/PageSaveForm.scala b/app/form/project/PageSaveForm.scala index 31487684f..30794769f 100644 --- a/app/form/project/PageSaveForm.scala +++ b/app/form/project/PageSaveForm.scala @@ -1,3 +1,3 @@ package form.project -case class PageSaveForm(parentId: Option[Int], content: Option[String]) +case class PageSaveForm(parentId: Option[Int], name: Option[String], content: Option[String]) diff --git a/app/models/admin/Review.scala b/app/models/admin/Review.scala new file mode 100644 index 000000000..8896b0bb9 --- /dev/null +++ b/app/models/admin/Review.scala @@ -0,0 +1,136 @@ +package models.admin + +import java.sql.Timestamp +import util.StringUtils +import ore.OreConfig +import play.twirl.api.Html +import play.api.libs.json._ +import play.api.libs.functional.syntax._ +import db.Model +import db.impl.ReviewTable +import db.impl.model.OreModel +import db.impl.schema.ReviewSchema +import db.impl.table.ModelKeys._ +import models.project.{Project, Version, Page} + + +/** + * Represents an approval instance of [[Project]] [[Version]]. + * + * @param id Unique ID + * @param createdAt When it was created + * @param versionId User who is approving + * @param userId User who is approving + * @param endedAt When the approval process ended + * @param message Message of why it ended + */ +case class Review(override val id: Option[Int] = None, + override val createdAt: Option[Timestamp] = None, + versionId: Int = -1, + userId: Int, + var endedAt: Option[Timestamp], + var message: String) extends OreModel(id, createdAt) { + + /** Self referential type */ + override type M = Review + /** The model's table */ + override type T = ReviewTable + /** The model's schema */ + override type S = ReviewSchema + + /** + * Set a message and update the database + * @param content + * @return + */ + private def setMessage(content: String) = { + this.message = content + update(Comment) + } + + /** + * Add new message + * @param message + * @return + */ + def addMessage(message: Message) = { + + /** + * Helper function to encode to json + */ + implicit val messageWrites = new Writes[Message] { + def writes(message: Message) = Json.obj( + "message" -> message.message, + "time" -> message.time, + "action" -> message.action + ) + } + + val messages = getMessages() :+ message + val js: Seq[JsValue] = messages.map(m => Json.toJson(m)) + setMessage( + Json.stringify( + JsObject(Seq( + "messages" -> JsArray( + js + ) + )) + ) + ) + } + + /** + * Get all messages + * @return + */ + def getMessages(): Seq[Message] = { + if (message.startsWith("{") && message.endsWith("}")) { + val messages: JsValue = Json.parse(message) + (messages \ "messages").as[Seq[Message]] + } else { + Seq() + } + } + + /** + * Set time and update in db + * @param time + * @return + */ + def setEnded(time: Timestamp) = { + this.endedAt = Some(time) + update(EndedAt) + } + + /** + * Returns a copy of this model with an updated ID and timestamp. + * + * @param id ID to set + * @param theTime Timestamp + * @return Copy of model + */ + override def copyWith(id: Option[Int], theTime: Option[Timestamp]): Model = this.copy(id = id, createdAt = createdAt) + + /** + * Helper function to decode the json + */ + implicit val messageReads: Reads[Message] = ( + (JsPath \ "message").read[String] and + (JsPath \ "time").read[Long] and + (JsPath \ "action").read[String] + )(Message.apply _) + + +} + +/** + * This modal is needed to convert the json + * @param time + * @param message + */ +case class Message(message: String, time: Long = System.currentTimeMillis(), action: String = "message") { + def getTime(implicit oreConfig: OreConfig) = StringUtils.prettifyDateAndTime(new Timestamp(time)) + def isTakeover() = action.equalsIgnoreCase("takeover") + def isStop() = action.equalsIgnoreCase("stop") + def render(implicit oreConfig: OreConfig): Html = Page.Render(message) +} diff --git a/app/models/admin/VisibilityChange.scala b/app/models/admin/VisibilityChange.scala new file mode 100644 index 000000000..ff98abc50 --- /dev/null +++ b/app/models/admin/VisibilityChange.scala @@ -0,0 +1,58 @@ +package models.admin + +import java.sql.Timestamp + +import db.Model +import db.impl.VisibilityChangeTable +import db.impl.model.OreModel +import db.impl.table.ModelKeys._ +import models.project.Page +import models.user.User +import play.twirl.api.Html + +case class VisibilityChange(override val id: Option[Int] = None, + override val createdAt: Option[Timestamp] = None, + createdBy: Option[Int] = None, + projectId: Int = -1, + comment: String, + var resolvedAt: Option[Timestamp] = None, + var resolvedBy: Option[Int] = None, + visibility: Int = 1) extends OreModel(id, createdAt) { + /** Self referential type */ + override type M = VisibilityChange + /** The model's table */ + override type T = VisibilityChangeTable + + /** Render the comment as Html */ + def renderComment(): Html = Page.Render(comment) + + /** Check if the change has been dealt with */ + def isResolved: Boolean = !resolvedAt.isEmpty + + /** + * Set the resolvedAt time + * @param time + */ + def setResolvedAt(time: Timestamp) = { + this.resolvedAt = Some(time) + update(ResolvedAtVC) + } + + /** + * Set the resolvedBy user + * @param user + */ + def setResolvedBy(user: User) = { + this.resolvedBy = user.id + update(ResolvedByVC) + } + + /** + * Returns a copy of this model with an updated ID and timestamp. + * + * @param id ID to set + * @param theTime Timestamp + * @return Copy of model + */ + override def copyWith(id: Option[Int], theTime: Option[Timestamp]): Model = this.copy(id = id, createdAt = createdAt) +} diff --git a/app/models/project/Flag.scala b/app/models/project/Flag.scala index e7e03750d..358b1e07f 100644 --- a/app/models/project/Flag.scala +++ b/app/models/project/Flag.scala @@ -1,10 +1,12 @@ package models.project import java.sql.Timestamp +import java.time.Instant import db.impl.FlagTable import db.impl.model.OreModel import db.impl.table.ModelKeys._ +import models.user.User import ore.permission.scope.ProjectScope import ore.project.FlagReasons.FlagReason import ore.user.UserOwned @@ -25,7 +27,9 @@ case class Flag(override val id: Option[Int], override val userId: Int, reason: FlagReason, comment: String, - private var _isResolved: Boolean = false) + private var _isResolved: Boolean = false, + var resolvedAt: Option[Timestamp] = None, + var resolvedBy: Option[Int] = None) extends OreModel(id, createdAt) with UserOwned with ProjectScope { @@ -50,9 +54,15 @@ case class Flag(override val id: Option[Int], * * @param resolved True if resolved */ - def setResolved(resolved: Boolean) = Defined { + def setResolved(resolved: Boolean, user: Option[User]) = Defined { this._isResolved = resolved update(IsResolved) + if (resolved) { + this.resolvedAt = Some(Timestamp.from(Instant.now)) + update(ResolvedAt) + this.resolvedBy = Some(user.flatMap(_.id).getOrElse(-1)) + update(ResolvedBy) + } } override def copyWith(id: Option[Int], theTime: Option[Timestamp]) = this.copy(id = id, createdAt = theTime) diff --git a/app/models/project/Page.scala b/app/models/project/Page.scala index f65ac210f..d6becdcc1 100644 --- a/app/models/project/Page.scala +++ b/app/models/project/Page.scala @@ -11,10 +11,11 @@ import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension import com.vladsch.flexmark.ext.tables.TablesExtension import com.vladsch.flexmark.ext.typographic.TypographicExtension -import com.vladsch.flexmark.html.renderer.{LinkStatus, LinkType, NodeRendererContext, ResolvedLink} +import com.vladsch.flexmark.ext.wikilink.{WikiLink, WikiLinkExtension} +import com.vladsch.flexmark.html.renderer._ import com.vladsch.flexmark.html.{HtmlRenderer, LinkResolver, LinkResolverFactory} import com.vladsch.flexmark.parser.Parser -import com.vladsch.flexmark.util.options.MutableDataSet +import com.vladsch.flexmark.util.options.{MutableDataHolder, MutableDataSet} import db.access.ModelAccess import db.impl.OrePostgresDriver.api._ import db.impl.PageTable @@ -22,6 +23,7 @@ import db.impl.model.OreModel import db.impl.schema.PageSchema import db.impl.table.ModelKeys._ import db.{ModelFilter, Named} +import models.project import ore.permission.scope.ProjectScope import ore.{OreConfig, Visitable} import play.twirl.api.Html @@ -99,14 +101,34 @@ case class Page(override val id: Option[Int] = None, * * @return HTML representation */ - def html: Html = Render(this.contents) + def html: Html = RenderPage(this) /** * Returns true if this is the home page. * * @return True if home page */ - def isHome: Boolean = this.name.equals(HomeName) + def isHome: Boolean = this.name.equals(HomeName) && parentId == -1 + + /** + * Get Project associated with page. + * + * @return Optional Project + */ + def parentProject: Option[Project] = this.projectBase.get(projectId) + + /** + * + * @return + */ + def parentPage: Option[Page] = if (parentProject.isDefined) { parentProject.get.pages.find(ModelFilter[Page](_.id === parentId).fn).lastOption } else { None } + + /** + * Get the /:parent/:child + * + * @return String + */ + def fullSlug: String = if (parentPage.isDefined) { s"${parentPage.get.slug}/${slug}" } else { slug } /** * Returns access to this Page's children (if any). @@ -116,7 +138,7 @@ case class Page(override val id: Option[Int] = None, def children: ModelAccess[Page] = this.service.access[Page](classOf[Page], ModelFilter[Page](_.parentId === this.id.get)) - override def url: String = this.project.url + "/pages/" + this.slug + override def url: String = this.project.url + "/pages/" + this.fullSlug override def copyWith(id: Option[Int], theTime: Option[Timestamp]) = this.copy(id = id, createdAt = theTime) } @@ -132,13 +154,13 @@ object Page { override def affectsGlobalScope() = false - override def create(context: NodeRendererContext) = new ExternalLinkResolver(this.config) + override def create(context: LinkResolverContext) = new ExternalLinkResolver(this.config) } } private class ExternalLinkResolver(config: OreConfig) extends LinkResolver { - override def resolveLink(node: Node, context: NodeRendererContext, link: ResolvedLink): ResolvedLink = { + override def resolveLink(node: Node, context: LinkResolverContext, link: ResolvedLink): ResolvedLink = { if (link.getLinkType.equals(LinkType.IMAGE) || node.isInstanceOf[MailLink]) { link } else { @@ -191,7 +213,8 @@ object Page { StrikethroughExtension.create(), TaskListExtension.create(), TablesExtension.create(), - TypographicExtension.create() + TypographicExtension.create(), + WikiLinkExtension.create() )) (Parser.builder(options).build(), HtmlRenderer.builder(options) @@ -206,6 +229,19 @@ object Page { Html(htmlRenderer.render(markdownParser.parse(markdown))) } + def RenderPage(page: Page)(implicit config: OreConfig): Html = { + if (linkResolver.isEmpty) + linkResolver = Some(new ExternalLinkResolver.Factory(config)) + + val options = new MutableDataSet().set[String](WikiLinkExtension.LINK_ESCAPE_CHARS, " +<>") + val project = page.parentProject + + if (project.isDefined) + options.set[String](WikiLinkExtension.LINK_PREFIX, s"/${project.get.ownerName}/${project.get.slug}/pages/") + + Html(htmlRenderer.withOptions(options).render(markdownParser.parse(page._contents))) + } + /** * The name of each Project's homepage. */ diff --git a/app/models/project/Project.scala b/app/models/project/Project.scala index 3cd0d83f0..baf4cf066 100755 --- a/app/models/project/Project.scala +++ b/app/models/project/Project.scala @@ -1,6 +1,7 @@ package models.project import java.sql.Timestamp +import java.time.Instant import com.google.common.base.Preconditions._ import db.access.ModelAccess @@ -12,18 +13,24 @@ import db.impl.schema.ProjectSchema import db.impl.table.ModelKeys import db.impl.table.ModelKeys._ import db.{ModelService, Named} -import models.admin.ProjectLog +import models.admin.{ProjectLog, VisibilityChange} import models.api.ProjectApiKey import models.statistic.ProjectView import models.user.User import models.user.role.ProjectRole +import ore.permission.role.RoleTypes import ore.permission.scope.ProjectScope import ore.project.Categories.Category import ore.project.FlagReasons.FlagReason import ore.project.{Categories, ProjectMember} import ore.user.MembershipDossier -import ore.{Joinable, Visitable} -import util.StringUtils._ +import ore.{Joinable, OreConfig, Visitable} +import play.api.libs.json._ +import play.api.libs.functional.syntax._ +import play.twirl.api.Html +import _root_.util.StringUtils +import _root_.util.StringUtils._ +import models.project.VisibilityTypes.{Public, Visibility} /** * Represents an Ore package. @@ -44,8 +51,9 @@ import util.StringUtils._ * @param _topicId ID of forum topic * @param _postId ID of forum topic post ID * @param _isTopicDirty Whether this project's forum topic needs to be updated - * @param _isVisible Whether this project is visible to the default user + * @param _visibility Whether this project is visible to the default user * @param _lastUpdated Instant of last version release + * @param _notes JSON notes */ case class Project(override val id: Option[Int] = None, override val createdAt: Option[Timestamp] = None, @@ -63,8 +71,9 @@ case class Project(override val id: Option[Int] = None, private var _topicId: Int = -1, private var _postId: Int = -1, private var _isTopicDirty: Boolean = false, - private var _isVisible: Boolean = true, - private var _lastUpdated: Timestamp = null) + private var _visibility: Visibility = Public, + private var _lastUpdated: Timestamp = null, + var _notes: String = "") extends OreModel(id, createdAt) with ProjectScope with Downloadable @@ -122,6 +131,14 @@ case class Project(override val id: Option[Int] = None, */ override def owner: ProjectMember = new ProjectMember(this, this.ownerId) + override def transferOwner(member: ProjectMember) { + // Down-grade current owner to "Developer" + this.memberships.getRoles(this.owner.user).filter(_.roleType == RoleTypes.ProjectOwner) + .foreach(_.roleType = RoleTypes.ProjectDev); + this.memberships.getRoles(member.user).foreach(_.roleType = RoleTypes.ProjectOwner); + this.owner = member.user; + } + /** * Sets the [[User]] that owns this Project. * @@ -255,18 +272,35 @@ case class Project(override val id: Option[Int] = None, * * @return True if visible */ - override def isVisible: Boolean = this._isVisible + override def visibility: Visibility = this._visibility /** * Sets whether this project is visible. * - * @param visible True if visible + * @param visibility True if visible */ - def setVisible(visible: Boolean) = { - this._isVisible = visible - if (isDefined) update(IsVisible) + def setVisibility(visibility: Visibility, comment: String, creator: User) = { + this._visibility = visibility + if (isDefined) update(ModelKeys.Visibility) + + if (lastVisibilityChange.isDefined) { + val visibilityChange =lastVisibilityChange.get + visibilityChange.setResolvedAt(Timestamp.from(Instant.now())) + visibilityChange.setResolvedBy(creator) + } + + this.service.access[VisibilityChange](classOf[VisibilityChange]).add(VisibilityChange(None, Some(Timestamp.from(Instant.now())), creator.id, this.id.get, comment, None, None, visibility.id)) } + /** + * Get VisibilityChanges + */ + def visibilityChanges = this.schema.getChildren[VisibilityChange](classOf[VisibilityChange], this) + def visibilityChangesByDate = visibilityChanges.all.toSeq.sortWith(byCreationDate) + def byCreationDate(first: VisibilityChange, second: VisibilityChange) = first.createdAt.getOrElse(Timestamp.from(Instant.MIN)).getTime < second.createdAt.getOrElse(Timestamp.from(Instant.MIN)).getTime + def lastVisibilityChange: Option[VisibilityChange] = visibilityChanges.all.toSeq.filter(cr => !cr.isResolved).sortWith(byCreationDate).headOption + def lastChangeRequest: Option[VisibilityChange] = visibilityChanges.all.toSeq.filter(cr => cr.visibility == VisibilityTypes.NeedsChanges.id).sortWith(byCreationDate).lastOption + /** * Returns the last time this [[Project]] was updated. * @@ -528,6 +562,78 @@ case class Project(override val id: Option[Int] = None, override def hashCode() = this.id.get.hashCode override def equals(o: Any) = o.isInstanceOf[Project] && o.asInstanceOf[Project].id.get == this.id.get + /** + * Set a message and update the database + * @param content + * @return + */ + private def setNote(content: String) = { + this._notes = content + update(Notes) + } + + /** + * Helper function to decode the json + */ + implicit val notesRead: Reads[Note] = ( + (JsPath \ "message").read[String] and + (JsPath \ "user").read[Int] and + (JsPath \ "time").read[Long] + ) (Note.apply _) + + /** + * Add new note + * @param message + * @return + */ + def addNote(message: Note) = { + + /** + * Helper function to encode to json + */ + implicit val noteWrites = new Writes[Note] { + def writes(note: Note) = Json.obj( + "message" -> note.message, + "user" -> note.user, + "time" -> note.time + ) + } + + val messages = getNotes() :+ message + val js: Seq[JsValue] = messages.map(m => Json.toJson(m)) + setNote( + Json.stringify( + JsObject(Seq( + "messages" -> JsArray( + js + ) + )) + ) + ) + } + + /** + * Get all messages + * @return + */ + def getNotes(): Seq[Note] = { + if (this._notes.startsWith("{") && _notes.endsWith("}")) { + val messages: JsValue = Json.parse(_notes) + (messages \ "messages").as[Seq[Note]] + } else { + Seq() + } + } +} + +/** + * This modal is needed to convert the json + * @param time + * @param message + */ +case class Note(message: String, user: Int, time: Long = System.currentTimeMillis()) { + def getTime(implicit oreConfig: OreConfig) = StringUtils.prettifyDateAndTime(new Timestamp(time)) + def render(implicit oreConfig: OreConfig): Html = Page.Render(message) } object Project { @@ -543,6 +649,7 @@ object Project { private var _ownerName: String = _ private var _ownerId: Int = -1 private var _name: String = _ + private var _visibility: Visibility = _ def pluginId(pluginId: String) = { this._pluginId = pluginId @@ -564,6 +671,11 @@ object Project { this } + def visibility(visibility: Visibility) = { + this._visibility = visibility + this + } + def build(): Project = { checkNotNull(this._pluginId, "plugin id null", "") checkNotNull(this._ownerName, "owner name null", "") @@ -574,7 +686,8 @@ object Project { _ownerName = this._ownerName, _ownerId = this._ownerId, _name = this._name, - _slug = slugify(this._name) + _slug = slugify(this._name), + _visibility = this._visibility )) } diff --git a/app/models/project/Version.scala b/app/models/project/Version.scala index 1bc1623d2..f07d38df6 100755 --- a/app/models/project/Version.scala +++ b/app/models/project/Version.scala @@ -1,16 +1,18 @@ package models.project import java.sql.Timestamp +import java.time.Instant import com.google.common.base.Preconditions.{checkArgument, checkNotNull} import db.ModelService import db.access.ModelAccess import db.impl.OrePostgresDriver.api._ -import db.impl.VersionTable +import db.impl.{ReviewTable, VersionTable} import db.impl.model.OreModel import db.impl.model.common.{Describable, Downloadable} import db.impl.schema.VersionSchema import db.impl.table.ModelKeys._ +import models.admin.Review import models.statistic.VersionDownload import models.user.User import ore.Visitable @@ -18,6 +20,7 @@ import ore.permission.scope.ProjectScope import ore.project.Dependency import play.twirl.api.Html import util.FileUtils +import util.StringUtils.equalsIgnoreCase /** * Represents a single version of a Project. @@ -259,6 +262,14 @@ case class Version(override val id: Option[Int] = None, override def hashCode() = this.id.hashCode override def equals(o: Any) = o.isInstanceOf[Version] && o.asInstanceOf[Version].id.get == this.id.get + def byCreationDate(first: Review, second: Review) = first.createdAt.getOrElse(Timestamp.from(Instant.MIN)).getTime < second.createdAt.getOrElse(Timestamp.from(Instant.MIN)).getTime + def reviewEntries = this.schema.getChildren[Review](classOf[Review], this) + def unfinishedReviews: Seq[Review] = reviewEntries.all.toSeq.filter(rev => rev.createdAt.isDefined && rev.endedAt.isEmpty).sortWith(byCreationDate) + def mostRecentUnfinishedReview: Option[Review] = unfinishedReviews.headOption + def mostRecentReviews: Seq[Review] = reviewEntries.toSeq.sortWith(byCreationDate) + def reviewById(id: Int): Option[Review] = reviewEntries.find(equalsInt[ReviewTable](_.id, id)) + def equalsInt[T <: Table[_]](int1: T => Rep[Int], int2: Int): T => Rep[Boolean] = int1(_) === int2 + } object Version { diff --git a/app/models/project/Visibility.scala b/app/models/project/Visibility.scala new file mode 100644 index 000000000..61338aafd --- /dev/null +++ b/app/models/project/Visibility.scala @@ -0,0 +1,25 @@ +package models.project + +import db.impl.OrePostgresDriver +import db.table.MappedType +import ore.permission.{Permission, ReviewProjects} +import slick.jdbc.JdbcType + + +object VisibilityTypes extends Enumeration { + val Public = Visibility(1, "public" , ReviewProjects, false, "") + val New = Visibility(2, "new" , ReviewProjects, false, "project-new") + val NeedsChanges = Visibility(3, "needsChanges" , ReviewProjects, true, "striped project-needsChanges") + val NeedsApproval = Visibility(4, "needsApproval" , ReviewProjects, false, "striped project-needsChanges") + val SoftDelete = Visibility(5, "softDelete" , ReviewProjects, true, "striped project-hidden") + + def withId(id: Int): Visibility = { + this.apply(id).asInstanceOf[Visibility] + } + + case class Visibility(override val id: Int, nameKey: String, permission: Permission, showModal: Boolean, cssClass: String) extends super.Val(id) with MappedType[Visibility] { + implicit val mapper: JdbcType[Visibility] = OrePostgresDriver.api.visibiltyTypeMapper + } + + implicit def convert(value: Value): Visibility = value.asInstanceOf[Visibility] +} diff --git a/app/models/user/Organization.scala b/app/models/user/Organization.scala index 347e3e1f3..956b9152c 100644 --- a/app/models/user/Organization.scala +++ b/app/models/user/Organization.scala @@ -2,12 +2,15 @@ package models.user import java.sql.Timestamp +import com.google.common.base.Preconditions._ import db.impl.access.UserBase import db.impl.model.OreModel import db.impl.{OrganizationMembersTable, OrganizationRoleTable, OrganizationTable} +import db.impl.table.ModelKeys._ import db.{Model, Named} import models.user.role.OrganizationRole import ore.organization.OrganizationMember +import ore.permission.role.RoleTypes import ore.permission.scope.OrganizationScope import ore.user.{MembershipDossier, UserOwned} import ore.{Joinable, Visitable} @@ -25,7 +28,7 @@ import ore.{Joinable, Visitable} case class Organization(override val id: Option[Int] = None, override val createdAt: Option[Timestamp] = None, username: String, - ownerId: Int) + private var ownerId: Int) extends OreModel(id, createdAt) with UserOwned with OrganizationScope @@ -62,6 +65,29 @@ case class Organization(override val id: Option[Int] = None, */ override def owner: OrganizationMember = new OrganizationMember(this, this.ownerId) + override def transferOwner(member: OrganizationMember) { + // Down-grade current owner to "Admin" + this.memberships.getRoles(this.owner.user).filter(_.roleType == RoleTypes.OrganizationOwner) + .foreach(_.roleType = RoleTypes.OrganizationAdmin); + this.memberships.getRoles(member.user).foreach(_.roleType = RoleTypes.OrganizationOwner); + this.owner = member.user; + } + + + /** + * Sets the [[User]] that owns this Organization. + * + * @param user User that owns this organization + */ + def owner_=(user: User) = { + checkNotNull(user, "null user", "") + checkArgument(user.isDefined, "undefined user", "") + this.ownerId = user.id.get + if (isDefined) { + update(OrgOwnerId) + } + } + /** * Returns this Organization as a [[User]]. * diff --git a/app/models/user/User.scala b/app/models/user/User.scala index dda91f67b..c0a7060b1 100644 --- a/app/models/user/User.scala +++ b/app/models/user/User.scala @@ -3,14 +3,14 @@ package models.user import java.sql.Timestamp import com.google.common.base.Preconditions._ -import db.Named +import db.{ModelFilter, Named} import db.access.ModelAccess import db.impl.OrePostgresDriver.api._ import db.impl._ import db.impl.access.{OrganizationBase, UserBase} import db.impl.model.OreModel import db.impl.table.ModelKeys._ -import models.project.{Flag, Project, Version} +import models.project.{Flag, Project, Version, VisibilityTypes} import models.user.role.{OrganizationRole, ProjectRole} import ore.{OreConfig, Visitable} import ore.permission._ @@ -321,8 +321,9 @@ case class User(override val id: Option[Int] = None, val starsPerPage = this.config.users.getInt("stars-per-page").get val limit = if (page < 1) -1 else starsPerPage val offset = (page - 1) * starsPerPage + val filter = ModelFilter[Project](_.visibility === VisibilityTypes.Public) +|| ModelFilter[Project](_.visibility === VisibilityTypes.New) this.schema.getAssociation[ProjectStarsTable, Project](classOf[ProjectStarsTable], this) - .sorted(ordering = _.name, limit = limit, offset = offset) + .sorted(ordering = _.name, filter = filter.fn, limit = limit, offset = offset) } /** diff --git a/app/ore/Joinable.scala b/app/ore/Joinable.scala index 897011d7a..38d5edfc8 100644 --- a/app/ore/Joinable.scala +++ b/app/ore/Joinable.scala @@ -16,6 +16,11 @@ trait Joinable[M <: Member[_ <: RoleModel]] extends ScopeSubject { */ def owner: M + /** + * Transfers ownership of this object to the given member. + */ + def transferOwner(owner: M) + /** * Returns this objects membership information. * diff --git a/app/ore/permission/Permission.scala b/app/ore/permission/Permission.scala index 24823b6e1..02e09063e 100644 --- a/app/ore/permission/Permission.scala +++ b/app/ore/permission/Permission.scala @@ -1,6 +1,6 @@ package ore.permission -import ore.permission.role.{Absolute, Limited, Standard, Trust} +import ore.permission.role._ /** * Represents a permission for a user to do something in the application. @@ -8,16 +8,21 @@ import ore.permission.role.{Absolute, Limited, Standard, Trust} sealed trait Permission { def trust: Trust } case object EditChannels extends Permission { val trust = Standard } case object EditPages extends Permission { val trust = Limited } -case object EditSettings extends Permission { val trust = Absolute } +case object EditSettings extends Permission { val trust = Lifted } case object EditVersions extends Permission { val trust = Standard } case object HideProjects extends Permission { val trust = Standard } +case object HardRemoveProject extends Permission { val trust = Absolute } case object ReviewFlags extends Permission { val trust = Standard } case object ReviewProjects extends Permission { val trust = Standard } +case object ReviewVisibility extends Permission { val trust = Standard } case object ViewHealth extends Permission { val trust = Standard } -case object ViewLogs extends Permission { val trust = Standard } +case object ViewLogs extends Permission { val trust = Lifted } +case object UserAdmin extends Permission { val trust = Lifted } case object ResetOre extends Permission { val trust = Absolute } case object SeedOre extends Permission { val trust = Absolute } case object MigrateOre extends Permission { val trust = Absolute } -case object CreateProject extends Permission { val trust = Absolute } +case object CreateProject extends Permission { val trust = Lifted } case object PostAsOrganization extends Permission { val trust = Standard } -case object EditApiKeys extends Permission { val trust = Absolute } +case object EditApiKeys extends Permission { val trust = Lifted } +case object ViewActivity extends Permission { val trust = Standard } +case object ViewStats extends Permission { val trust = Standard } diff --git a/app/ore/permission/role/RoleTypes.scala b/app/ore/permission/role/RoleTypes.scala index 74e5a1e22..d9370af9d 100644 --- a/app/ore/permission/role/RoleTypes.scala +++ b/app/ore/permission/role/RoleTypes.scala @@ -16,15 +16,15 @@ object RoleTypes extends Enumeration { // Global val Admin = new RoleType( 0, 61, classOf[GlobalRole], Absolute, "Ore Admin", Red) - val Mod = new RoleType( 1, 62, classOf[GlobalRole], Standard, "Ore Moderator", Aqua) - val SpongeLeader = new RoleType( 2, 44, classOf[GlobalRole], Absolute, "Sponge Leader", Amber) - val TeamLeader = new RoleType( 3, 58, classOf[GlobalRole], Standard, "Team Leader", Amber) - val CommunityLeader = new RoleType( 4, 59, classOf[GlobalRole], Standard, "Community Leader", Amber) - val Staff = new RoleType( 5, 3, classOf[GlobalRole], Standard, "Sponge Staff", Amber) - val SpongeDev = new RoleType( 6, 41, classOf[GlobalRole], Standard, "Sponge Developer", Green) - val WebDev = new RoleType( 7, 45, classOf[GlobalRole], Standard, "Web Developer", Blue) - val Scribe = new RoleType( 8, 51, classOf[GlobalRole], Limited, "Sponge Documenter", Aqua) - val Support = new RoleType( 9, 43, classOf[GlobalRole], Limited, "Sponge Support", Aqua) + val Mod = new RoleType( 1, 62, classOf[GlobalRole], Lifted, "Ore Moderator", Aqua) + val SpongeLeader = new RoleType( 2, 44, classOf[GlobalRole], Default, "Sponge Leader", Amber) + val TeamLeader = new RoleType( 3, 58, classOf[GlobalRole], Default, "Team Leader", Amber) + val CommunityLeader = new RoleType( 4, 59, classOf[GlobalRole], Default, "Community Leader", Amber) + val Staff = new RoleType( 5, 3, classOf[GlobalRole], Default, "Sponge Staff", Amber) + val SpongeDev = new RoleType( 6, 41, classOf[GlobalRole], Default, "Sponge Developer", Green) + val WebDev = new RoleType( 7, 45, classOf[GlobalRole], Default, "Web Developer", Blue) + val Scribe = new RoleType( 8, 51, classOf[GlobalRole], Default, "Sponge Documenter", Aqua) + val Support = new RoleType( 9, 43, classOf[GlobalRole], Default, "Sponge Support", Aqua) val Contributor = new RoleType(10, 49, classOf[GlobalRole], Default, "Sponge Contributor", Green) val Adviser = new RoleType(11, 48, classOf[GlobalRole], Default, "Sponge Adviser", Aqua) @@ -48,7 +48,7 @@ object RoleTypes extends Enumeration { isAssignable = false) val OrganizationOwner = new RoleType(22, -5, classOf[OrganizationRole], Absolute, "Owner", Purple, isAssignable = false) - val OrganizationAdmin = new RoleType(26, -9, classOf[OrganizationRole], Absolute, "Admin", Purple) + val OrganizationAdmin = new RoleType(26, -9, classOf[OrganizationRole], Lifted, "Admin", Purple) val OrganizationDev = new RoleType(23, -6, classOf[OrganizationRole], Standard, "Developer", Transparent) val OrganizationEditor = new RoleType(24, -7, classOf[OrganizationRole], Limited, "Editor", Transparent) val OrganizationSupport = new RoleType(25, -8, classOf[OrganizationRole], Default, "Support", Transparent) diff --git a/app/ore/permission/role/Trust.scala b/app/ore/permission/role/Trust.scala index 99a39eff5..fba857582 100644 --- a/app/ore/permission/role/Trust.scala +++ b/app/ore/permission/role/Trust.scala @@ -25,7 +25,12 @@ case object Limited extends Trust { override val level = 1 } */ case object Standard extends Trust { override val level = 2 } +/** + * User that can perform any action but they are not on top. + */ +case object Lifted extends Trust { override val level = 3 } + /** * User is absolutely trusted and may perform any action. */ -case object Absolute extends Trust { override val level = 3 } +case object Absolute extends Trust { override val level = 4 } diff --git a/app/ore/project/NotifyWatchersTask.scala b/app/ore/project/NotifyWatchersTask.scala index 3528efa6d..dba0406a3 100644 --- a/app/ore/project/NotifyWatchersTask.scala +++ b/app/ore/project/NotifyWatchersTask.scala @@ -26,7 +26,7 @@ case class NotifyWatchersTask(version: Version, messages: MessagesApi)(implicit message = messages("notification.project.newVersion", project.name, version.name), action = Some(version.url) ) - for (watcher <- project.watchers.all) + for (watcher <- project.watchers.all.filterNot(_.userId == version.author.get.userId)) watcher.sendNotification(notification) } diff --git a/app/ore/project/ProjectTask.scala b/app/ore/project/ProjectTask.scala new file mode 100644 index 000000000..9f67ff116 --- /dev/null +++ b/app/ore/project/ProjectTask.scala @@ -0,0 +1,56 @@ +package ore.project + +import java.sql.Timestamp +import java.time.Instant +import javax.inject.{Inject, Singleton} + +import akka.actor.ActorSystem +import scala.concurrent.duration._ +import scala.concurrent.ExecutionContext.Implicits.global +import db.impl.OrePostgresDriver.api._ +import db.impl.schema.ProjectSchema +import db.{ModelFilter, ModelService} +import models.project.{Project, VisibilityTypes} +import ore.OreConfig + +/** + * Task that is responsible for publishing New projects + */ +@Singleton +class ProjectTask @Inject()(models: ModelService, actorSystem: ActorSystem, config: OreConfig) extends Runnable { + + val Logger = play.api.Logger("ProjectTask") + val interval = this.config.projects.getLong("check-interval").get.millis + val draftExpire: Long = this.config.projects.getLong("draft-expire").getOrElse(86400000) + + /** + * Starts the task. + */ + def start() = { + this.actorSystem.scheduler.schedule(this.interval, this.interval, this) + Logger.info(s"Initialized. First run in ${this.interval.toString}.") + } + + /** + * Task runner + */ + def run() = { + val actions = this.models.getSchema(classOf[ProjectSchema]) + + val newFilter: ModelFilter[Project] = ModelFilter[Project](_.visibility === VisibilityTypes.New) + val future = actions.collect(newFilter.fn, ProjectSortingStrategies.Default, -1, 0) + val projects = this.models.await(future).get + + val dayAgo = System.currentTimeMillis() - draftExpire + + projects.foreach(project => { + Logger.info(s"Found project: ${project.ownerName}/${project.slug}") + val createdAt = project.createdAt.getOrElse(Timestamp.from(Instant.now())).getTime + if (createdAt < dayAgo) { + Logger.info(s"Changed ${project.ownerName}/${project.slug} from New to Public") + project.setVisibility(VisibilityTypes.Public, "Changed by task", project.owner) + } + }) + + } +} diff --git a/app/ore/project/factory/PendingVersion.scala b/app/ore/project/factory/PendingVersion.scala index 10010f77a..67ae977bc 100644 --- a/app/ore/project/factory/PendingVersion.scala +++ b/app/ore/project/factory/PendingVersion.scala @@ -1,9 +1,10 @@ package ore.project.factory import db.impl.access.ProjectBase -import models.project.{Project, Version} +import models.project._ import ore.Cacheable import ore.Colors.Color +import ore.project.Dependency import ore.project.io.PluginFile import play.api.cache.CacheApi import util.PendingAction @@ -44,4 +45,18 @@ case class PendingVersion(projects: ProjectBase, override def key: String = this.project.url + '/' + this.underlying.versionString + def dependenciesAsGhostTags: Seq[Tag] = { + var ghostFlags: Seq[Tag] = Seq() + for (dependency <- this.underlying.dependencies) { + if (factory.dependencyVersionRegex.pattern.matcher(dependency.version).matches()) { + if (dependency.pluginId.equalsIgnoreCase(Dependency.SpongeApiId)) { + ghostFlags = ghostFlags :+ Tag(None, List(), "Sponge", dependency.version, TagColors.Sponge) + } + if (dependency.pluginId.equalsIgnoreCase(Dependency.ForgeId)) { + ghostFlags = ghostFlags :+ Tag(None, List(), "Forge", dependency.version, TagColors.Forge) + } + } + } + ghostFlags + } } diff --git a/app/ore/project/factory/ProjectFactory.scala b/app/ore/project/factory/ProjectFactory.scala index 5a462832c..014ad74b4 100755 --- a/app/ore/project/factory/ProjectFactory.scala +++ b/app/ore/project/factory/ProjectFactory.scala @@ -50,6 +50,7 @@ trait ProjectFactory { val cacheApi: CacheApi val actorSystem: ActorSystem val pgp: PGPVerifier = new PGPVerifier + val dependencyVersionRegex = "^[0-9a-zA-Z\\.\\,\\[\\]\\(\\)-]+$".r implicit val messages: MessagesApi implicit val config: OreConfig @@ -155,6 +156,7 @@ trait ProjectFactory { .ownerName(owner.name) .ownerId(owner.id.get) .name(metaData.getName) + .visibility(VisibilityTypes.New) .build() val pendingProject = PendingProject( @@ -193,6 +195,7 @@ trait ProjectFactory { .hash(plugin.md5) .fileName(path.getFileName.toString) .signatureFileName(plugin.signaturePath.getFileName.toString) + .authorId(plugin.user.id.get) .build() PendingVersion( @@ -325,10 +328,15 @@ trait ProjectFactory { signatureFileName = pendingVersion.signatureFileName )) - def addTags(dependencyName: String, tagName: String, tagColor: TagColor) = { + def addTags(dependencyName: String, tagName: String, tagColor: TagColor): Unit = { val dependenciesMatchingName = newVersion.dependencies.filter(_.pluginId == dependencyName) if (dependenciesMatchingName.nonEmpty) { val dependency = dependenciesMatchingName.head + + if (!dependencyVersionRegex.pattern.matcher(dependency.version).matches()) { + return + } + val tagsWithVersion = service.access(classOf[ProjectTag]) .filter(t => t.name === tagName && t.data === dependency.version).toList diff --git a/app/ore/rest/OreRestfulApi.scala b/app/ore/rest/OreRestfulApi.scala index ede91434a..82d3261ee 100755 --- a/app/ore/rest/OreRestfulApi.scala +++ b/app/ore/rest/OreRestfulApi.scala @@ -112,14 +112,21 @@ trait OreRestfulApi { */ def getPages(pluginId: String, parentId: Option[Int]): Option[JsValue] = { this.projects.withPluginId(pluginId).map { project => - val pages = project.pages + val pages = project.pages.sorted(_.name) var result: Seq[Page] = null if (parentId.isDefined) { - result = pages.filter(_.parentId === parentId.get) + result = pages.filter(_.parentId == parentId.get) } else { result = pages.toSeq } - result + Some(toJson(result.map(page => obj( + "createdAt" -> page.createdAt, + "id" -> page.id, + "name" -> page.name, + "parentId" -> page.parentId, + "slug" -> page.slug, + "fullSlug" -> page.fullSlug + )))) } map { toJson(_) } diff --git a/app/ore/user/MembershipDossier.scala b/app/ore/user/MembershipDossier.scala index 4482bf729..245941e70 100644 --- a/app/ore/user/MembershipDossier.scala +++ b/app/ore/user/MembershipDossier.scala @@ -29,9 +29,10 @@ trait MembershipDossier { implicit def convertModel(model: ModelType): this.model.M = model.asInstanceOf[this.model.M] + def roles: ModelAccess[RoleType] = this.model.schema.getChildren[RoleType](this.roleClass, this.model) + private def association = this.model.schema.getAssociation[MembersTable, User](this.membersTableClass, this.model) - private def roles: ModelAccess[RoleType] = this.model.schema.getChildren[RoleType](this.roleClass, this.model) private def roleAccess: ModelAccess[RoleType] = this.model.service.access[RoleType](roleClass) private def addMember(user: User) = this.association.add(user) @@ -52,7 +53,7 @@ trait MembershipDossier { * @return All members */ def members: Set[MemberType] = { - this.model.schema.getAssociation[MembersTable, User](this.membersTableClass, this.model).all.map { user => + this.association.all.map { user => newMember(user.id.get) } } diff --git a/app/ore/user/notification/NotificationTypes.scala b/app/ore/user/notification/NotificationTypes.scala index 3165169c7..f3062bd4a 100644 --- a/app/ore/user/notification/NotificationTypes.scala +++ b/app/ore/user/notification/NotificationTypes.scala @@ -14,6 +14,7 @@ object NotificationTypes extends Enumeration { val ProjectInvite = NotificationType(0) val OrganizationInvite = NotificationType(1) val NewProjectVersion = NotificationType(2) + val VersionReviewed = NotificationType(3) case class NotificationType(i: Int) extends super.Val(i) with MappedType[NotificationType] { implicit val mapper: JdbcType[NotificationType] = OrePostgresDriver.api.notificationTypeTypeMapper diff --git a/app/util/StringUtils.scala b/app/util/StringUtils.scala index b30b4c1d2..27c79a500 100644 --- a/app/util/StringUtils.scala +++ b/app/util/StringUtils.scala @@ -70,4 +70,12 @@ object StringUtils { def readAndFormatFile(path: Path, params: String*): String = MessageFormat.format(new String(Files.readAllBytes(path)), params.map(_.asInstanceOf[AnyRef]): _*) + /** + * Formats the specified date into the standard application form time. + * + * @param date Date to format + * @return Standard formatted date + */ + def prettifyDateAndTime(date: Date)(implicit config: OreConfig): String + = new SimpleDateFormat(config.ore.getString("date-and-time-format").get).format(date) } diff --git a/app/views/bootstrap/footer.scala.html b/app/views/bootstrap/footer.scala.html index 5dbf7dba9..c9a14b5d3 100644 --- a/app/views/bootstrap/footer.scala.html +++ b/app/views/bootstrap/footer.scala.html @@ -5,7 +5,7 @@
If you suspect this is a bug, please take a minute to report it to the issue tracker. diff --git a/app/views/errors/offline.scala.html b/app/views/errors/offline.scala.html deleted file mode 100644 index 62ac0949e..000000000 --- a/app/views/errors/offline.scala.html +++ /dev/null @@ -1,21 +0,0 @@ -@import db.ModelService -@import db.impl.access.UserBase -@import ore.{OreConfig, OreEnv} -@import security.NonceFilter._ -@()(implicit messages: Messages, session: Session, request: Request[_] = null, service: ModelService, config: OreConfig, - users: UserBase, env: OreEnv) - -@bootstrap.layout(messages("error.offline")) { - -
Submitter | +Reason | +When | +Resolved | +|
---|---|---|---|---|
@flag.user.username | +@flag.reason, @flag.comment | +@prettifyDateAndTime(flag.createdAt.getOrElse(Timestamp.from(Instant.EPOCH))) | + @if(flag.isResolved) { +@users.get(flag.resolvedBy.getOrElse(-1)).get.username + at @prettifyDateAndTime(flag.resolvedAt.getOrElse(Timestamp.from(Instant.EPOCH))) | + } else { +-not resolved- | + } +
@prettifyDateAndTime(new Timestamp(note.time)) | +@users.get(note.user).map(_.username).getOrElse("Unknown") | +@note.render | +