Added support for mail digests

This commit is contained in:
Šesták Vít
2016-03-10 16:25:32 +01:00
parent dd99fe8e9b
commit 7b6192593d
12 changed files with 216 additions and 31 deletions

View File

@@ -1,21 +1,32 @@
package services
import java.util.NoSuchElementException
import javax.inject.Named
import com.ysoft.odc.{SetDiff, Absolutizer}
import com.mohiva.play.silhouette.api.LoginInfo
import com.ysoft.odc.{Absolutizer, SetDiff}
import controllers._
import models.EmailMessageId
import play.api.libs.mailer.{MailerClient, Email}
import models.Change.Direction
import models.{Change, EmailMessageId}
import play.api.libs.mailer.{Email, MailerClient}
import scala.concurrent.{ExecutionContext, Future}
class EmailExportService(from: String, nobodyInterestedContact: String, mailerClient: MailerClient, notificationService: VulnerabilityNotificationService, emailSendingExecutionContext: ExecutionContext, absolutizer: Absolutizer)(implicit executionContext: ExecutionContext) {
object EmailExportType extends Enumeration {
val Vulnerabilities = Value("vulnerabilities")
val Digest = Value("digest")
}
class EmailExportService(from: String, nobodyInterestedContact: String, val exportType: EmailExportType.Value, odcService: OdcService, mailerClient: MailerClient, notificationService: VulnerabilityNotificationService, emailSendingExecutionContext: ExecutionContext, absolutizer: Absolutizer)(implicit executionContext: ExecutionContext) {
// Maybe it is not the best place for exportType, but I am not sure if we want this to be configurable. If no, then we can get rid of it. If yes, we should refactor it.
private def getEmail(loginInfo: LoginInfo) = loginInfo.providerKey // TODO: get the email in a cleaner way
def recipientsForProjects(projects: Set[ReportInfo]) = for{
recipients <- notificationService.getRecipientsForProjects(projects)
} yield {
recipients.map(_.providerKey) match { // TODO: get the email in a cleaner way
recipients.map(getEmail) match {
case Seq() => Seq(nobodyInterestedContact) -> false
case other => other -> true
}
@@ -61,4 +72,47 @@ class EmailExportService(from: String, nobodyInterestedContact: String, mailerCl
bodyText = Some(vulnerability.description + "\n\n" + s"More details: "+absolutizer.absolutize(routes.Statistics.vulnerability(vulnerability.name, None)))
)
def emailDigest(subscriber: LoginInfo, changes: Seq[Change], projects: ProjectsWithReports): Future[Email] = {
val vulnNames = changes.map(_.vulnerabilityName).toSet
for {
vulns <- Future.traverse(vulnNames.toSeq)(name => odcService.getVulnerabilityDetails(name).map(v => name -> v.get)).map(_.toMap)
groups = changes.groupBy(_.direction).withDefaultValue(Seq())
} yield {
val changesMarks = Map(Direction.Added -> "❢", Direction.Removed -> "☑")
def vulnerabilityText(change: Change, vulnerability: Vulnerability) = (
s"#### ${changesMarks(change.direction)} ${vulnerability.name}${vulnerability.cvssScore.fold("")(sev => s" (CVSS severity: $sev)")}"
+"\n"+vulnerability.description
+"\nmore info: "+absolutizer.absolutize(routes.Statistics.vulnerability(vulnerability.name, None))
)
def vulnChanges(changes: Seq[Change]) =
changes.map(c => c -> vulns(c.vulnerabilityName))
.sortBy{case (change, vuln) => (vuln.cvssScore.map(-_), vuln.name)}
.map((vulnerabilityText _).tupled)
.mkString("\n\n")
def vulnerableProjects(projectIdToChanges: Map[String, Seq[Change]]) =
projectIdToChanges.toIndexedSeq.map{case (project, ch) => (projects.parseUnfriendlyNameGracefully(project), ch)}
.sortBy{case (ri, _) => friendlyProjectNameString(ri).toLowerCase}
.map{case (project, changes) => "### "+friendlyProjectNameString(project)+"\n"+vulnChanges(changes)}
.mkString("\n\n")
def section(title: String, direction: Direction) = {
groups(direction) match {
case Seq() => None
case list => Some("## "+title + "\n\n" + vulnerableProjects(list.groupBy(_.projectName)))
}
}
Email(
subject = s"New changes in vulnerabilities (${changes.size}: +${groups(Direction.Added).size} -${groups(Direction.Removed).size})",
to = Seq(getEmail(subscriber)),
from = from,
bodyText = Some(Seq(
section("Projects newly affected by a vulnerability", Direction.Added),
section("Projects no longer affected by a vulnerability", Direction.Removed)
).flatten.mkString("\n\n"))
//bodyHtml = TODO
)
}
}
}

View File

@@ -6,7 +6,10 @@ import com.mohiva.play.silhouette.api.LoginInfo
import com.ysoft.odc.SetDiff
import controllers.{ProjectsWithReports, ReportInfo}
import models._
import models.tables._
import play.api.Logger
import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider}
import slick.dbio.FutureAction
import slick.jdbc.TransactionIsolation
import scala.concurrent.{ExecutionContext, Future}
@@ -30,8 +33,25 @@ class VulnerabilityNotificationService @Inject() (protected val dbConfigProvider
import dbConfig.driver.api._
import models.tables._
def subscribers = db.run(vulnerabilitySubscriptions.result).map(_.groupBy(_.user))
def watchedProjectsByUser(identity: LoginInfo) = db.run(vulnerabilitySubscriptions.filter(_.user === identity).result)
def subscribe(user: LoginInfo, project: String) = db.run(vulnerabilitySubscriptions += VulnerabilitySubscription(user = user, project = project))
def subscribe(user: LoginInfo, project: String) = db.run(
DBIO.seq(
ensureUserHasNotificationDigestStatus(user),
vulnerabilitySubscriptions += VulnerabilitySubscription(user = user, project = project)
)
)
private def ensureUserHasNotificationDigestStatus(user: LoginInfo): DBIOAction[Unit, slick.dbio.NoStream, Effect.Read with Effect.Write] = (
notificationDigestStatuses.filter(_.user === user).result.map(_.nonEmpty) flatMap {
case true => DBIO.seq()
case false => for{
changelogTopIdOption <- changelogTopIdOptionQuery
(res: Int) <- notificationDigestStatuses += NotificationDigestStatus(user = user, lastChangelogIdOption = changelogTopIdOption)
} yield ()
}
).withTransactionIsolation(TransactionIsolation.Serializable)
def unsubscribe(user: LoginInfo, project: String) = db.run(vulnerabilitySubscriptions.filter(vs => vs.user === user && vs.project === project).delete)
@@ -85,7 +105,13 @@ class VulnerabilityNotificationService @Inject() (protected val dbConfigProvider
def changeAffectedProjects(vulnerabilityName: String, affectedProjectsDiff: SetDiff[String]): Future[Unit] = {
val time = DateTime.now()
def createRecord(projectName: String, direction: Change.Direction) = Change(time, vulnerabilityName, projectName, direction)
def createRecord(projectName: String, direction: Change.Direction) = Change(
time = time,
vulnerabilityName = vulnerabilityName,
projectName = projectName,
direction = direction,
notifiedToSomebody = false
)
val recordsToAdd = affectedProjectsDiff.added.map(projectName => createRecord(projectName, Change.Direction.Added)) ++
affectedProjectsDiff.removed.map(projectName => createRecord(projectName, Change.Direction.Removed))
/*
@@ -98,6 +124,34 @@ class VulnerabilityNotificationService @Inject() (protected val dbConfigProvider
).map(_ => ()))
}
private def changelogTopIdOptionQuery = changelog.map(_.id).max.result
def sendDigestToSubscriber(subscriber: LoginInfo, subscriptions: Seq[VulnerabilitySubscription])(sendDigest: Seq[Change] => Future[Unit]): Future[Unit] = {
def subscriptionCondition(change: Changes, subscription: VulnerabilitySubscription): Rep[Boolean] = {
//noinspection ScalaUnnecessaryParentheses because it looks less confusing
(change.projectName === subscription.project) ||
(if (subscription.project contains '/') (false: Rep[Boolean]) else change.projectName.startsWith(subscription.project + "/"))
}
val projectCondition: Changes => Rep[Boolean] = (change) =>
subscriptions.foldLeft(false: Rep[Boolean])((cond, subscription) => cond || subscriptionCondition(change, subscription))
val notificationDigestStatusSelection = notificationDigestStatuses.filter(_.user === subscriber)
db.run(
(
for{
oldStatus <- notificationDigestStatusSelection.result.map(_.head)
_ = println(oldStatus.lastChangelogIdOption.fold(changelog.filter(_ => true: Rep[Boolean]))(lastChangelogId => changelog.filter(_.id > lastChangelogId)).filter(projectCondition).result.statements.mkString("\n"))
changelogSinceLastNotified <- oldStatus.lastChangelogIdOption.fold(changelog.filter(_ => true: Rep[Boolean]))(lastChangelogId => changelog.filter(_.id > lastChangelogId)).filter(projectCondition).result
changelogIds = changelogSinceLastNotified.map(_._1)
changelogTopIdOption <- changelogTopIdOptionQuery
newLastChangelogIdOption = changelogTopIdOption.orElse(oldStatus.lastChangelogIdOption)
(_: Unit) <- FutureAction(sendDigest(changelogSinceLastNotified.map(_._2))) // Yes, we are waiting for user to be notified over some slow I/O when having an open transaction
(_: Int) <- notificationDigestStatusSelection.map(_.lastChangelogId).update(newLastChangelogIdOption)
(_: Int) <- changelog.filter(_.id inSet changelogIds).map(_.notifiedToSomebody).update(true)
} yield ()
).withTransactionIsolation(TransactionIsolation.Serializable)
)
}
val issueTrackerExport = new ExportPlatform(tables.issueTrackerExportTables)
val mailExport = new ExportPlatform(tables.mailExportTables)