diff --git a/app/views/user/mod.scala b/app/views/user/mod.scala index e094d25bfa..a373534172 100644 --- a/app/views/user/mod.scala +++ b/app/views/user/mod.scala @@ -453,8 +453,7 @@ object mod { private val sortNumberTh = th(attr("data-sort-method") := "number") private val dataSort = attr("data-sort") - private val dataIps = attr("data-ips") - private val dataFps = attr("data-fps") + private val dataTags = attr("data-tags") private val playban = iconTag("p") private val alt: Frag = i("A") private val shadowban: Frag = iconTag("c") @@ -504,11 +503,10 @@ object mod { val dox = isGranted(_.Doxing) || (o.lameOrAlt && !o.hasTitle) val userNotes = notes.filter(n => n.to == o.id && (ctx.me.exists(n.isFrom) || isGranted(_.Doxing))) - tr( - o == u option (cls := "same"), - dataIps := other.ips.mkString(","), - dataFps := other.fps.map(_.value).mkString(",") - )( + val row = + if (o == u) tr(cls := "same") + else tr(dataTags := s"${other.ips.mkString(" ")} ${other.fps.mkString(" ")}") + row( if (dox || o == u) td(dataSort := o.id)(userLink(o, withBestRating = true, params = "?mod")) else td, if (dox) td(othersWithEmail emailValueOf o) @@ -557,37 +555,39 @@ object mod { def identification(spy: UserSpy): Frag = mzSection("identification")( div(cls := "spy_ips")( - strong(spy.ips.size, " IP addresses"), - ul( - spy.ipsByLocations.map { - case (location, ips) => { - li( - p(location.toString), - ul( - ips.map { ip => - li(cls := "ip")( - a( - cls := List("address" -> true, "blocked" -> ip.blocked), - href := routes.Mod.singleIp(ip.ip.value.value) - )( - tag("ip")(ip.ip.value.value), - " ", - momentFromNowServer(ip.ip.date) - ), - ip.proxy option span(cls := "proxy")("PROXY") - ) - } + table(cls := "slist")( + thead( + tr( + th(pluralize("IP", spy.prints.size)), + sortNumberTh("Alts"), + th, + sortNumberTh("Date"), + th + ) + ), + tbody( + spy.ips.sortBy(-_.ip.date.getMillis).map { ip => + tr(cls := ip.blocked option "blocked", title := ip.location.toString)( + td(a(ip.ip.value)), + td(dataSort := ip.alts.score)(altMarks(ip.alts)), + td(ip.proxy option span(cls := "proxy")("PROXY")), + td(dataSort := ip.ip.date.getMillis)(momentFromNowServer(ip.ip.date)), + td( + button( + cls := "button button-empty", + href := routes.Mod.singleIpBan(!ip.blocked, ip.ip.value.value) + )("BAN") ) ) } - } + ) ) ), div(cls := "spy_fps")( table(cls := "slist")( thead( tr( - th(strong(pluralize("Print", spy.prints.size))), + th(pluralize("Print", spy.prints.size)), sortNumberTh("Alts"), sortNumberTh("Date"), th @@ -611,11 +611,23 @@ object mod { ) ), div(cls := "spy_uas")( - strong(spy.uas.size, " User agent(s)"), - ul( - spy.uas.sorted.map { ua => - li(ua.value, " ", momentFromNowServer(ua.date)) - } + table(cls := "slist")( + thead( + tr( + th(pluralize("User Agent", spy.uas.size)), + sortNumberTh("Date"), + th + ) + ), + tbody( + spy.uas.map { ua => + tr( + td(ua.value.toString), + td(dataSort := ua.date.getMillis)(momentFromNowServer(ua.date)), + td + ) + } + ) ) ) ) diff --git a/build.sbt b/build.sbt index 650fb6341f..dd836f70d6 100644 --- a/build.sbt +++ b/build.sbt @@ -36,7 +36,7 @@ libraryDependencies ++= akka.bundle ++ Seq( scalaz, chess, compression, scalalib, hasher, reactivemongo.driver, maxmind, prismic, scalatags, kamon.core, kamon.influxdb, kamon.metrics, kamon.prometheus, - scrimage, scaffeine, lettuce + scrimage, scaffeine, lettuce, uaparser ) ++ { if (useEpoll) Seq(epoll, reactivemongo.epoll) else Seq.empty @@ -278,7 +278,7 @@ lazy val oauth = module("oauth", lazy val security = module("security", Seq(common, hub, db, user, i18n, slack, oauth), - Seq(scalatags, maxmind, hasher, specs2) ++ reactivemongo.bundle + Seq(scalatags, maxmind, hasher, uaparser, specs2) ++ reactivemongo.bundle ) lazy val shutup = module("shutup", diff --git a/modules/security/src/main/GeoIP.scala b/modules/security/src/main/GeoIP.scala index 8b1174592d..7ca6108f38 100644 --- a/modules/security/src/main/GeoIP.scala +++ b/modules/security/src/main/GeoIP.scala @@ -47,8 +47,6 @@ case class Location( city: Option[String] ) { - def comparable = (country, ~region, ~city) - def shortCountry: String = ~country.split(',').headOption override def toString = List(shortCountry.some, region, city).flatten mkString " > " diff --git a/modules/security/src/main/Store.scala b/modules/security/src/main/Store.scala index a8c84f07b6..999a47d146 100644 --- a/modules/security/src/main/Store.scala +++ b/modules/security/src/main/Store.scala @@ -202,6 +202,7 @@ object Store { case class Dated[V](value: V, date: DateTime) extends Ordered[Dated[V]] { def compare(other: Dated[V]) = other.date compareTo date + def map[X](f: V => X) = copy(value = f(value)) } case class Info(ip: IpAddress, ua: String, fp: Option[FingerHash], date: DateTime) { diff --git a/modules/security/src/main/UserSpy.scala b/modules/security/src/main/UserSpy.scala index dd270a4760..357e3ad2eb 100644 --- a/modules/security/src/main/UserSpy.scala +++ b/modules/security/src/main/UserSpy.scala @@ -3,6 +3,8 @@ package lila.security import org.joda.time.DateTime import reactivemongo.api.ReadPreference import reactivemongo.api.bson._ +import org.uaparser.scala.{ Parser => UAParser } +import org.uaparser.scala.Client import lila.common.{ EmailAddress, IpAddress } import lila.db.dsl._ @@ -11,7 +13,7 @@ import lila.user.{ User, UserRepo } case class UserSpy( ips: List[UserSpy.IPData], prints: List[UserSpy.FPData], - uas: List[Store.Dated[String]], + uas: List[Store.Dated[Client]], otherUsers: List[UserSpy.OtherUser] ) { @@ -20,9 +22,6 @@ case class UserSpy( def rawIps = ips map (_.ip.value) def rawFps = prints map (_.fp.value) - def ipsByLocations: List[(Location, List[UserSpy.IPData])] = - ips.sortBy(_.ip).groupBy(_.location).toList.sortBy(_._1.comparable) - def otherUserIds = otherUsers.map(_.user.id) def usersSharingIp = @@ -78,7 +77,7 @@ final class UserSpyApi( Alts(othersByFp.getOrElse(fp.value, Set.empty)) ) }.toList, - uas = distinctRecent(infos.map(_.datedUa)).toList, + uas = distinctRecent(infos.map(_.datedUa map parseUa)).toList, otherUsers = otherUsers ) } @@ -87,6 +86,8 @@ final class UserSpyApi( private[security] def userHasPrint(u: User): Fu[Boolean] = store.coll.secondaryPreferred.exists($doc("user" -> u.id, "fp" $exists true)) + private val parseUa = UAParser.default.parse _ + private def fetchOtherUsers( user: User, ipSet: Set[IpAddress], diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 4efd7324c6..c7e60710a4 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -30,6 +30,7 @@ object Dependencies { val autoconfig = "io.methvin.play" %% "autoconfig-macros" % "0.3.2" % "provided" val scalatest = "org.scalatest" %% "scalatest" % "3.1.0" % Test val akkatestkit = "com.typesafe.akka" %% "akka-testkit" % "2.6.5" % Test + val uaparser = "org.uaparser" %% "uap-scala" % "0.11.0" object flexmark { val version = "0.50.50" diff --git a/ui/site/css/mod/_user.scss b/ui/site/css/mod/_user.scss index 7c965e7982..e61baa2330 100644 --- a/ui/site/css/mod/_user.scss +++ b/ui/site/css/mod/_user.scss @@ -114,28 +114,17 @@ margin: 0; } } - .slist td { - padding: 0; - &:first-child { - padding-left: 1em; - width: 0%; + .slist { + tbody td { + padding: 0; + &:first-child { + padding-left: 1em; + width: 0%; + } } - } - .spy_ips { - white-space: nowrap; - > ul > li { - list-style: inside disc; - } - ul p { + thead th:first-child { font-weight: bold; - display: inline; } - li li { - margin-left: 1em; - font-family: monospace; - } - } - .spy_fps { white-space: nowrap; a { font-family: monospace; @@ -146,6 +135,12 @@ i { margin-left: 1ch; } + tr:hover { + background: mix($c-brag, $c-bg-box, 20%); + } + } + .spy_ips a { + letter-spacing: -1px; } .spy_uas { padding-left: 1em; @@ -400,3 +395,7 @@ max-height: 50vh; overflow-y: auto; } + +#main-wrap.very-long { + margin-bottom: 100vh; +} diff --git a/ui/site/src/user-mod.js b/ui/site/src/user-mod.js index ec105148c6..854e9f3de4 100644 --- a/ui/site/src/user-mod.js +++ b/ui/site/src/user-mod.js @@ -22,7 +22,7 @@ function streamLoad() { function loadZone() { $zone.html(lichess.spinnerHtml).removeClass('none'); - $('#main-wrap').addClass('full-screen-force'); + $('#main-wrap').addClass('full-screen-force very-long'); $zone.html(''); streamLoad(); window.addEventListener('scroll', onScroll); @@ -30,7 +30,7 @@ function loadZone() { } function unloadZone() { $zone.addClass('none'); - $('#main-wrap').removeClass('full-screen-force'); + $('#main-wrap').removeClass('full-screen-force very-long'); window.removeEventListener('scroll', onScroll); scrollTo('#top'); } @@ -55,12 +55,14 @@ function userMod($zone) { $('#mz_menu > a:not(.available)').each(function() { $(this).toggleClass('available', !!$($(this).attr('href')).length); }); - makeReady('#mz_menu > a', (el, i) => { - const id = el.href.replace(/.+(#\w+)$/, '$1'), n = '' + (i + 1); - $(el).prepend(`${n}`); - Mousetrap.bind(n, () => { - console.log(id, n); - scrollTo(id); + makeReady('#mz_menu', el => { + $(el).find('a').each(function(i) { + const id = this.href.replace(/.+(#\w+)$/, '$1'), n = '' + (i + 1); + $(this).prepend(`${n}`); + Mousetrap.bind(n, () => { + console.log(id, n); + scrollTo(id); + }); }); }); @@ -87,13 +89,22 @@ function userMod($zone) { makeReady('#mz_others table', el => tablesort(el, { descending: true }) ); - makeReady('.spy_fps table', el => { + makeReady('#mz_identification table', el => { tablesort(el, { descending: true }); $(el).find('.button').click(function() { $.post($(this).attr('href')); $(this).parent().parent().toggleClass('blocked'); return false; }); + $(el).find('tr').on('mouseenter', function() { + const v = $(this).find('td:first').text(); + $('#mz_others tbody tr').each(function() { + $(this).toggleClass('none', !($(this).data('tags') || '').includes(v)); + }); + }); + $(el).on('mouseleave', function() { + $('#mz_others tbody tr').removeClass('none'); + }); }); makeReady('#mz_others .more-others', el => { $(el).addClass('.ready').click(() => { @@ -105,7 +116,7 @@ function userMod($zone) { function makeReady(selector, f) { $zone.find(selector + ':not(.ready)').each(function(i) { - f($(this).addClass('.ready')[0], i); + f($(this).addClass('ready')[0], i); }); }