Merge branch 'master' into messagingAPI

This commit is contained in:
Mark Henle 2016-10-06 23:23:42 -04:00
commit 5f20bc0b66
402 changed files with 5848 additions and 3526 deletions

14
.gitmodules vendored
View file

@ -31,9 +31,6 @@
[submodule "public/vendor/stockfish.js"]
path = public/vendor/stockfish.js
url = https://github.com/niklasf/stockfish.js
[submodule "public/vendor/Sunsetter"]
path = public/vendor/Sunsetter
url = https://github.com/niklasf/Sunsetter.git
[submodule "public/vendor/flatpickr"]
path = public/vendor/flatpickr
url = https://github.com/chmln/flatpickr
@ -43,3 +40,14 @@
[submodule "public/visualizer"]
path = public/visualizer
url = https://github.com/ornicar/chess_game_visualizer
[submodule "public/vendor/Sunsetter"]
path = public/vendor/Sunsetter
url = https://github.com/niklasf/Sunsetter
branch = js
[submodule "public/vendor/stockfish.pexe"]
path = public/vendor/stockfish.pexe
url = https://github.com/niklasf/stockfish.pexe
branch = ddugovic
[submodule "public/vendor/jquery-textcomplete"]
path = public/vendor/jquery-textcomplete
url = https://github.com/yuku-t/jquery-textcomplete

View file

@ -1,3 +1,4 @@
sudo: false
language: scala
# https://docs.travis-ci.com/user/notifications/#IRC-notification

View file

@ -1,5 +1,3 @@
Copyright (c) 2012-2015 Thibault Duplessis
The MIT license
Permission is hereby granted, free of charge, to any person obtaining a copy

View file

@ -48,7 +48,10 @@ drop us an email at contact@lichess.org and we'll discuss it.
### API Limits
To respect the API servers and avoid an IP ban, please wait 1 second between requests. If you receive an HTTP response with a [429 status](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#429), please wait a full minute before resuming API usage.
To respect the API servers and avoid an IP ban, please wait 1 second between requests.
If you receive an HTTP response with a [429 status](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#429), please wait a full minute before resuming API usage.
Please do not automate computer analysis requests. They're very expensive.
### `GET /api/user/<username>` fetch one user

View file

@ -13,7 +13,6 @@ final class Env(
val CliUsername = config getString "cli.username"
private val RendererName = config getString "app.renderer.name"
private val WebPath = config getString "app.web_path"
lazy val bus = lila.common.Bus(system)

View file

@ -18,6 +18,7 @@ object Global extends GlobalSettings {
override def onRouteRequest(req: RequestHeader): Option[Handler] = {
lila.mon.http.request.all()
if (req.remoteAddress contains ":") lila.mon.http.request.ipv6()
Env.i18n.requestHandler(req) orElse super.onRouteRequest(req)
}

View file

@ -5,6 +5,7 @@ import play.api.mvc._, Results._
import lila.api.Context
import lila.app._
import lila.common.LilaCookie
import lila.common.PimpedJson._
import lila.security.Permission
import lila.user.{ User => UserModel, UserRepo }
import views._
@ -51,8 +52,10 @@ object Account extends LilaController {
"nowPlaying" -> JsArray(povs take 20 map Env.api.lobbyApi.nowPlaying),
"nbFollowing" -> nbFollowing,
"nbFollowers" -> nbFollowers,
"kid" -> me.kid,
"nbChallenges" -> nbChallenges)
"kid" -> me.kid.option(true),
"troll" -> me.troll.option(true),
"nbChallenges" -> nbChallenges
).noNull
}
}
}

View file

@ -1,40 +1,32 @@
package controllers
import scala.util.{ Success, Failure }
import scala.concurrent.duration._
import akka.pattern.ask
import play.api.http.ContentTypes
import play.api.mvc._
import play.twirl.api.Html
import lila.api.Context
import lila.app._
import lila.common.HTTPRequest
import lila.evaluation.PlayerAssessments
import lila.game.{ Pov, Game => GameModel, GameRepo, PgnDump }
import lila.hub.actorApi.map.Tell
import lila.game.{ Pov, GameRepo }
import views._
import chess.Color
object Analyse extends LilaController {
private def env = Env.analyse
private def bookmarkApi = Env.bookmark.api
private val divider = Env.game.divider
def requestAnalysis(id: String) = Auth { implicit ctx =>
me =>
OptionFuResult(GameRepo game id) { game =>
Env.fishnet.analyser(game, lila.fishnet.Work.Sender(
userId = me.id.some,
ip = HTTPRequest.lastRemoteAddress(ctx.req).some,
mod = isGranted(_.Hunter),
system = false)) map {
case true => Ok
case false => Unauthorized
}
def requestAnalysis(id: String) = Auth { implicit ctx => me =>
OptionFuResult(GameRepo game id) { game =>
Env.fishnet.analyser(game, lila.fishnet.Work.Sender(
userId = me.id.some,
ip = HTTPRequest.lastRemoteAddress(ctx.req).some,
mod = isGranted(_.Hunter),
system = false)) map {
case true => Ok
case false => Unauthorized
}
}
}
def replay(pov: Pov, userTv: Option[lila.user.User])(implicit ctx: Context) =
@ -56,19 +48,19 @@ object Analyse extends LilaController {
withMoveTimes = true,
withDivision = true,
withOpening = true) map { data =>
Ok(html.analyse.replay(
pov,
data,
initialFen,
Env.analyse.annotator(pgn, analysis, pov.game.opening, pov.game.winnerColor, pov.game.status, pov.game.clock).toString,
analysis,
analysisInProgress,
simul,
crosstable,
userTv,
chat,
bookmarked = bookmarked))
}
Ok(html.analyse.replay(
pov,
data,
initialFen,
Env.analyse.annotator(pgn, analysis, pov.game.opening, pov.game.winnerColor, pov.game.status, pov.game.clock).toString,
analysis,
analysisInProgress,
simul,
crosstable,
userTv,
chat,
bookmarked = bookmarked))
}
}
}
}

View file

