diff --git a/modules/msg/src/main/Env.scala b/modules/msg/src/main/Env.scala index d08f3395c8..d31f3c7daa 100644 --- a/modules/msg/src/main/Env.scala +++ b/modules/msg/src/main/Env.scala @@ -16,7 +16,8 @@ final class Env( relationApi: lila.relation.RelationApi, prefApi: lila.pref.PrefApi, notifyApi: lila.notify.NotifyApi, - cacheApi: lila.memo.CacheApi + cacheApi: lila.memo.CacheApi, + spam: lila.security.Spam )(implicit ec: scala.concurrent.ExecutionContext, scheduler: akka.actor.Scheduler) { private val colls = wire[MsgColls] diff --git a/modules/msg/src/main/MsgApi.scala b/modules/msg/src/main/MsgApi.scala index 6b80c9b6e2..1baa88cec4 100644 --- a/modules/msg/src/main/MsgApi.scala +++ b/modules/msg/src/main/MsgApi.scala @@ -54,13 +54,14 @@ final class MsgApi( val msg = Msg.make(text, orig) val threadId = MsgThread.id(orig, dest) !colls.thread.exists($id(threadId)) flatMap { isNew => - security.post(orig, dest, msg, isNew) flatMap { - case MsgSecurity.Ok(mute) => + security.can.post(dest, msg, isNew) flatMap { + case _: MsgSecurity.Reject => funit + case send: MsgSecurity.Send => val msgWrite = colls.msg.insert.one(writeMsg(msg, threadId)) val threadWrite = if (isNew) colls.thread.insert.one { - writeThread(MsgThread.make(orig, dest, msg), delBy = mute option dest) + writeThread(MsgThread.make(orig, dest, msg), delBy = send.mute option dest) }.void else colls.thread.update @@ -68,7 +69,7 @@ final class MsgApi( $id(threadId), $set("lastMsg" -> msg.asLast) ++ $pull( // unset deleted by receiver unless the message is muted - "del" $in (orig :: (!mute).option(dest).toList) + "del" $in (orig :: (!send.mute).option(dest).toList) ) ) .void diff --git a/modules/msg/src/main/MsgSecurity.scala b/modules/msg/src/main/MsgSecurity.scala index af46309dfe..5666e8bdc8 100644 --- a/modules/msg/src/main/MsgSecurity.scala +++ b/modules/msg/src/main/MsgSecurity.scala @@ -3,15 +3,19 @@ package lila.msg import org.joda.time.DateTime import scala.concurrent.duration._ +import lila.common.Bus import lila.db.dsl._ +import lila.hub.actorApi.report.AutoFlag import lila.memo.RateLimit +import lila.shutup.Analyser import lila.user.User final private class MsgSecurity( colls: MsgColls, prefApi: lila.pref.PrefApi, userRepo: lila.user.UserRepo, - relationApi: lila.relation.RelationApi + relationApi: lila.relation.RelationApi, + spam: lila.security.Spam )(implicit ec: scala.concurrent.ExecutionContext) { import BsonHandlers._ @@ -31,22 +35,46 @@ final private class MsgSecurity( key = "msg_reply.user" ) - def post(orig: User.ID, dest: User.ID, msg: Msg, isNew: Boolean): Fu[Verdict] = - may.post(orig, dest) flatMap { - case false => fuccess(Block) - case _ => - val limiter = if (isNew) CreateLimitPerUser else ReplyLimitPerUser - if (!limiter(orig)(true)) fuccess(Limit) - else - muteTroll(orig, dest) map { troll => - Ok(mute = troll) + object can { + + def post(dest: User.ID, msg: Msg, isNew: Boolean): Fu[Verdict] = + may.post(msg.user, dest) flatMap { + case false => fuccess(Block) + case _ => + isLimited(msg, isNew) orElse + isSpam(msg) orElse + isTroll(msg.user, dest) orElse + isDirt(msg, isNew) getOrElse + fuccess(Ok) + } flatMap { + case mute: Mute => + relationApi.fetchFollows(dest, msg.user) dmap { isFriend => + if (isFriend) Ok else mute } + case verdict => fuccess(verdict) + } addEffect { + case Dirt => + Bus.publish(AutoFlag(msg.user, s"msg/${msg.user}/$dest", msg.text), "autoFlag") + case Spam => + logger.warn(s"PM spam from ${msg.user}: ${msg.text}") + case _ => + } + + private def isLimited(msg: Msg, isNew: Boolean): Fu[Option[Verdict]] = { + val limiter = if (isNew) CreateLimitPerUser else ReplyLimitPerUser + !limiter(msg.user)(true) ?? fuccess(Limit.some) } - private def muteTroll(orig: User.ID, dest: User.ID): Fu[Boolean] = - userRepo.isTroll(orig) >>& - !userRepo.isTroll(dest) >>& - !relationApi.fetchFollows(dest, orig) + private def isSpam(msg: Msg): Fu[Option[Verdict]] = + spam.detect(msg.text) ?? fuccess(Spam.some) + + private def isTroll(orig: User.ID, dest: User.ID): Fu[Option[Verdict]] = + userRepo.isTroll(orig) >>& !userRepo.isTroll(dest) dmap { _ option Troll } + + private def isDirt(msg: Msg, isNew: Boolean): Fu[Option[Verdict]] = + (isNew && Analyser(msg.text).dirty) ?? + !userRepo.isCreatedSince(msg.user, DateTime.now.minusDays(30)) dmap { _ option Dirt } + } object may { @@ -81,8 +109,14 @@ final private class MsgSecurity( private object MsgSecurity { sealed trait Verdict + sealed trait Reject extends Verdict + sealed abstract class Send(val mute: Boolean) extends Verdict + sealed abstract class Mute extends Send(true) - case class Ok(mute: Boolean) extends Verdict - case object Block extends Verdict - case object Limit extends Verdict + case object Ok extends Send(false) + case object Troll extends Mute + case object Spam extends Mute + case object Dirt extends Mute + case object Block extends Reject + case object Limit extends Reject } diff --git a/modules/user/src/main/UserRepo.scala b/modules/user/src/main/UserRepo.scala index 89420cea55..a0d6b41bc2 100644 --- a/modules/user/src/main/UserRepo.scala +++ b/modules/user/src/main/UserRepo.scala @@ -341,6 +341,9 @@ final class UserRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionCont def isTroll(id: ID): Fu[Boolean] = coll.exists($id(id) ++ trollSelect(true)) + def isCreatedSince(id: ID, since: DateTime): Fu[Boolean] = + coll.exists($id(id) ++ $doc(F.createdAt $lt since)) + def setRoles(id: ID, roles: List[String]) = coll.updateField($id(id), F.roles, roles) def disableTwoFactor(id: ID) = coll.update.one($id(id), $unset(F.totpSecret))