@ -96,6 +96,10 @@ object Auth extends LilaController {
}
}
private def mustConfirmEmailByIP(ip: String): Fu[Boolean] =
api.recentByIpExists(ip) >>|
Mod.ipIntelCache(ip).map(80 <).recover { case _: Exception => false }
def signupPost = OpenBody { implicit ctx =>
implicit val req = ctx.body
NoTor {
@ -106,7 +110,7 @@ object Auth extends LilaController {
data => env.recaptcha.verify(~data.recaptchaResponse, req).flatMap {
case false => BadRequest(html.auth.signup(forms.signup.website fill data, env.RecaptchaPublicKey)).fuccess
case true =>
api.recentByIpExists(HTTPRequest lastRemoteAddress ctx.req) flatMap { mustConfirmEmail =>
mustConfirmEmailByIP(HTTPRequest lastRemoteAddress ctx.req) flatMap { mustConfirmEmail =>
lila.mon.user.register.website()
lila.mon.user.register.mustConfirmEmail(mustConfirmEmail)()
val email = env.emailAddress.validate(data.email) err s"Invalid email ${data.email}"

View file

@ -14,22 +14,17 @@ object Coach extends LilaController {
def allDefault(page: Int) = all(CoachPager.Order.Login.key, page)
private val canViewCoaches = (u: UserModel) =>
isGranted(_.Admin, u) || isGranted(_.Coach, u) || isGranted(_.PreviewCoach, u)
def all(o: String, page: Int) = SecureF(canViewCoaches) { implicit ctx =>
me =>
val order = CoachPager.Order(o)
Env.coach.pager(order, page) map { pager =>
Ok(html.coach.index(pager, order))
}
def all(o: String, page: Int) = Open { implicit ctx =>
val order = CoachPager.Order(o)
Env.coach.pager(order, page) map { pager =>
Ok(html.coach.index(pager, order))
}
}
def show(username: String) = SecureF(canViewCoaches) { implicit ctx =>
me =>
def show(username: String) = Open { implicit ctx =>
OptionFuResult(api find username) { c =>
WithVisibleCoach(c) {
Env.study.api.byIds {
Env.study.api.publicByIds {
c.coach.profile.studyIds.map(_.value)
} flatMap Env.study.pager.withChaptersAndLiking(ctx.me) flatMap { studies =>
api.reviews.approvedByCoach(c.coach) flatMap { reviews =>
@ -68,7 +63,7 @@ object Coach extends LilaController {
}
private def WithVisibleCoach(c: CoachModel.WithUser)(f: Fu[Result])(implicit ctx: Context) =
if ((c.coach.isListed || ctx.me.??(c.coach.is) || isGranted(_.Admin)) && ctx.me.??(canViewCoaches)) f
if (c.coach.isListed || ctx.me.??(c.coach.is) || isGranted(_.Admin)) f
else notFound
def edit = Secure(_.Coach) { implicit ctx =>

View file

@ -1,7 +1,6 @@
package controllers
import scala.concurrent.duration._
import lila.common.HTTPRequest
import lila.app._
import views._
@ -53,6 +52,18 @@ object ForumPost extends LilaController with ForumController {
}
}
def edit(postId: String) = AuthBody { implicit ctx =>
me =>
implicit val req = ctx.body
forms.postEdit.bindFromRequest.fold(err => Redirect(routes.ForumPost.redirect(postId)).fuccess,
data =>
postApi.editPost(postId, data.changes, me).map { post =>
Redirect(routes.ForumPost.redirect(post.id))
}
)
}
def delete(categSlug: String, id: String) = Auth { implicit ctx =>
me =>
CategGrantMod(categSlug) {

View file

@ -5,6 +5,7 @@ import scala.concurrent.duration._
import lila.common.HTTPRequest
import lila.app._
import lila.forum.CategRepo
import play.api.libs.json._
import views._
object ForumTopic extends LilaController with ForumController {
@ -73,4 +74,14 @@ object ForumTopic extends LilaController with ForumController {
routes.ForumTopic.show(categSlug, slug, pag.nbPages)
}
}
/**
* Returns a list of the usernames of people participating in a forum topic conversation
*/
def participants(topicId: String) = Auth { implicit ctx => me =>
postApi.userIds(topicId) map { ids =>
val usernames = Env.user.lightUserApi.getList(ids.sorted).map(_.titleName)
Ok(Json.toJson(usernames))
}
}
}

View file

@ -9,9 +9,11 @@ object Importer extends LilaController {
private def env = Env.importer
def importGame = Open { implicit ctx =>
def importGame = OpenBody { implicit ctx =>
fuccess {
Ok(html.game.importGame(env.forms.importForm))
val pgn = ctx.body.queryString.get("pgn").flatMap(_.headOption).getOrElse("")
val data = lila.importer.ImportData(pgn, None)
Ok(html.game.importGame(env.forms.importForm.fill(data)))
}
}

View file

@ -50,16 +50,26 @@ private[controllers] trait LilaController
)
protected def Socket[A: FrameFormatter](f: Context => Fu[(Iteratee[A, _], Enumerator[A])]) =
WebSocket.tryAccept[A] { req => reqToCtx(req) flatMap f map scala.util.Right.apply }
WebSocket.tryAccept[A] { req =>
SocketCSRF(req) {
reqToCtx(req) flatMap f map scala.util.Right.apply
}
}
protected def SocketEither[A: FrameFormatter](f: Context => Fu[Either[Result, (Iteratee[A, _], Enumerator[A])]]) =
WebSocket.tryAccept[A] { req => reqToCtx(req) flatMap f }
WebSocket.tryAccept[A] { req =>
SocketCSRF(req) {
reqToCtx(req) flatMap f
}
}
protected def SocketOption[A: FrameFormatter](f: Context => Fu[Option[(Iteratee[A, _], Enumerator[A])]]) =
WebSocket.tryAccept[A] { req =>
reqToCtx(req) flatMap f map {
case None => Left(NotFound(jsonError("socket resource not found")))
case Some(pair) => Right(pair)
SocketCSRF(req) {
reqToCtx(req) flatMap f map {
case None => Left(NotFound(jsonError("socket resource not found")))
case Some(pair) => Right(pair)
}
}
}
@ -76,8 +86,10 @@ private[controllers] trait LilaController
protected def Open[A](p: BodyParser[A])(f: Context => Fu[Result]): Action[A] =
Action.async(p) { req =>
reqToCtx(req) flatMap { ctx =>
Env.i18n.requestHandler.forUser(req, ctx.me).fold(f(ctx))(fuccess)
CSRF(req) {
reqToCtx(req) flatMap { ctx =>
Env.i18n.requestHandler.forUser(req, ctx.me).fold(f(ctx))(fuccess)
}
}
}
@ -85,19 +97,22 @@ private[controllers] trait LilaController
OpenBody(BodyParsers.parse.anyContent)(f)
protected def OpenBody[A](p: BodyParser[A])(f: BodyContext[A] => Fu[Result]): Action[A] =
Action.async(p)(req => reqToCtx(req) flatMap f)
protected def OpenNoCtx(f: RequestHeader => Fu[Result]): Action[AnyContent] =
Action.async(f)
Action.async(p) { req =>
CSRF(req) {
reqToCtx(req) flatMap f
}
}
protected def Auth(f: Context => UserModel => Fu[Result]): Action[Unit] =
Auth(BodyParsers.parse.empty)(f)
protected def Auth[A](p: BodyParser[A])(f: Context => UserModel => Fu[Result]): Action[A] =
Action.async(p) { req =>
reqToCtx(req) flatMap { implicit ctx =>
ctx.me.fold(authenticationFailed) { me =>
Env.i18n.requestHandler.forUser(req, ctx.me).fold(f(ctx)(me))(fuccess)
CSRF(req) {
reqToCtx(req) flatMap { implicit ctx =>
ctx.me.fold(authenticationFailed) { me =>
Env.i18n.requestHandler.forUser(req, ctx.me).fold(f(ctx)(me))(fuccess)
}
}
}
}
@ -107,8 +122,10 @@ private[controllers] trait LilaController
protected def AuthBody[A](p: BodyParser[A])(f: BodyContext[A] => UserModel => Fu[Result]): Action[A] =
Action.async(p) { req =>
reqToCtx(req) flatMap { implicit ctx =>
ctx.me.fold(authenticationFailed)(me => f(ctx)(me))
CSRF(req) {
reqToCtx(req) flatMap { implicit ctx =>
ctx.me.fold(authenticationFailed)(me => f(ctx)(me))
}
}
}
@ -119,21 +136,18 @@ private[controllers] trait LilaController
Secure(BodyParsers.parse.anyContent)(perm)(f)
protected def Secure[A](p: BodyParser[A])(perm: Permission)(f: Context => UserModel => Fu[Result]): Action[A] =
Auth(p) { implicit ctx =>
me =>
isGranted(perm).fold(f(ctx)(me), fuccess(authorizationFailed(ctx.req)))
Auth(p) { implicit ctx => me =>
isGranted(perm).fold(f(ctx)(me), fuccess(authorizationFailed(ctx.req)))
}
protected def SecureF(s: UserModel => Boolean)(f: Context => UserModel => Fu[Result]): Action[AnyContent] =
Auth(BodyParsers.parse.anyContent) { implicit ctx =>
me =>
s(me).fold(f(ctx)(me), fuccess(authorizationFailed(ctx.req)))
Auth(BodyParsers.parse.anyContent) { implicit ctx => me =>
s(me).fold(f(ctx)(me), fuccess(authorizationFailed(ctx.req)))
}
protected def SecureBody[A](p: BodyParser[A])(perm: Permission)(f: BodyContext[A] => UserModel => Fu[Result]): Action[A] =
AuthBody(p) { implicit ctx =>
me =>
isGranted(perm).fold(f(ctx)(me), fuccess(authorizationFailed(ctx.req)))
AuthBody(p) { implicit ctx => me =>
isGranted(perm).fold(f(ctx)(me), fuccess(authorizationFailed(ctx.req)))
}
protected def SecureBody(perm: Permission.type => Permission)(f: BodyContext[_] => UserModel => Fu[Result]): Action[AnyContent] =
@ -242,8 +256,8 @@ private[controllers] trait LilaController
def notFound(implicit ctx: Context): Fu[Result] = negotiate(
html =
if (HTTPRequest isSynchronousHttp ctx.req) Main notFound ctx.req
else fuccess(Results.NotFound("Resource not found")),
if (HTTPRequest isSynchronousHttp ctx.req) Main notFound ctx.req
else fuccess(Results.NotFound("Resource not found")),
api = _ => notFoundJson("Resource not found")
)
@ -289,16 +303,15 @@ private[controllers] trait LilaController
case _ => html
}) map (_.withHeaders("Vary" -> "Accept"))
protected def reqToCtx(req: RequestHeader): Fu[HeaderContext] =
restoreUser(req) flatMap { d =>
val ctx = UserContext(req, d.map(_.user))
pageDataBuilder(ctx, d.??(_.hasFingerprint)) map { Context(ctx, _) }
}
protected def reqToCtx(req: RequestHeader): Fu[HeaderContext] = restoreUser(req) flatMap { d =>
val ctx = UserContext(req, d.map(_.user))
pageDataBuilder(ctx, d.exists(_.hasFingerprint)) map { Context(ctx, _) }
}
protected def reqToCtx[A](req: Request[A]): Fu[BodyContext[A]] =
restoreUser(req) flatMap { d =>
val ctx = UserContext(req, d.map(_.user))
pageDataBuilder(ctx, d.??(_.hasFingerprint)) map { Context(ctx, _) }
pageDataBuilder(ctx, d.exists(_.hasFingerprint)) map { Context(ctx, _) }
}
import lila.hub.actorApi.relation._
@ -341,6 +354,15 @@ private[controllers] trait LilaController
}
}
private val csrfCheck = Env.security.csrfRequestHandler.check _
private val csrfForbiddenResult = Forbidden("Cross origin request forbidden").fuccess
private def CSRF(req: RequestHeader)(f: => Fu[Result]): Fu[Result] =
if (csrfCheck(req)) f else csrfForbiddenResult
protected def SocketCSRF[A](req: RequestHeader)(f: => Fu[Either[Result, A]]): Fu[Either[Result, A]] =
if (csrfCheck(req)) f else csrfForbiddenResult map Left.apply
protected def XhrOnly(res: => Fu[Result])(implicit ctx: Context) =
if (HTTPRequest isXhr ctx.req) res else notFound

View file

@ -18,27 +18,29 @@ trait LilaSocket { self: LilaController =>
def rateLimitedSocket[A: FrameFormatter](consumer: TokenBucket.Consumer, name: String)(f: AcceptType[A]): WebSocket[A, A] =
WebSocket[A, A] { req =>
reqToCtx(req) flatMap { ctx =>
val ip = HTTPRequest lastRemoteAddress req
def userInfo = {
val sri = get("sri", req) | "none"
val username = ctx.usernameOrAnon
s"user:$username sri:$sri"
}
// logger.debug(s"socket:$name socket connect $ip $userInfo")
f(ctx).map { resultOrSocket =>
resultOrSocket.right.map {
case (readIn, writeOut) => (e, i) => {
writeOut |>> i
e &> Enumeratee.mapInputM { in =>
consumer(ip).map { credit =>
if (credit >= 0) in
else {
logger.info(s"socket:$name socket close $ip $userInfo $in")
Input.EOF
SocketCSRF(req) {
reqToCtx(req) flatMap { ctx =>
val ip = HTTPRequest lastRemoteAddress req
def userInfo = {
val sri = get("sri", req) | "none"
val username = ctx.usernameOrAnon
s"user:$username sri:$sri"
}
// logger.debug(s"socket:$name socket connect $ip $userInfo")
f(ctx).map { resultOrSocket =>
resultOrSocket.right.map {
case (readIn, writeOut) => (e, i) => {
writeOut |>> i
e &> Enumeratee.mapInputM { in =>
consumer(ip).map { credit =>
if (credit >= 0) in
else {
logger.info(s"socket:$name socket close $ip $userInfo $in")
Input.EOF
}
}
}
} |>> readIn
} |>> readIn
}
}
}
}

View file

@ -2,6 +2,8 @@ package controllers
import play.api.libs.json._
import play.api.mvc._
import play.twirl.api.Html
import scala.concurrent.duration._
import lila.api.Context
import lila.app._
@ -27,12 +29,7 @@ object Lobby extends LilaController {
}
def renderHome(status: Results.Status)(implicit ctx: Context): Fu[Result] = {
Env.current.preloader(
posts = Env.forum.recent(ctx.me, Env.team.cached.teamIds),
tours = Env.tournament.cached promotable true,
events = Env.event.api promotable true,
simuls = Env.simul allCreatedFeaturable true
) map (html.lobby.home.apply _).tupled map { status(_) } map ensureSessionId(ctx.req)
HomeCache(ctx) map { status(_) } map ensureSessionId(ctx.req)
}.mon(_.http.response.home)
def seeks = Open { implicit ctx =>
@ -58,8 +55,60 @@ object Lobby extends LilaController {
}
}
def timeline = Auth { implicit ctx =>
me =>
Env.timeline.entryRepo.userEntries(me.id) map { html.timeline.entries(_) }
def timeline = Auth { implicit ctx => me =>
Env.timeline.entryRepo.userEntries(me.id) map { html.timeline.entries(_) }
}
private object HomeCache {
private case class RequestKey(
uri: String,
headers: Headers)
private val cache = lila.memo.AsyncCache[RequestKey, Html](
f = renderRequestKey,
timeToLive = 1 second)
private def renderCtx(implicit ctx: Context): Fu[Html] = Env.current.preloader(
posts = Env.forum.recent(ctx.me, Env.team.cached.teamIds),
tours = Env.tournament.cached promotable true,
events = Env.event.api promotable true,
simuls = Env.simul allCreatedFeaturable true
) map (html.lobby.home.apply _).tupled
private def renderRequestKey(r: RequestKey): Fu[Html] = renderCtx {
lila.mon.lobby.cache.miss()
val req = new RequestHeader {
def id = 1000l
def tags = Map.empty
def uri = r.uri
def path = "/"
def method = "GET"
def version = "1.1"
def queryString = Map.empty
def headers = r.headers
def remoteAddress = "0.0.0.0"
def secure = true
}
new lila.api.HeaderContext(
headerContext = new lila.user.HeaderUserContext(req, none),
data = lila.api.PageData.default)
}
def apply(ctx: Context) =
if (ctx.isAuth) {
lila.mon.lobby.cache.user()
renderCtx(ctx)
}
else {
lila.mon.lobby.cache.anon()
cache(RequestKey(
uri = ctx.req.uri,
headers = new Headers(
ctx.req.headers.get(COOKIE) ?? { cookie =>
List(COOKIE -> cookie)
}
)))
}
}
}

View file

@ -41,6 +41,10 @@ object Main extends LilaController {
}
}
def apiWebsocket = WebSocket.tryAccept { req =>
Env.site.apiSocketHandler.apply map Right.apply
}
def captchaCheck(id: String) = Open { implicit ctx =>
Env.hub.actor.captcher ? ValidCaptcha(id, ~get("solution")) map {
case valid: Boolean => Ok(valid fold (1, 0))
@ -77,22 +81,23 @@ object Main extends LilaController {
}
}
def mobileRegister(platform: String, deviceId: String) = Auth { implicit ctx =>
me =>
Env.push.registerDevice(me, platform, deviceId)
def mobileRegister(platform: String, deviceId: String) = Auth { implicit ctx => me =>
Env.push.registerDevice(me, platform, deviceId)
}
def mobileUnregister = Auth { implicit ctx =>
me =>
Env.push.unregisterDevices(me)
def mobileUnregister = Auth { implicit ctx => me =>
Env.push.unregisterDevices(me)
}
def jslog(id: String) = Open { ctx =>
val known = ctx.me.??(_.engine)
val referer = HTTPRequest.referer(ctx.req)
val name = get("n", ctx.req) | "?"
lila.log("cheat").branch("jslog").info(s"${ctx.req.remoteAddress} ${ctx.userId} $referer $name")
if (!known) {
lila.log("cheat").branch("jslog").info(s"${ctx.req.remoteAddress} ${ctx.userId} $referer $name")
}
lila.mon.cheat.cssBot()
ctx.userId.?? {
ctx.userId.ifFalse(known) ?? {
Env.report.api.autoBotReport(_, referer, name)
}
lila.game.GameRepo pov id map {
@ -100,15 +105,18 @@ object Main extends LilaController {
} inject Ok
}
def glyphs = Action { req =>
private lazy val glyphsResult: Result = {
import chess.format.pgn.Glyph
import lila.socket.tree.Node.glyphWriter
Ok(Json.obj(
"move" -> Glyph.MoveAssessment.all,
"position" -> Glyph.PositionAssessment.all,
"observation" -> Glyph.Observation.all
"move" -> Glyph.MoveAssessment.display,
"position" -> Glyph.PositionAssessment.display,
"observation" -> Glyph.Observation.display
)) as JSON
}
def glyphs = Action { req =>
glyphsResult
}
def image(id: String, hash: String, name: String) = Action.async { req =>
Env.db.image.fetch(id) map {
@ -122,6 +130,15 @@ object Main extends LilaController {
}
}
val robots = Action { _ =>
Ok {
if (Env.api.Net.Crawlable)
"User-agent: *\nAllow: /\nDisallow: /game/export"
else
"User-agent: *\nDisallow: /"
}
}
def notFound(req: RequestHeader): Fu[Result] =
reqToCtx(req) map { implicit ctx =>
lila.mon.http.response.code404()

View file

@ -122,7 +122,7 @@ object Mod extends LilaController {
}
}
private val ipIntelCache =
private[controllers] val ipIntelCache =
lila.memo.AsyncCache[String, Int](ip => {
import play.api.libs.ws.WS
import play.api.Play.current

View file

@ -0,0 +1,37 @@
package controllers
import scala.concurrent.duration._
import play.api.libs.ws.WS
import play.api.mvc._, Results._
import play.api.Play.current
import lila.app._
import views._
object Monitor extends LilaController {
private val url = "http://api.monitor.lichess.org/render"
private object path {
val coachPageView = "servers.lichess.statsite.counts.main.counter.coach.page_view.profile"
}
private val coachPageViewCache = lila.memo.AsyncCache[lila.user.User.ID, Result](
f = userId => WS.url(url).withQueryString(
"format" -> "json",
"target" -> s"""summarize(${path.coachPageView}.$userId,"1d","sum",false)""",
// "target" -> s"""summarize(servers.lichess.statsite.counts.main.counter.insight.request,'1d','sum',false)""",
"from" -> "-7d",
"until" -> "now"
).get() map {
case res if res.status == 200 => Ok(res.body)
case res =>
lila.log("monitor").warn(s"coach ${res.status} ${res.body}")
NotFound
},
timeToLive = 10 seconds
)
def coachPageView = Secure(_.Coach) { ctx =>
me =>
coachPageViewCache(me.id)
}
}

View file

@ -1,7 +1,6 @@
package controllers
import lila.app._
import lila.common.paginator.PaginatorJson
import lila.notify.Notification.Notifies
import play.api.libs.json._

View file

@ -181,13 +181,15 @@ object Puzzle extends LilaController {
_ ?? lila.game.GameRepo.gameWithInitialFen flatMap {
case Some((game, initialFen)) =>
Ok(Env.api.pgnDump(game, initialFen.map(_.value)).toString).fuccess
case _ => lila.game.GameRepo.findRandomFinished(1000) flatMap {
_ ?? { game =>
lila.game.GameRepo.initialFen(game) map { fen =>
Ok(Env.api.pgnDump(game, fen).toString)
case _ =>
lila.log("puzzle import").info("No recent good game, serving a random one :-/")
lila.game.GameRepo.findRandomFinished(1000) flatMap {
_ ?? { game =>
lila.game.GameRepo.initialFen(game) map { fen =>
Ok(Env.api.pgnDump(game, fen).toString)
}
}
}
}
}
}
}

View file

@ -34,7 +34,7 @@ trait AssetHelper { self: I18nHelper =>
def jsTagCompiled(name: String) = if (isProd) jsAt("compiled/" + name) else jsTag(name)
val jQueryTag = cdnOrLocal(
cdn = "//cdnjs.cloudflare.com/ajax/libs/jquery/3.1.0/jquery.min.js",
cdn = "//cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js",
test = "window.jQuery",
local = staticUrl("javascripts/vendor/jquery.min.js"))
@ -44,9 +44,9 @@ trait AssetHelper { self: I18nHelper =>
local = staticUrl("vendor/highcharts4/highcharts.js"))
val highchartsLatestTag = cdnOrLocal(
cdn = "//code.highcharts.com/4.1/highcharts.js",
cdn = "//code.highcharts.com/4.2/highcharts.js",
test = "window.Highcharts",
local = staticUrl("vendor/highcharts4/highcharts-4.1.9.js"))
local = staticUrl("vendor/highcharts4/highcharts-4.2.5.js"))
val highchartsMoreTag = Html {
"""<script src="//code.highcharts.com/4.1.4/highcharts-more.js"></script>"""

View file

@ -46,6 +46,7 @@ object Environment
def netDomain = apiEnv.Net.Domain
def netBaseUrl = apiEnv.Net.BaseUrl
val isGloballyCrawlable = apiEnv.Net.Crawlable
def isProd = apiEnv.isProd

View file

@ -34,8 +34,10 @@ evenMoreCss =cssTag("material.form.css")) {
</div>
}
<div>
@base.form.group(form("fideRating"), Html("FIDE rating"), klass = "half") {
@base.form.input(form("fideRating"), typ="number")
@List("fide", "uscf", "ecf").map { rn =>
@base.form.group(form(s"${rn}Rating"), Html(s"${rn.toUpperCase} rating"), help=Html("If none, leave empty").some, klass = "third") {
@base.form.input(form(s"${rn}Rating"), typ="number")
}
}
</div>
@errMsg(form)

View file

@ -75,7 +75,7 @@ atom = atom.some) {
}
/
<a data-icon="x" class="text" target="_blank" rel="nofollow" href="@cdnUrl(routes.Export.pdf(game.id).url)">@trans.printFriendlyPDF()</a>
@if(game.variant.standard) {
@if(lila.game.Game.visualisableVariants(game.variant)) {
/
<a data-icon="x" class="text" target="_blank" href="@routes.Export.visualizer(game.id)">Generate images</a>
}

View file

@ -6,7 +6,7 @@ side: Option[Html] = None,
menu: Option[Html] = None,
chat: Option[Html] = None,
underchat: Option[Html] = None,
robots: Boolean = true,
robots: Boolean = isGloballyCrawlable,
moreCss: Html = Html(""),
moreJs: Html = Html(""),
zen: Boolean = false,
@ -149,7 +149,7 @@ withGtm: Boolean = false)(body: Html)(implicit ctx: Context)
<a class="fright link text data-count" href="@routes.Report.list" data-count="@reportNbUnprocessed" data-icon="@icon.mod"></a>
}
}
<a id="reconnecting" class="fright link" onclick="location.reload();" data-icon="B">&nbsp;@trans.reconnecting()</a>
<a id="reconnecting" class="fright link" onclick="window.location.reload()" data-icon="B">&nbsp;@trans.reconnecting()</a>
</div>
<div id="fpmenu">@fpmenu()</div>
<div class="content @ctx.is3d.fold("is3d", "is2d")">

View file

@ -16,9 +16,7 @@
<a href="@routes.Puzzle.home">@trans.training()</a>
<a href="@routes.Coordinate.home">@trans.coordinates()</a>
<a href="@routes.Study.allDefault(1)">Study</a>
@if(isGranted(_.Coach)) {
<a href="@routes.Coach.allDefault(1)">Chess coaches</a>
}
</div>
</section>
<section>

View file

@ -16,22 +16,24 @@
@moreJs = {
<script src="//oss.maxcdn.com/jquery.form/3.50/jquery.form.min.js"></script>
@jsAt("vendor/bar-rating/dist/jquery.barrating.min.js")
@jsTag("chart/coachPageView.js")
@jsTag("coach.form.js")
}
@side = {
<a href="@routes.Coach.show(c.user.username)" class="button text" data-icon="v">Preview coach page</a>
<a href="@routes.Coach.show(c.user.username)" class="preview button text" data-icon="v">Preview coach page</a>
}
@layout(title = s"${c.user.titleUsername} coach page",
moreCss = moreCss,
moreJs = moreJs) {
moreJs = moreJs,
side = side.some) {
<div class="coach_edit content_box no_padding">
<div class="top">
<div class="picture_wrap">
@if(c.coach.hasPicture) {
<a class="upload_picture" href="@routes.Coach.picture" title="Change/delete your profile picture">
@pic(c, 200)
@pic(c, 250)
</a>
} else {
<div class="upload_picture">
@ -43,12 +45,12 @@ moreJs = moreJs) {
<h1>
@c.user.title.map { t => @t }@c.user.profileOrDefault.nonEmptyRealName.getOrElse(c.user.username)
</h1>
<div class="todo" data-profile="c.user.profileOrDefault.isComplete">
<div class="todo" data-profile="@c.user.profileOrDefault.isComplete">
<h3>TODO list before publishing your coach profile</h3>
<ul></ul>
</div>
<div class="analytics">
Soon here: Coach page analytics
<div class="pageview_chart">@base.spinner()</div>
</div>
</div>
</div>
@ -92,11 +94,11 @@ moreJs = moreJs) {
@textarea(form("profile.methodology"), Html("Teaching methodology"), help = Html("How you prepare and run lessons. How you follow up with students.").some)
</div>
<div class="panel contents">
@textarea(form("profile.youtubeVideos"), Html("Featured youtube videos"), help = Html("Up to 6 Youtube video URLs, one per line").some)
@textarea(form("profile.publicStudies"), Html("Featured public lichess studies"), help = Html("Up to 6 lichess study URLs, one per line").some)
@base.form.group(form("profile.youtubeChannel"), Html("URL of your Youtube channel")) {
@base.form.input(form("profile.youtubeChannel"))
}
@textarea(form("profile.publicStudies"), Html("Featured public lichess studies"), help = Html("Up to 6 lichess study URLs, one per line").some)
@textarea(form("profile.youtubeVideos"), Html("Featured youtube videos"), help = Html("Up to 6 Youtube video URLs, one per line").some)
</div>
<div class="panel reviews">
<p class="help text" data-icon="">Reviews are visible only after you approve them.</p>

View file

@ -1,7 +1,7 @@
@(pager: Paginator[lila.coach.Coach.WithUser], order: lila.coach.CoachPager.Order)(implicit ctx: Context)
@side = {
<div class="coach-intro">
<div class="coach-intro coach-side">
<img src="@staticUrl("images/icons/certification.svg")" class="certification" />
<h2>Certified coaches</h2>
<p>
@ -16,7 +16,7 @@
make your choice and enjoy learning chess!
</p>
</div>
<div class="coach-add">
<div class="coach-add coach-side">
<p>
Are you a great chess coach?<br />
Do you want to be part of this list?<br />
@ -58,7 +58,7 @@ side = side.some) {
}
@pager.nextPage.map { np =>
<div class="pager none">
<a href="@addQueryParameter(routes.Coach.all(order.key).toString, "page", np)">Next</a>
<a rel="next" href="@addQueryParameter(routes.Coach.all(order.key).toString, "page", np)">Next</a>
</div>
}
</div>

View file

@ -5,7 +5,9 @@
@cssTag("coach.form.css")
}
@layout(title = s"${c.user.titleUsername} coach picture", moreCss = moreCss) {
@layout(title = s"${c.user.titleUsername} coach picture",
moreJs = jsTag("coach.form.js"),
moreCss = moreCss) {
<div class="coach_picture content_box small_box no_padding">
<h1 class="lichess_title">
@userLink(c.user) coach picture
@ -28,7 +30,7 @@
}
}
<div class="cancel">
<a href="@routes.Coach.edit" class="text" data-icon="I">Cancel and return to coach page form</a>
<a href="@routes.Coach.edit" class="text" data-icon="I">Return to coach page form</a>
</div>
</div>
</div>

View file

@ -1,6 +1,6 @@
@(c: lila.coach.Coach.WithUser, approval: Boolean)(implicit ctx: Context)
<div class="review-form">
<div class="review-form coach-side">
@if(approval) {
<div class="approval">
<p>Thank you for the review!</p>

View file

@ -1,7 +1,7 @@
@(c: lila.coach.Coach.WithUser, reviews: lila.coach.CoachReview.Reviews)(implicit ctx: Context)
@if(reviews.list.nonEmpty) {
<div class="reviews">
<div class="reviews coach-side">
<h2>@pluralize("Player review", reviews.list.size)</h2>
@reviews.list.map { r =>
<div class="review">

View file

@ -73,8 +73,8 @@ moreCss = moreCss) {
<tr class="rating">
<th>Rating</th>
<td>
@profile.fideRating.map { r =>
FIDE: @r,
@profile.officialRating.map { r =>
@r.name.toUpperCase: @r.rating,
}
<a href="@routes.User.show(c.user.username)">
@c.user.best8Perfs.take(6).filter(c.user.hasEstablishedRating).map { pt =>
@ -135,6 +135,18 @@ moreCss = moreCss) {
@section("Best skills", profile.skills)
@section("Teaching methodology", profile.methodology)
</div>
@if(studies.nonEmpty) {
<section class="studies">
<h1>Public studies</h1>
<div class="list">
@studies.map { s =>
<div class="study">
@study.widget(s)
</div>
}
</div>
</section>
}
@if(profile.youtubeUrls.nonEmpty) {
<section class="youtube">
<h1>
@ -150,18 +162,6 @@ moreCss = moreCss) {
</div>
</section>
}
@if(studies.nonEmpty) {
<section class="studies">
<h1>Public studies</h1>
<div class="list">
@studies.map { s =>
<div class="study">
@study.widget(s)
</div>
}
</div>
</section>
}
}
</div>
}

View file

@ -20,7 +20,7 @@ moreCss = cssTag("event.css")) {
}
</td>
<td>
<span class="date">@showDateTime(e.startsAt)</span>
<span class="date">@momentFormat(e.startsAt)</span>
@momentFromNow(e.startsAt)
</td>
</tr>

View file

@ -23,7 +23,7 @@
}
}
@if(pager.hasNextPage) {
<a href="@url(pager.nextPage.get)" data-icon="H"></a>
<a rel="next" href="@url(pager.nextPage.get)" data-icon="H"></a>
} else {
<span class="disabled" data-icon="H"></span>
}

View file

@ -1,9 +1,13 @@
@(text: Field, author: Field)(implicit ctx: Context)
@(text: Field, author: Field, topic: Option[lila.forum.Topic])(implicit ctx: Context)
<label>
<span class="required">@trans.message()</span>
<textarea name="@text.name" id="@text.id">@text.value</textarea>
@topic match {
case Some(topic) => { <textarea class="post-text-area" name="@text.name" data-topic="@topic.id">@text.value</textarea> }
case None => { <textarea class="post-text-area" name="@text.name">@text.value</textarea> }
}
@errMsg(text)
</label>
@if(!ctx.isAuth) {
<label>
<span>Author</span>

View file

@ -17,7 +17,7 @@ searchText = text
<tbody class="infinitescroll">
@views.nextPage.map { n =>
<tr><th class="pager none">
<a href="@routes.ForumPost.search(text, n)">Next</a>
<a rel="next" href="@routes.ForumPost.search(text, n)">Next</a>
</th></tr>
<tr></tr>
}

View file

@ -1,6 +1,6 @@
@(categ: lila.forum.Categ, form: Form[_], captcha: lila.common.Captcha)(implicit ctx: Context)
@forum.layout(title = "New forum topic") {
@forum.layout(title = "New forum topic", moreJs=jsTag("forum-post.js")) {
<ol class="crumbs">
<li><a style="text-decoration:none" data-icon="d" class="is4" href="@routes.ForumCateg.index"> Forum</a></li>
<li><h1>@categ.name</h1></li>
@ -23,7 +23,7 @@
<input class="subject" autofocus="true" minlength=3 maxlength=100 value="@form("name").value" type="text" name="@form("name").name" id="@form("name").id">
@errMsg(form("name"))
</label>
@forum.post.formFields(form("post")("text"), form("post")("author"))
@forum.post.formFields(form("post")("text"), form("post")("author"), None)
@base.captcha(form("post")("move"), form("post")("gameId"), captcha)
@errMsg(form("post"))
<button class="submit button" type="submit" data-icon="E"> @trans.createTheTopic()</button>

View file

@ -8,6 +8,7 @@
@forum.layout(
title = s"${categ.name} / ${topic.name} page ${posts.currentPage}",
menu = modMenu.some.ifTrue(categ.isStaff),
moreJs = jsTag("forum-post.js"),
openGraph = lila.app.ui.OpenGraph(
title = s"Forum: ${categ.name} / ${topic.name} page ${posts.currentPage}",
url = s"$netBaseUrl${routes.ForumTopic.show(categ.slug, topic.slug).url}",
@ -36,16 +37,36 @@ description = shorten(posts.currentPageResults.headOption.??(_.text).replace("\n
<div class="post" id="@post.number">
<div class="metas clearfix">
@authorLink(post, "author".some)
@momentFromNow(post.createdAt)
@post.updatedAt.map { updatedAt =>
<span class="post-edited">edited</span>
@momentFromNow(updatedAt)
}.getOrElse {
@momentFromNow(post.createdAt)
}
<a class="anchor" href="@routes.ForumTopic.show(categ.slug, topic.slug, posts.currentPage)#@post.number">#@post.number</a>
@if(isGranted(_.IpBan)) {
<span class="mod postip">@post.ip</span>
}
@if(ctx.userId.fold(false)(post.shouldShowEditForm(_))) {
<a class="mod thin edit button" data-icon="m"> Edit</a>
}
@if(isGrantedMod(categ.slug)) {
<a class="mod thin delete button" href="@routes.ForumPost.delete(categ.slug, post.id)" data-icon="q"> Delete</a>
}
</div>
<p class="message">@autoLink(post.text)</p>
@if(ctx.userId.fold(false)(post.shouldShowEditForm(_))) {
<form class="edit-post-form" method="post" action="@routes.ForumPost.edit(post.id)">
<textarea data-topic="@topic.id" name="changes" class="post-text-area edit-post-box" minlength="3" required>@post.text</textarea>
<div class="edit-buttons">
<a class="edit-post-cancel" href="@routes.ForumPost.redirect(post.id)" style="margin-left:20px">@trans.cancel()</a>
<button type="submit" class="submit button">@trans.apply()</button>
</div>
</form>
}
</div>
}
</div>
@ -73,7 +94,7 @@ description = shorten(posts.currentPageResults.headOption.??(_.text).replace("\n
case (form, captcha) => {
<h2 class="postNewTitle" id="reply">@trans.replyToThisTopic()</h2>
<form class="wide" action="@routes.ForumPost.create(categ.slug, topic.slug, posts.currentPage)#reply" method="POST" novalidate>
@forum.post.formFields(form("text"), form("author"))
@forum.post.formFields(form("text"), form("author"), topic.some)
@base.captcha(form("move"), form("gameId"), captcha)
@errMsg(form)
<button type="submit" class="submit button" data-icon="E"> @trans.reply()</button>

View file

@ -34,7 +34,7 @@
}
</span>
@game.pgnImport.flatMap(_.date).getOrElse(
game.isBeingPlayed.fold(trans.playingRightNow(), Html(s"<span title='${showDateTime(game.createdAt)}'>${momentFormat(game.createdAt)}</span>"))
game.isBeingPlayed.fold(trans.playingRightNow(), momentFormat(game.createdAt))
)
</div>
@game.pgnImport.flatMap(_.date).map { date =>

View file

@ -26,7 +26,7 @@ title = trans.inbox.str()) {
<tbody class="infinitescroll">
@if(threads.hasToPaginate) {
<tr><th class="pager none">
<a href="@routes.Message.inbox(threads.nextPage | 1)">Next</a>
<a rel="next" href="@routes.Message.inbox(threads.nextPage | 1)">Next</a>
</th></tr>
}
@threads.currentPageResults.map { thread =>

View file

@ -28,7 +28,7 @@ description = "Community knowledge and frequently asked questions about lichess.
}
@questions.nextPage.map { next =>
<tr><th class="pager none">
<a href="@routes.QaQuestion.index(next.some)">Next</a>
<a rel="next" href="@routes.QaQuestion.index(next.some)">Next</a>
</th></tr>
}
</tbody>

View file

@ -207,7 +207,7 @@ description = s"Search in ${nbGames.localize} chess games using advanced criteri
</div>
<div class="search_infinitescroll">
@pager.nextPage.map { n =>
<div class="pager none"><a href="@routes.Search.index(n)">Next</a></div>
<div class="pager none"><a rel="next" href="@routes.Search.index(n)">Next</a></div>
}.getOrElse {
<div class="none"></div>
}

View file

@ -13,7 +13,7 @@
}
@pager.nextPage.map { np =>
<div class="pager none">
<a href="@addQueryParameter(url.toString, "page", np)">Next</a>
<a rel="next" href="@addQueryParameter(url.toString, "page", np)">Next</a>
</div>
}
</div>

View file

@ -15,7 +15,7 @@ currentTab = tab.some) {
<tbody class="infinitescroll">
@next.map { n =>
<tr><th class="pager none">
<a href="@n">Next</a>
<a rel="next" href="@n">Next</a>
</th></tr>
<tr></tr>
}

View file

@ -14,7 +14,7 @@
<h2>@trans.teamRecentMembers()</h2>
<div class="userlist infinitescroll">
@members.nextPage.map { np =>
<div class="pager none"><a href="@routes.Team.show(t.id, np)">Next</a></div>
<div class="pager none"><a rel="next" href="@routes.Team.show(t.id, np)">Next</a></div>
}
@members.currentPageResults.map { member =>
<div class="paginated_element">@userLink(member.user)</div>

View file

@ -60,5 +60,13 @@
@base.form.select(form("conditions.maxRating.perf"), Condition.DataForm.perfChoices)
}
</div>
<div>
@base.form.group(form("conditions.minRating.rating"), Html("Minimum top rating"), half = true) {
@base.form.select(form("conditions.minRating.rating"), Condition.DataForm.minRatingChoices)
}
@base.form.group(form("conditions.minRating.perf"), Html("In variant"), half = true) {
@base.form.select(form("conditions.minRating.perf"), Condition.DataForm.perfChoices)
}
</div>
@base.form.submit()

View file

@ -2,7 +2,7 @@
<tbody class="infinitescroll">
@finished.nextPage.map { np =>
<tr><th class="pager none">
<a href="@routes.Tournament.home(np)">Next</a>
<a rel="next" href="@routes.Tournament.home(np)">Next</a>
</th></tr>
}
@finished.currentPageResults.map { t =>

View file

@ -53,7 +53,7 @@
} else {
@trans.by(usernameOrId(tour.createdBy))
}
<span title="@showDateTime(tour.startsAt)">@momentFormat(tour.startsAt)</span>
• @momentFormat(tour.startsAt)
@if(!tour.position.initial) {
<br /><br />
<a target="_blank" href="@tour.position.url">

View file

@ -9,7 +9,7 @@
</div>
<div class="search_infinitescroll">
@pager.nextPage.map { n =>
<div class="pager none"><a href="@routes.User.showFilter(u.username, filterName, n)">Next</a></div>
<div class="pager none"><a rel="next" href="@routes.User.showFilter(u.username, filterName, n)">Next</a></div>
}.getOrElse {
<div class="none"></div>
}
@ -25,7 +25,7 @@
} else {
<div class="games infinitescroll @if(filterName == "playing" && pager.nbResults > 2) {game_list playing center}">
@pager.nextPage.map { np =>
<div class="pager none"><a href="@routes.User.showFilter(u.username, filterName, np)">Next</a></div>
<div class="pager none"><a rel="next" href="@routes.User.showFilter(u.username, filterName, np)">Next</a></div>
}
@if(filterName == "playing" && pager.nbResults > 2) {
@pager.currentPageResults.flatMap{ Pov(_, u) }.map { p =>

View file

@ -76,7 +76,6 @@
<div class="preferences">
<strong class="text inline" data-icon="%">Notable preferences:</strong>
@if(pref.keyboardMove != Pref.KeyboardMove.NO) { [keyboard moves] }
@if(pref.blindfold != Pref.Blindfold.NO) { [blindfold] }
</div>
@optionAggregateAssessment.fold{
<div class="evaluation">
@ -262,13 +261,10 @@
<br />
}
</div>
@if(spy.otherUsers.size == 1) {
<strong data-icon="f"> No similar user found</strong>
} else {
<table class="others slist">
<thead>
<tr>
<th>@spy.otherUsers.size similar user(s)</th>
<th>@spy.otherUsers.size user(s) on these IPs</th>
<th>Same</th>
<th>Games</th>
<th>Marks</th>
@ -306,7 +302,6 @@
}
</tbody>
</table>
}
<div class="listings clearfix">
<div class="spy_ips">
<strong>@spy.ips.size IP addresses</strong> <ul>@spy.ipsByLocations.map {

View file

@ -172,8 +172,8 @@ description = describeUser(u)).some) {
}
}
<div class="stats">
@profile.fideRating.map { rating =>
<div class="fide_rating">FIDE rating: <strong>@rating</strong></div>
@profile.officialRating.map { r =>
<div>@r.name.toUpperCase rating: <strong>@r.rating</strong></div>
}
@NotForKids {
@profile.nonEmptyLocation.map { l =>
@ -224,7 +224,7 @@ description = describeUser(u)).some) {
}
</div>
}
@if(u.hasGames || info.nbImported > 0) {
@if(u.hasGames || info.nbImported > 0 || info.nbBookmark > 0) {
<div class="content_box_inter tabs" id="games">
@filters.list.map { f =>
<a class="intertab@{ (filters.current == f).??(" active") }" href="@routes.User.showFilter(u.username, f.name)">

View file

@ -5,7 +5,7 @@
<tbody class="infinitescroll">
@pager.nextPage.map { np =>
<tr><th class="pager none">
<a href="@call.url?page=@np">Next</a>
<a rel="next" href="@call.url?page=@np">Next</a>
</th></tr>
}
@pager.currentPageResults.map { r =>

View file

@ -18,7 +18,7 @@
<tbody class="infinitescroll">
@pager.nextPage.map { np =>
<tr><th class="pager none">
<a href="@routes.UserTournament.path(u.username, path, np)">Next</a>
<a rel="next" href="@routes.UserTournament.path(u.username, path, np)">Next</a>
</th></tr>
}
@pager.currentPageResults.map { e =>
@ -36,7 +36,7 @@
} else {
@e.tour.perfType.map(_.name)
} •
@showDateTime(e.tour.startsAt)
@momentFormat(e.tour.startsAt)
</span>
</a>
</td>

View file

@ -16,7 +16,7 @@ control = control) {
}
@videos.nextPage.map { next =>
<div class="pager none">
<a href="@routes.Video.author(author)?@control.queryString&page=@next">Next</a>
<a rel="next" href="@routes.Video.author(author)?@control.queryString&page=@next">Next</a>
</div>
}
</div>

View file

@ -48,7 +48,7 @@ control = control) {
}
@videos.nextPage.map { next =>
<div class="pager none">
<a href="@routes.Video.index?@control.queryString&page=@next">Next</a>
<a rel="next" href="@routes.Video.index?@control.queryString&page=@next">Next</a>
</div>
}
</div>

View file

@ -29,7 +29,7 @@ control = control) {
}
@videos.nextPage.map { next =>
<div class="pager none">
<a href="@routes.Video.index?@control.queryString&page=@next">Next</a>
<a rel="next" href="@routes.Video.index?@control.queryString&page=@next">Next</a>
</div>
}
</div>

View file

@ -10,12 +10,6 @@ cd gfc-semver
sbt publish-local
cd ..
git clone https://github.com/ornicar/ReactiveMongo --branch lichess
cd ReactiveMongo
git checkout b3e895f1c723d7cb518763f31fba62dc74311eab
sbt publish-local
cd ..
git clone https://github.com/ornicar/scalalib
cd scalalib
sbt publish-local

View file

@ -0,0 +1,45 @@
var puzzles = db.puzzle;
var count = 0;
function depthOf(obj) {
var level = 1;
var key;
for (key in obj) {
if (!obj.hasOwnProperty(key)) continue;
if (typeof obj[key] === 'object') {
var depth = depthOf(obj[key]) + 1;
level = Math.max(depth, level);
}
}
return level;
}
puzzles.find({
"mate": true,
"_id": {
"$gt": 60120
},
'vote.sum': {
'$gt': -8000
}
}).forEach(function(p) {
var depth = depthOf(p);
if (depth % 2 === 1) {
count++;
puzzles.update({
_id: p._id
}, {
$set: {
vote: {
up: NumberInt(0),
down: NumberInt(9000),
sum: NumberInt(-9000)
}
}
});
print(p._id);
}
});
print("Disabled " + count + " puzzles");

View file

@ -0,0 +1,21 @@
var puzzles = db.puzzle;
var count = 0;
puzzles.find().forEach(function(p) {
var parts = p.fen.split(/\s/);
var pieceCount = parts[0].split(/[nbrqkp]/i).length - 1;
if (pieceCount < 9 && p.vote.sum < 50 && p.vote.sum > -1000) {
count++;
puzzles.update({
_id: p._id
}, {
$set: {
vote: {
up: NumberInt(0),
down: NumberInt(0),
sum: NumberInt(-9000)
}
}
});
}
});
print("Disabled " + count + " puzzles");

View file

@ -0,0 +1,10 @@
var coll = db.puzzle;
coll.find({_id:{$lt:60120},'vote.sum':{$gte:100}}).sort({_id:1}).forEach(function(p) {
if (coll.count({fen:p.fen}) == 1) {
var nextId = coll.find({},{_id:1}).sort({_id:-1}).limit(1)[0]._id + 1;
p.salvaged = NumberInt(p._id);
p._id = NumberInt(nextId);
print(p.salvaged + ' -> ' + p._id);
coll.insert(p);
}
});

View file

@ -33,7 +33,9 @@ if [ $mode = "main" ]; then
$CLI deploy pre
fi
bin/dev ";stage;exit"
SBT_OPTS=""
export JAVA_OPTS="-Xms1024M -Xmx1024M -XX:ReservedCodeCacheSize=64m -XX:+UseConcMarkSweepGC"
sbt ";stage;exit"
if [ $? != 0 ]; then
lilalog "Deploy canceled"
exit 1

View file

@ -18,7 +18,9 @@ fi
lilalog "Deploy assets to $mode server $REMOTE:$REMOTE_DIR"
bin/prod/compile-client
# if [ $2 != "skip" ]; then
bin/prod/compile-client
# fi
lilalog "Rsync scripts"
rsync --archive --no-o --no-g --progress public $REMOTE:$REMOTE_DIR

View file

@ -10,9 +10,10 @@ net {
ip = "5.196.91.160"
asset {
domain = ${net.domain}
version = 1099
version = 1145
}
email = "contact@lichess.org"
crawlable = false
}
forcedev = false
play {
@ -217,6 +218,7 @@ bookmark {
}
analyse {
collection.analysis = analysis2
collection.requester = analysis_requester
net.domain = ${net.domain}
cached.nb.ttl = ${game.cached.nb.ttl}
paginator.max_per_page = ${game.paginator.max_per_page}
@ -253,13 +255,14 @@ security {
refresh_delay = 1 hour
}
disposable_email {
provider_url = "https://raw.githubusercontent.com/ornicar/disposable-email-domains/master/index.json"
provider_url = "https://raw.githubusercontent.com/ornicar/disposable-email-domains/master/list"
refresh_delay = 10 minutes
}
recaptcha = ${recaptcha}
whois {
key = "matewithknightandbishop"
}
net.domain = ${net.domain}
}
recaptcha {
endpoint = "https://www.google.com/recaptcha/api/siteverify"
@ -394,6 +397,7 @@ tv {
google.api_key = ""
keyword = "lichess.org"
hitbox.url = "http://api.hitbox.tv/media/live/"
twitch.client_id = ""
}
}
explorer {

View file

@ -26,6 +26,13 @@
<!-- </appender> -->
<!-- </logger> -->
<logger name="reactivemongo" level="DEBUG">
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${application.home}/logs/reactivemongo.log</file>
<encoder><pattern>%date [%level] %message%n%xException</pattern></encoder>
</appender>
</logger>
<logger name="lobby" level="DEBUG">
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${application.home}/logs/lobby.log</file>
@ -56,6 +63,12 @@
<encoder><pattern>%date [%level] %message%n%xException</pattern></encoder>
</appender>
</logger>
<logger name="csrf" level="DEBUG">
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${application.home}/logs/csrf.log</file>
<encoder><pattern>%date [%level] %message%n%xException</pattern></encoder>
</appender>
</logger>
<!-- <logger name="java.io.IOException" level="OFF" /> -->
<!-- <logger name="java.nio.channels.ClosedChannelException" level="OFF" /> -->

View file

@ -125,6 +125,16 @@
</rollingPolicy>
</appender>
</logger>
<logger name="csrf" level="DEBUG">
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/lichess/csrf.log</file>
<encoder><pattern>%date %-5level %logger{30} %message%n%xException</pattern></encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/var/log/lichess/csrf-log-%d{yyyy-MM-dd}.gz</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
</appender>
</logger>
<!-- Set a specific actor to DEBUG -->
<!-- <logger name="actors.MyActor" level="DEBUG" /> -->

View file

@ -357,11 +357,13 @@ GET /forum/search controllers.ForumPost.search(text: String
GET /forum/:slug controllers.ForumCateg.show(slug: String, page: Int ?= 1)
GET /forum/:categSlug/form controllers.ForumTopic.form(categSlug: String)
POST /forum/:categSlug/new controllers.ForumTopic.create(categSlug: String)
GET /forum/participants/:topicId controllers.ForumTopic.participants(topicId: String)
GET /forum/:categSlug/:slug controllers.ForumTopic.show(categSlug: String, slug: String, page: Int ?= 1)
POST /forum/:categSlug/:slug/close controllers.ForumTopic.close(categSlug: String, slug: String)
POST /forum/:categSlug/:slug/hide controllers.ForumTopic.hide(categSlug: String, slug: String)
POST /forum/:categSlug/:slug/new controllers.ForumPost.create(categSlug: String, slug: String, page: Int ?= 1)
POST /forum/:categSlug/delete/:id controllers.ForumPost.delete(categSlug: String, id: String)
POST /forum/post/:id controllers.ForumPost.edit(id: String)
GET /forum/redirect/post/:id controllers.ForumPost.redirect(id: String)
# Message
@ -437,6 +439,7 @@ GET /api/game/:id controllers.Api.game(id: String)
GET /api/tournament controllers.Api.currentTournaments
GET /api/tournament/:id controllers.Api.tournament(id: String)
GET /api/status controllers.Api.status
GET /api/socket controllers.Main.apiWebsocket
# Events
GET /event controllers.Event.index
@ -481,8 +484,13 @@ GET /help/contribute controllers.Page.contribute
GET /help/master controllers.Page.master
GET /help/stream-on-lichess controllers.Page.streamHowTo
# Graphite
GET /monitor/coach/pageview controllers.Monitor.coachPageView
POST /jslog/$id<\w{12}> controllers.Main.jslog(id)
# Assets
GET /glyphs controllers.Main.glyphs
GET /assets/*file controllers.Assets.at(path="/public", file)
GET /robots.txt controllers.Main.robots

View file

@ -3,12 +3,14 @@ package lila.analyse
import akka.actor.ActorSelection
import chess.format.FEN
import lila.db.dsl._
import lila.game.actorApi.InsertGame
import lila.game.{ Game, GameRepo }
import lila.hub.actorApi.map.Tell
final class Analyser(
indexer: ActorSelection,
requesterApi: RequesterApi,
roundSocket: ActorSelection,
bus: lila.common.Bus) {
@ -21,6 +23,7 @@ final class Analyser(
sendAnalysisProgress(analysis) >>- {
bus.publish(actorApi.AnalysisReady(game, analysis), 'analysisReady)
indexer ! InsertGame(game)
requesterApi save analysis
}
}
}

View file

@ -17,15 +17,19 @@ final class Env(
indexer: ActorSelection) {
private val CollectionAnalysis = config getString "collection.analysis"
private val CollectionRequester = config getString "collection.requester"
private val NetDomain = config getString "net.domain"
private val CachedNbTtl = config duration "cached.nb.ttl"
private val PaginatorMaxPerPage = config getInt "paginator.max_per_page"
private val ActorName = config getString "actor.name"
private[analyse] lazy val analysisColl = db(CollectionAnalysis)
lazy val analysisColl = db(CollectionAnalysis)
lazy val requesterApi = new RequesterApi(db(CollectionRequester))
lazy val analyser = new Analyser(
indexer = indexer,
requesterApi = requesterApi,
roundSocket = roundSocket,
bus = system.lilaBus)

View file

@ -0,0 +1,24 @@
package lila.analyse
import org.joda.time._
import lila.db.dsl._
import lila.user.User
final class RequesterApi(coll: Coll) {
private val formatter = format.DateTimeFormat.forPattern("YYYY-MM-dd")
private def today = formatter.print(DateTime.now)
def save(analysis: Analysis): Funit = coll.update(
$id(analysis.uid | "anonymous"),
$inc("total" -> 1) ++
$inc(today -> 1) ++
$set("last" -> analysis.id),
upsert = true
).void
def countToday(userId: User.ID): Fu[Int] =
coll.primitiveOne[Int]($id(userId), today) map (~_)
}

View file

@ -4,9 +4,9 @@ import play.api.libs.json.{ JsObject, JsArray }
import play.api.mvc.{ Request, RequestHeader }
import lila.common.HTTPRequest
import lila.hub.actorApi.relation.OnlineFriends
import lila.pref.Pref
import lila.user.{ UserContext, HeaderUserContext, BodyUserContext }
import lila.hub.actorApi.relation.OnlineFriends
case class PageData(
onlineFriends: OnlineFriends,
@ -69,7 +69,7 @@ sealed trait Context extends lila.user.UserContextWrapper {
def requiresFingerprint = isAuth && !pageData.hasFingerprint
private def ctxPref(name: String): Option[String] =
userContext.req.session get name orElse { pref get name }
req.session get name orElse { pref get name }
}
sealed abstract class BaseContext(

View file

@ -44,6 +44,7 @@ final class Env(
val AssetDomain = config getString "net.asset.domain"
val AssetVersion = config getInt "net.asset.version"
val Email = config getString "net.email"
val Crawlable = config getBoolean "net.crawlable"
}
val PrismicApiUrl = config getString "prismic.api_url"
val EditorAnimationDuration = config duration "editor.animation.duration"

View file

@ -37,40 +37,40 @@ private[api] final class GameApi(
nb: Int,
page: Int): Fu[JsObject] = Paginator(
adapter = new CachedAdapter(
adapter = new Adapter[Game](
collection = GameRepo.coll,
selector = {
if (~playing) lila.game.Query.nowPlaying(user.id)
else $doc(
G.playerUids -> user.id,
G.status $gte chess.Status.Mate.id,
G.analysed -> analysed.map(_.fold[BSONValue](BSONBoolean(true), $doc("$exists" -> false)))
)
} ++ $doc(
G.rated -> rated.map(_.fold[BSONValue](BSONBoolean(true), $doc("$exists" -> false)))
),
projection = $empty,
sort = $doc(G.createdAt -> -1),
readPreference = ReadPreference.secondaryPreferred
),
nbResults =
if (~playing) gameCache.nbPlaying(user.id)
else fuccess {
rated.fold(user.count.game)(_.fold(user.count.rated, user.count.casual))
}
),
adapter = new Adapter[Game](
collection = GameRepo.coll,
selector = {
if (~playing) lila.game.Query.nowPlaying(user.id)
else $doc(
G.playerUids -> user.id,
G.status $gte chess.Status.Mate.id,
G.analysed -> analysed.map(_.fold[BSONValue](BSONBoolean(true), $doc("$exists" -> false)))
)
} ++ $doc(
G.rated -> rated.map(_.fold[BSONValue](BSONBoolean(true), $doc("$exists" -> false)))
),
projection = $empty,
sort = $doc(G.createdAt -> -1),
readPreference = ReadPreference.secondaryPreferred
),
nbResults =
if (~playing) gameCache.nbPlaying(user.id)
else fuccess {
rated.fold(user.count.game)(_.fold(user.count.rated, user.count.casual))
}
),
currentPage = page,
maxPerPage = nb) flatMap { pag =>
gamesJson(
withAnalysis = withAnalysis,
withMoves = withMoves,
withOpening = withOpening,
withFens = false,
withMoveTimes = withMoveTimes,
token = token)(pag.currentPageResults) map { games =>
PaginatorJson(pag withCurrentPageResults games)
}
gamesJson(
withAnalysis = withAnalysis,
withMoves = withMoves,
withOpening = withOpening,
withFens = false,
withMoveTimes = withMoveTimes,
token = token)(pag.currentPageResults) map { games =>
PaginatorJson(pag withCurrentPageResults games)
}
}
def one(
id: String,
@ -177,7 +177,11 @@ private[api] final class GameApi(
"moves" -> withMoves.option(g.pgnMoves mkString " "),
"opening" -> withOpening.??(g.opening),
"fens" -> (withFens && g.finished) ?? {
chess.Replay.boards(g.pgnMoves, initialFen, g.variant).toOption map { boards =>
chess.Replay.boards(
moveStrs = g.pgnMoves,
initialFen = initialFen map chess.format.FEN,
variant = g.variant
).toOption map { boards =>
JsArray(boards map chess.format.Forsyth.exportBoard map JsString.apply)
}
},

View file

@ -29,9 +29,9 @@ final class BookmarkApi(
user ?? { u =>
val candidateIds = games.filter(_.bookmarks > 0).map(_.id)
if (candidateIds.isEmpty) fuccess(Set.empty)
else coll.distinct("g", Some(
else coll.distinct[String, Set]("g", Some(
userIdQuery(u.id) ++ $doc("g" $in candidateIds)
)) map lila.db.BSON.asStringSet
))
}
def removeByGameId(gameId: String): Funit =

View file

@ -53,7 +53,7 @@ private final class ChallengeRepo(coll: Coll, maxPerUser: Int) {
)).cursor[Challenge]().gather[List](max)
private[challenge] def expired(max: Int): Fu[List[Challenge]] =
coll.find($doc("expiresAt" -> $doc("$lt" -> DateTime.now))).list[Challenge](max)
coll.find($doc("expiresAt" -> $lt(DateTime.now))).list[Challenge](max)
def setSeenAgain(id: Challenge.ID) = coll.update(
$id(id),

View file

@ -25,7 +25,7 @@ private[challenge] final class SocketHandler(
owner: Boolean): Fu[Option[JsSocketHandler]] = for {
socket socketHub ? Get(challengeId) mapTo manifest[ActorRef]
join = Socket.Join(uid = uid, userId = userId, owner = owner)
handler Handler(hub, socket, uid, join, userId) {
handler Handler(hub, socket, uid, join) {
case Socket.Connected(enum, member) =>
(controller(socket, challengeId, uid, member), enum, member)
}

@ -1 +1 @@
Subproject commit 8c4592ad012f2f4e1e2631ea2db64f6cde9698a3
Subproject commit a9df5e707a86565e192958b3d5574bf9510bd1b5

View file

@ -104,11 +104,14 @@ final class CoachApi(
approved = false,
updatedAt = DateTime.now)
}
reviewColl.update($id(id), review, upsert = true) >>
notifyApi.addNotification(Notification(
notifies = Notification.Notifies(coach.id.value),
content = lila.notify.CoachReview
)) >> refreshCoachNbReviews(coach.id) inject review
if (me.troll) fuccess(review)
else {
reviewColl.update($id(id), review, upsert = true) >>
notifyApi.addNotification(Notification(
notifies = Notification.Notifies(coach.id.value),
content = lila.notify.CoachReview
)) >> refreshCoachNbReviews(coach.id) inject review
}
}
def byId(id: String) = reviewColl.byId[CoachReview](id)

View file

@ -11,7 +11,7 @@ object UrlList {
def apply(text: String): List[Url] =
text.lines.toList.map(_.trim).filter(_.nonEmpty) flatMap toUrl take max
private val UrlRegex = """.*(?:youtube\.com|youtu\.be)/(?:watch)?(?:\?v=)(.+)$""".r
private val UrlRegex = """.*(?:youtube\.com|youtu\.be)/(?:watch)?(?:\?v=)?([^"&?\/ ]{11}).*""".r
/*
* https://www.youtube.com/watch?v=wEwoyYp_iw8

View file

@ -9,11 +9,12 @@ object HTTPRequest {
(req.headers get "X-Requested-With") contains "XMLHttpRequest"
def isSocket(req: RequestHeader): Boolean =
(req.headers get HeaderNames.UPGRADE) ?? (_.toLowerCase == "websocket")
(req.headers get HeaderNames.UPGRADE).exists(_.toLowerCase == "websocket")
def isSynchronousHttp(req: RequestHeader) = !isXhr(req) && !isSocket(req)
def isSafe(req: RequestHeader) = req.method == "GET"
def isSafe(req: RequestHeader) = req.method == "GET" || req.method == "HEAD" || req.method == "OPTIONS"
def isUnsafe(req: RequestHeader) = !isSafe(req)
def isRedirectable(req: RequestHeader) = isSynchronousHttp(req) && isSafe(req)
@ -28,6 +29,8 @@ object HTTPRequest {
def isChrome(req: RequestHeader) = uaContains(req, "Chrome/")
def isSafari(req: RequestHeader) = uaContains(req, "Safari/") && !isChrome(req)
def origin(req: RequestHeader): Option[String] = req.headers get HeaderNames.ORIGIN
def referer(req: RequestHeader): Option[String] = req.headers get HeaderNames.REFERER
def lastRemoteAddress(req: RequestHeader): String =
@ -56,4 +59,7 @@ object HTTPRequest {
def hasFileExtension(req: RequestHeader) =
fileExtensionPattern.matcher(req.path).matches
def print(req: RequestHeader) =
s"${req.method} ${req.domain}${req.uri} ${lastRemoteAddress(req)} origin:${~origin(req)} referer:${~referer(req)} ua:${~userAgent(req)}"
}

View file

@ -36,4 +36,7 @@ object Maths {
val s = math.pow(10, p)
(math floor n * s) / s
}
def toInt(l: Long): Int = l.min(Int.MaxValue).max(Int.MinValue).toInt
def toInt(l: Option[Long]): Option[Int] = l map toInt
}

View file

@ -11,6 +11,7 @@ object mon {
object http {
object request {
val all = inc("http.request.all")
val ipv6 = inc("http.request.ipv6")
}
object response {
val code400 = inc("http.response.4.00")
@ -45,7 +46,12 @@ object mon {
val timeout = inc("http.mailgun.timeout")
}
object userGames {
def cost = incX(s"http.user-games.cost")
def cost = incX("http.user-games.cost")
}
object csrf {
val missingOrigin = inc("http.csrf.missing_origin")
val forbidden = inc("http.csrf.forbidden")
val websocket = inc("http.csrf.websocket")
}
}
object lobby {
@ -63,6 +69,11 @@ object mon {
val member = rec("lobby.socket.member")
val resync = inc("lobby.socket.resync")
}
object cache {
val user = inc("lobby.cache.count.user")
val anon = inc("lobby.cache.count.anon")
val miss = inc("lobby.cache.count.miss")
}
}
object round {
object api {
@ -228,6 +239,12 @@ object mon {
val created = rec("tournament.created")
val started = rec("tournament.started")
val player = rec("tournament.player")
object startedOrganizer {
val tickTime = rec("tournament.started_organizer.tick_time")
}
object createdOrganizer {
val tickTime = rec("tournament.created_organizer.tick_time")
}
}
object donation {
val goal = rec("donation.goal")
@ -259,6 +276,7 @@ object mon {
object selector {
val count = inc("puzzle.selector")
val time = rec("puzzle.selector")
def vote(v: Int) = rec("puzzle.selector.vote")(1000 + v) // vote sum of selected puzzle
}
object attempt {
val user = inc("puzzle.attempt.user")
@ -356,6 +374,8 @@ object mon {
def totalMeganode = incX(s"fishnet.analysis.total.meganode.$client")
def totalSecond = incX(s"fishnet.analysis.total.second.$client")
def totalPosition = incX(s"fishnet.analysis.total.position.$client")
def endgameCount = incX(s"fishnet.analysis.total.endgame.count.$client")
def endgameTime = incX(s"fishnet.analysis.total.endgame.time.$client")
}
val post = rec("fishnet.analysis.post")
}

View file

@ -196,23 +196,6 @@ object BSON extends Handlers {
def debugDoc(doc: Bdoc): String = (doc.elements.toList map {
case (k, v) => s"$k: ${debug(v)}"
}).mkString("{", ", ", "}")
def hashDoc(doc: Bdoc): String = debugDoc(doc).replace(" ", "")
def asStrings(vs: List[BSONValue]): List[String] = {
val b = new scala.collection.mutable.ListBuffer[String]
vs foreach {
case BSONString(s) => b += s
case _ =>
}
b.toList
}
def asStringSet(vs: List[BSONValue]): Set[String] = {
val b = Set.newBuilder[String]
vs foreach {
case BSONString(s) => b += s
case _ =>
}
b.result
}
}

View file

@ -34,10 +34,13 @@ trait CollExt { self: dsl with QueryBuilderExt =>
def exists(selector: Bdoc): Fu[Boolean] = countSel(selector).map(0!=)
def byOrderedIds[D: BSONDocumentReader](ids: Iterable[String], readPreference: ReadPreference = ReadPreference.primary)(docId: D => String): Fu[List[D]] =
coll.find($inIds(ids)).cursor[D](readPreference = readPreference).collect[List]() map { docs =>
val docsMap = docs.map(u => docId(u) -> u).toMap
ids.flatMap(docsMap.get).toList
}
coll.find($inIds(ids)).cursor[D](readPreference = readPreference).
collect[List](Int.MaxValue, err = Cursor.FailOnError[List[D]]()).
map { docs =>
val docsMap = docs.map(u => docId(u) -> u).toMap
ids.flatMap(docsMap.get).toList
}
// def byOrderedIds[A <: Identified[String]: TubeInColl](ids: Iterable[String]): Fu[List[A]] =
// byOrderedIds[String, A](ids)

View file

@ -7,11 +7,11 @@ import reactivemongo.bson._
trait CursorExt { self: dsl =>
// Can be refactor as CursorProducer
final implicit class ExtendCursor[A: BSONDocumentReader](val c: Cursor[A]) {
// like collect, but with stopOnError defaulting to false
def gather[M[_]](upTo: Int = Int.MaxValue)(implicit cbf: CanBuildFrom[M[_], A, M[A]]): Fu[M[A]] =
c.collect[M](upTo, stopOnError = false)
def gather[M[_]](upTo: Int = Int.MaxValue)(implicit cbf: CanBuildFrom[M[_], A, M[A]]): Fu[M[A]] = c.collect[M](upTo, Cursor.ContOnError[M[A]]())
def list(limit: Option[Int]): Fu[List[A]] = gather[List](limit | Int.MaxValue)
@ -20,7 +20,7 @@ trait CursorExt { self: dsl =>
def list(): Fu[List[A]] = list(none)
// like headOption, but with stopOnError defaulting to false
def uno: Fu[Option[A]] =
c.collect[Iterable](1, stopOnError = false).map(_.headOption)
def uno: Fu[Option[A]] = c.collect[Iterable](
1, Cursor.ContOnError[Iterable[A]]()).map(_.headOption)
}
}

View file

@ -1,48 +1,64 @@
package lila.db
import com.typesafe.config.Config
import reactivemongo.api._
import dsl.Coll
import reactivemongo.api.{ DefaultDB, MongoConnection, MongoDriver }
import scala.concurrent.duration._
import scala.concurrent.Future
import scala.util.{ Success, Failure }
import dsl._
import scala.concurrent.{ Await, ExecutionContext, Future }
import scala.util.{ Failure, Success, Try }
final class Env(
name: String,
config: Config,
lifecycle: play.api.inject.ApplicationLifecycle) {
lazy val db = {
val parsedUri: MongoConnection.ParsedURI =
MongoConnection.parseURI(config.getString("uri")) match {
case Success(parsedURI) => parsedURI
case Failure(e) => sys error s"Invalid mongodb.uri"
}
lazy val (connection, dbName) = {
val driver = new MongoDriver(Some(config))
val connection = driver.connection(parsedUri)
parsedUri.db.fold[DefaultDB](sys error s"cannot resolve database from URI: $parsedUri") { dbUri =>
val db = DB(dbUri, connection)
registerDriverShutdownHook(driver)
logger.info(s"""ReactiveMongoApi successfully started with DB '$dbUri'! Servers: ${parsedUri.hosts.map { s => s"[${s._1}:${s._2}]" }.mkString("\n\t\t")}""")
db
}
registerDriverShutdownHook(driver)
(for {
parsedUri <- MongoConnection.parseURI(config getString "uri")
con <- driver.connection(parsedUri, true)
db <- parsedUri.db match {
case Some(name) => Success(name)
case _ => Failure[String](new IllegalArgumentException(
s"cannot resolve connection from URI: $parsedUri"))
}
} yield con -> db).get
}
def apply(name: String): Coll = db(name)
private lazy val lnm = s"${connection.supervisor}/${connection.name}"
@inline private def resolveDB(ec: ExecutionContext) =
connection.database(dbName)(ec).andThen {
case _ => /*logger.debug*/ println(s"[$lnm] MongoDB resolved: $dbName")
}
def db(implicit ec: ExecutionContext): DefaultDB =
Await.result(resolveDB(ec), 10.seconds)
def apply(name: String)(implicit ec: ExecutionContext): Coll =
db(ec).apply(name)
object image {
private lazy val imageColl = apply(config getString "image.collection")
import dsl._
import DbImage.DbImageBSONHandler
def fetch(id: String): Fu[Option[DbImage]] = imageColl.byId[DbImage](id)
}
private def registerDriverShutdownHook(mongoDriver: MongoDriver): Unit =
lifecycle.addStopHook { () => Future(mongoDriver.close()) }
lifecycle.addStopHook { () =>
logger.info(s"[$lnm] Stopping the MongoDriver...")
Future(mongoDriver.close())
}
}
object Env {
lazy val current = "db" boot new Env(
name = "main",
config = lila.common.PlayApp loadConfig "mongodb",
lifecycle = lila.common.PlayApp.lifecycle)
}

View file

@ -16,7 +16,7 @@ trait QueryBuilderExt { self: dsl =>
// like collect, but with stopOnError defaulting to false
def gather[A, M[_]](upTo: Int = Int.MaxValue)(implicit cbf: CanBuildFrom[M[_], A, M[A]], reader: BSONDocumentReader[A]): Fu[M[A]] =
b.cursor[A]().collect[M](upTo, stopOnError = false)
b.cursor[A]().collect[M](upTo, Cursor.ContOnError[M[A]]())
def list[A: BSONDocumentReader](limit: Option[Int]): Fu[List[A]] = gather[A, List](limit | Int.MaxValue)
@ -27,10 +27,9 @@ trait QueryBuilderExt { self: dsl =>
// like one, but with stopOnError defaulting to false
def uno[A: BSONDocumentReader]: Fu[Option[A]] = uno[A](ReadPreference.primary)
def uno[A: BSONDocumentReader](readPreference: ReadPreference): Fu[Option[A]] =
b.copy(options = b.options.batchSize(1))
.cursor[A](readPreference = readPreference)
.collect[Iterable](1, stopOnError = false)
.map(_.headOption)
def uno[A: BSONDocumentReader](readPreference: ReadPreference): Fu[Option[A]] = b.copy(options = b.options.batchSize(1))
.cursor[A](readPreference = readPreference)
.collect[Iterable](1, Cursor.ContOnError[Iterable[A]]())
.map(_.headOption)
}
}

View file

@ -55,10 +55,11 @@ case class Assessible(analysed: Analysed) {
// SF1 SF2 BLR1 BLR2 MTs1 MTs2 Holds
case PlayerFlags(T, T, T, T, T, T, T) => Cheating // all T, obvious cheat
case PlayerFlags(T, _, T, _, _, T, _) => Cheating // high accuracy, high blurs, no fast moves
case PlayerFlags(T, _, _, T, _, _, _) => Cheating // high accuracy, moderate blurs
case PlayerFlags(_, _, _, T, T, _, _) => LikelyCheating // high accuracy, moderate blurs => 93% chance cheating
case PlayerFlags(T, _, _, _, _, _, T) => LikelyCheating // Holds are bad, hmk?
case PlayerFlags(_, T, _, _, _, _, T) => LikelyCheating // Holds are bad, hmk?
case PlayerFlags(T, _, _, T, _, T, _) => LikelyCheating // high accuracy, moderate blurs, no fast moves
case PlayerFlags(_, T, _, T, T, _, _) => LikelyCheating // always has advantage, moderate blurs, highly consistent move times
case PlayerFlags(_, T, T, _, _, _, _) => LikelyCheating // always has advantage, high blurs

View file

@ -9,6 +9,7 @@ import lila.common.PimpedConfig._
final class Env(
config: Config,
uciMemo: lila.game.UciMemo,
requesterApi: lila.analyse.RequesterApi,
hub: lila.hub.Env,
db: lila.db.Env,
system: ActorSystem,
@ -48,11 +49,11 @@ final class Env(
monitor = monitor,
sink = sink,
socketExists = id => {
import lila.hub.actorApi.map.Exists
import akka.pattern.ask
import makeTimeout.short
hub.socket.round ? Exists(id) mapTo manifest[Boolean]
},
import lila.hub.actorApi.map.Exists
import akka.pattern.ask
import makeTimeout.short
hub.socket.round ? Exists(id) mapTo manifest[Boolean]
},
offlineMode = OfflineMode,
analysisNodes = AnalysisNodes)(system)
@ -61,11 +62,15 @@ final class Env(
uciMemo = uciMemo,
maxPlies = MovePlies)
private val limiter = new Limiter(
analysisColl = analysisColl,
requesterApi = requesterApi)
val analyser = new Analyser(
repo = repo,
uciMemo = uciMemo,
sequencer = sequencer,
limiter = new Limiter(analysisColl))
limiter = limiter)
val aiPerfApi = new AiPerfApi
@ -110,6 +115,7 @@ object Env {
lazy val current: Env = "fishnet" boot new Env(
system = lila.common.PlayApp.system,
uciMemo = lila.game.Env.current.uciMemo,
requesterApi = lila.analyse.Env.current.requesterApi,
hub = lila.hub.Env.current,
db = lila.db.Env.current,
config = lila.common.PlayApp loadConfig "fishnet",

View file

@ -6,6 +6,7 @@ import play.api.libs.json._
import chess.format.{ Uci, Forsyth, FEN }
import chess.variant.Variant
import lila.common.Maths
import lila.fishnet.{ Work => W }
object JsonApi {
@ -80,7 +81,7 @@ object JsonApi {
def medianNodes = analysis
.filterNot(_.mateFound)
.filterNot(_.deadDraw)
.flatMap(_.nodes).toNel map lila.common.Maths.median[Int]
.flatMap(_.nodes).toNel map Maths.median[Int]
def strong = medianNodes.fold(true)(_ > Evaluation.acceptableNodes)
def weak = !strong
@ -153,6 +154,7 @@ object JsonApi {
def analysisFromWork(nodes: Int)(m: Work.Analysis) = Analysis(m.id.value, fromGame(m.game), nodes)
object readers {
import play.api.libs.functional.syntax._
implicit val ClientVersionReads = Reads.of[String].map(new Client.Version(_))
implicit val ClientPythonReads = Reads.of[String].map(new Client.Python(_))
implicit val ClientKeyReads = Reads.of[String].map(new Client.Key(_))
@ -164,7 +166,14 @@ object JsonApi {
implicit val MoveResultReads = Json.reads[Request.MoveResult]
implicit val PostMoveReads = Json.reads[Request.PostMove]
implicit val ScoreReads = Json.reads[Request.Evaluation.Score]
implicit val EvaluationReads = Json.reads[Request.Evaluation]
implicit val EvaluationReads: Reads[Request.Evaluation] = (
(__ \ "pv").readNullable[String] and
(__ \ "score").read[Request.Evaluation.Score] and
(__ \ "time").readNullable[Int] and
(__ \ "nodes").readNullable[Long].map(Maths.toInt) and
(__ \ "nps").readNullable[Long].map(Maths.toInt) and
(__ \ "depth").readNullable[Int]
)(Request.Evaluation.apply _)
implicit val EvaluationOptionReads = Reads[Option[Request.Evaluation]] {
case JsNull => JsSuccess(None)
case obj => EvaluationReads reads obj map some

View file

@ -1,12 +1,27 @@
package lila.fishnet
import scala.concurrent.duration._
import reactivemongo.bson._
import lila.db.dsl.Coll
private final class Limiter(analysisColl: Coll) {
private final class Limiter(
analysisColl: Coll,
requesterApi: lila.analyse.RequesterApi) {
def apply(sender: Work.Sender): Fu[Boolean] = sender match {
def apply(sender: Work.Sender): Fu[Boolean] =
concurrentCheck(sender) flatMap {
case false => fuccess(false)
case true => perDayCheck(sender)
}
private val RequestLimitPerIP = new lila.memo.RateLimit(
credits = 50,
duration = 20 hours,
name = "request analysis per IP",
key = "request_analysis.ip")
private def concurrentCheck(sender: Work.Sender) = sender match {
case Work.Sender(_, _, mod, system) if (mod || system) => fuccess(true)
case Work.Sender(Some(userId), _, _, _) => analysisColl.count(BSONDocument(
"sender.userId" -> userId
@ -16,4 +31,13 @@ private final class Limiter(analysisColl: Coll) {
).some) map (0 ==)
case _ => fuccess(false)
}
private def perDayCheck(sender: Work.Sender) = sender match {
case Work.Sender(_, _, mod, system) if (mod || system) => fuccess(true)
case Work.Sender(Some(userId), _, _, _) => requesterApi.countToday(userId) map (_ < 25)
case Work.Sender(_, Some(ip), _, _) => fuccess {
RequestLimitPerIP(ip, cost = 1)(true)
}
case _ => fuccess(false)
}
}

View file

@ -14,7 +14,7 @@ private final class Monitor(
private case class AnalysisMeta(time: Int, nodes: Int, nps: Int, depth: Int, pvSize: Int)
private def sumOf[A](ints: List[A])(f: A => Option[Int]) = ints.foldLeft(0) {
private def sumOf[A](items: List[A])(f: A => Option[Int]) = items.foldLeft(0) {
case (acc, a) => acc + f(a).getOrElse(0)
}
@ -47,6 +47,19 @@ private final class Monitor(
avgOf(_.cappedNps) foreach { monitor.nps(_) }
avgOf(_.depth) foreach { monitor.depth(_) }
avgOf(_.pvList.size.some) foreach { monitor.pvSize(_) }
// endgame positions count and total time
if (work.game.variant.standard)
chess.Replay.boardsFromUci(~work.game.uciList, work.game.initialFen, work.game.variant).fold(
err => logger.warn(s"Monitor couldn't get ${work.game.id}'s boards"),
boards => {
val (count, time) = (boards zip result.analysis).foldLeft(0 -> 0) {
case ((count, time), (board, _)) if board.pieces.size > 5 => (count, time)
case ((count, time), (_, eval)) => (count + 1, time + ~eval.time)
}
if (count > 0) monitor.endgameCount(count)
if (time > 0) monitor.endgameTime(time)
})
}
private def sample[A](elems: List[A], n: Int) =

View file

@ -2,7 +2,7 @@ package lila.fishnet
import org.joda.time.DateTime
import chess.format.FEN
import chess.format.{ Uci, FEN }
import chess.variant.Variant
sealed trait Work {
@ -48,6 +48,8 @@ object Work {
moves: String) {
def moveList = moves.split(' ').toList
def uciList = Uci readList moves
}
case class Sender(

View file

@ -7,7 +7,10 @@ import reactivemongo.bson._
private object BSONHandlers {
implicit val CategBSONHandler = Macros.handler[Categ]
implicit val PostEditBSONHandler = Macros.handler[OldVersion]
implicit val PostBSONHandler = Macros.handler[Post]
private val topicHandler: BSONDocumentReader[Topic] with BSONDocumentWriter[Topic] with BSONHandler[Bdoc, Topic] = Macros.handler[Topic]
implicit val TopicBSONHandler: BSONDocumentReader[Topic] with BSONDocumentWriter[Topic] with BSONHandler[Bdoc, Topic] = LoggingHandler(logger)(topicHandler)
}

View file

@ -17,6 +17,8 @@ private[forum] final class DataForm(val captcher: akka.actor.ActorSelection) ext
val post = Form(postMapping)
val postEdit = Form(mapping("changes" -> text(minLength=3))(PostEdit.apply)(PostEdit.unapply))
def postWithCaptcha = withCaptcha(post)
val topic = Form(mapping(
@ -36,4 +38,6 @@ object DataForm {
case class TopicData(
name: String,
post: PostData)
case class PostEdit(changes: String)
}

View file

@ -1,9 +1,11 @@
package lila.forum
import lila.user.User
import org.joda.time.DateTime
import ornicar.scalalib.Random
import scala.concurrent.duration._
import lila.user.User
case class OldVersion(text: String, createdAt: DateTime)
case class Post(
_id: String,
@ -17,7 +19,12 @@ case class Post(
troll: Boolean,
hidden: Boolean,
lang: Option[String],
createdAt: DateTime) {
editHistory: Option[List[OldVersion]] = None,
createdAt: DateTime,
updatedAt: Option[DateTime] = None) {
private val permitEditsFor = 4 hours
private val showEditFormFor = 3 hours
def id = _id
@ -28,6 +35,29 @@ case class Post(
def isTeam = categId startsWith teamSlug("")
def isStaff = categId == "staff"
def updatedOrCreatedAt = updatedAt | createdAt
def canStillBeEdited() = {
updatedOrCreatedAt.plus(permitEditsFor.toMillis).isAfter(DateTime.now)
}
def canBeEditedBy(editingId: String): Boolean = userId.fold(false)(editingId == _)
def shouldShowEditForm(editingId: String) =
canBeEditedBy(editingId) &&
updatedOrCreatedAt.plus(showEditFormFor.toMillis).isAfter(DateTime.now)
def editPost(updated: DateTime, newText: String): Post = {
val oldVersion = new OldVersion(text, updatedOrCreatedAt)
// We only store a maximum of 5 historical versions of the post to prevent abuse of storage space
val history = (oldVersion :: ~editHistory).take(5)
copy(editHistory = history.some, text = newText, updatedAt = updated.some)
}
def hasEdits = editHistory.isDefined
}
object Post {
@ -44,17 +74,20 @@ object Post {
number: Int,
lang: Option[String],
troll: Boolean,
hidden: Boolean): Post = Post(
_id = Random nextStringUppercase idSize,
topicId = topicId,
author = author,
userId = userId,
ip = ip,
text = text,
number = number,
lang = lang,
troll = troll,
hidden = hidden,
createdAt = DateTime.now,
categId = categId)
hidden: Boolean): Post = {
Post(
_id = Random nextStringUppercase idSize,
topicId = topicId,
author = author,
userId = userId,
ip = ip,
text = text,
number = number,
lang = lang,
troll = troll,
hidden = hidden,
createdAt = DateTime.now,
categId = categId)
}
}

View file

@ -3,14 +3,13 @@ package lila.forum
import actorApi._
import akka.actor.ActorSelection
import org.joda.time.DateTime
import lila.common.paginator._
import lila.db.dsl._
import lila.db.paginator._
import lila.hub.actorApi.timeline.{ Propagate, ForumPost }
import lila.hub.actorApi.timeline.{ForumPost, Propagate}
import lila.mod.ModlogApi
import lila.security.{ Granter => MasterGranter }
import lila.user.{ User, UserContext }
import lila.security.{Granter => MasterGranter}
import lila.user.{User, UserContext}
final class PostApi(
env: Env,
@ -69,6 +68,25 @@ final class PostApi(
}
}
def editPost(postId: String, newText: String, user: User) : Fu[Post] = {
get(postId) flatMap { post =>
val now = DateTime.now
post match {
case Some((_, post)) if !post.canBeEditedBy(user.id) =>
fufail("You are not authorized to modify this post.")
case Some((_, post)) if !post.canStillBeEdited() =>
fufail("Post can no longer be edited")
case Some((_,post)) =>
val spamEscapedTest = lila.security.Spam.replace(newText)
val newPost = post.editPost(now, spamEscapedTest)
env.postColl.update($id(post.id), newPost) inject newPost
case None => fufail("Post no longer exists.")
}
}
}
private val quickHideCategs = Set("lichess-feedback", "off-topic-discussion")
private def shouldHideOnPost(topic: Topic) =
@ -169,4 +187,6 @@ final class PostApi(
def nbByUser(userId: String) = env.postColl.countSel($doc("userId" -> userId))
def userIds(topic: Topic) = PostRepo userIdsByTopicId topic.id
def userIds(topicId: String) = PostRepo userIdsByTopicId topicId
}

View file

@ -1,7 +1,9 @@
package lila.forum
import lila.db.dsl._
import lila.user.User.BSONFields
import org.joda.time.DateTime
import reactivemongo.api.ReadPreference
object PostRepo extends PostRepo(false) {
@ -68,10 +70,10 @@ sealed abstract class PostRepo(troll: Boolean) {
def sortQuery = $sort.createdAsc
def userIdsByTopicId(topicId: String): Fu[List[String]] =
coll.distinct("userId", $doc("topicId" -> topicId).some) map lila.db.BSON.asStrings
coll.distinct[String, List]("userId", $doc("topicId" -> topicId).some)
def idsByTopicId(topicId: String): Fu[List[String]] =
coll.distinct("_id", $doc("topicId" -> topicId).some) map lila.db.BSON.asStrings
coll.distinct[String, List]("_id", $doc("topicId" -> topicId).some)
import reactivemongo.api.ReadPreference
def cursor(

View file

@ -134,4 +134,5 @@ private[forum] final class TopicApi(
updatedAtTroll = lastPostTroll.fold(topic.updatedAtTroll)(_.createdAt)
)).void
} yield ()
}

Some files were not shown because too many files have changed in this diff Show more