lila/app/controllers/Api.scala

511 lines
17 KiB
Scala
Raw Normal View History

2013-12-30 19:00:56 -07:00
package controllers
2019-12-04 16:39:16 -07:00
import akka.stream.scaladsl._
2015-01-17 04:35:54 -07:00
import play.api.libs.json._
2017-01-15 05:26:08 -07:00
import play.api.mvc._
import scala.concurrent.duration._
import scala.util.chaining._
2013-12-30 19:00:56 -07:00
2019-12-04 16:39:16 -07:00
import lila.api.{ Context, GameApiV2 }
2013-12-30 19:00:56 -07:00
import lila.app._
2019-12-04 16:39:16 -07:00
import lila.common.config.{ MaxPerPage, MaxPerSecond }
import lila.common.{ HTTPRequest, IpAddress }
import lila.common.LightUser
2013-12-30 19:00:56 -07:00
2019-12-04 16:39:16 -07:00
final class Api(
env: Env,
2019-12-05 14:51:18 -07:00
gameC: => Game
2019-12-04 16:39:16 -07:00
) extends LilaController(env) {
2013-12-30 19:00:56 -07:00
2019-12-05 14:51:18 -07:00
import Api._
2019-12-04 16:39:16 -07:00
private val userApi = env.api.userApi
private val gameApi = env.api.gameApi
2014-01-07 18:43:20 -07:00
private lazy val apiStatusJson = {
val api = lila.api.Mobile.Api
Json.obj(
"api" -> Json.obj(
2016-07-15 11:41:48 -06:00
"current" -> api.currentVersion.value,
2020-12-08 03:33:59 -07:00
"olds" -> Json.arr()
)
)
2015-01-17 04:35:54 -07:00
}
2016-08-02 04:43:13 -06:00
val status = Action { req =>
2019-12-13 07:30:20 -07:00
val appVersion = get("v", req)
val mustUpgrade = appVersion exists lila.api.Mobile.AppVersion.mustUpgrade
2021-02-24 08:12:38 -07:00
JsonOk(apiStatusJson.add("mustUpgrade", mustUpgrade))
2016-08-02 04:43:13 -06:00
}
2020-05-05 22:11:15 -06:00
def index =
Action {
Ok(views.html.site.bits.api)
}
2018-04-01 22:04:41 -06:00
2020-05-05 22:11:15 -06:00
def user(name: String) =
CookieBasedApiRequest { ctx =>
2021-09-20 04:21:47 -06:00
userApi.extended(name, ctx.me, userWithFollows(ctx.req)) map toApiResult
2020-05-05 22:11:15 -06:00
}
2018-02-03 14:40:03 -07:00
2021-09-20 04:21:47 -06:00
private[controllers] def userWithFollows(req: RequestHeader) =
HTTPRequest.apiVersion(req).exists(_ < 6) && !getBool("noFollows", req)
2020-05-03 13:16:57 -06:00
private[controllers] val UsersRateLimitPerIP = lila.memo.RateLimit.composite[IpAddress](
key = "users.api.ip",
enforce = env.net.rateLimit.value
)(
2020-07-17 00:48:37 -06:00
("fast", 2000, 10.minutes),
("slow", 40000, 1.day)
)
2020-05-05 22:11:15 -06:00
def usersByIds =
Action.async(parse.tolerantText) { req =>
2021-03-04 08:01:12 -07:00
val usernames = req.body.replace("\n", "").split(',').take(300).map(_.trim).toList
2020-10-22 07:01:30 -06:00
val ip = HTTPRequest ipAddress req
2020-05-05 22:11:15 -06:00
val cost = usernames.size / 4
UsersRateLimitPerIP(ip, cost = cost) {
2020-07-08 04:40:14 -06:00
lila.mon.api.users.increment(cost.toLong)
env.user.repo named usernames map {
2021-10-21 03:26:17 -06:00
_.map { env.user.jsonView.full(_, none, withOnline = false, withRating = true) }
2020-05-05 22:11:15 -06:00
} map toApiResult map toHttp
}(rateLimitedFu)
2017-01-22 14:21:57 -07:00
}
2020-05-05 22:11:15 -06:00
def usersStatus =
ApiRequest { req =>
val ids = get("ids", req).??(_.split(',').take(100).toList map lila.user.User.normalize)
env.user.lightUserApi asyncMany ids dmap (_.flatten) flatMap { users =>
2020-05-05 22:11:15 -06:00
val streamingIds = env.streamer.liveStreamApi.userIds
def toJson(u: LightUser) =
lila.common.LightUser.lightUserWrites
.writes(u)
.add("online" -> env.socket.isOnline(u.id))
.add("playing" -> env.round.playing(u.id))
.add("streaming" -> streamingIds(u.id))
if (getBool("withGameIds", req)) users.map { u =>
(env.round.playing(u.id) ?? env.game.cached.lastPlayedPlayingId(u.id)) map { gameId =>
toJson(u).add("playingId", gameId)
2020-05-05 22:11:15 -06:00
}
}.sequenceFu map toApiResult
else fuccess(toApiResult(users map toJson))
}
}
2017-02-15 17:53:15 -07:00
private val UserGamesRateLimitPerIP = new lila.memo.RateLimit[IpAddress](
credits = 10 * 1000,
2020-04-29 08:58:36 -06:00
duration = 10.minutes,
key = "user_games.api.ip"
)
2017-02-15 17:53:15 -07:00
private val UserGamesRateLimitPerUA = new lila.memo.RateLimit[String](
credits = 10 * 1000,
2020-04-29 08:58:36 -06:00
duration = 5.minutes,
key = "user_games.api.ua"
)
2017-02-15 17:53:15 -07:00
private val UserGamesRateLimitGlobal = new lila.memo.RateLimit[String](
credits = 20 * 1000,
2020-04-29 08:58:36 -06:00
duration = 2.minute,
key = "user_games.api.global"
)
2016-07-08 17:32:54 -06:00
private def UserGamesRateLimit(cost: Int, req: RequestHeader)(run: => Fu[ApiResult]) = {
2020-10-22 07:01:30 -06:00
val ip = HTTPRequest ipAddress req
2017-08-19 08:56:34 -06:00
UserGamesRateLimitPerIP(ip, cost = cost) {
UserGamesRateLimitPerUA(~HTTPRequest.userAgent(req), cost = cost, msg = ip.value) {
2017-08-19 08:56:34 -06:00
UserGamesRateLimitGlobal("-", cost = cost, msg = ip.value) {
run
}(fuccess(Limited))
}(fuccess(Limited))
}(fuccess(Limited))
2017-08-19 08:56:34 -06:00
}
private def gameFlagsFromRequest(req: RequestHeader) =
2017-04-24 03:42:44 -06:00
lila.api.GameApi.WithFlags(
analysis = getBool("with_analysis", req),
moves = getBool("with_moves", req),
fens = getBool("with_fens", req),
opening = getBool("with_opening", req),
moveTimes = getBool("with_movetimes", req),
token = get("token", req)
2017-04-24 03:42:44 -06:00
)
2018-05-07 16:39:26 -06:00
// for mobile app
2020-05-05 22:11:15 -06:00
def userGames(name: String) =
MobileApiRequest { req =>
val page = (getInt("page", req) | 1) atLeast 1 atMost 200
val nb = MaxPerPage((getInt("nb", req) | 10) atLeast 1 atMost 100)
val cost = page * nb.value + 10
UserGamesRateLimit(cost, req) {
2020-07-08 04:40:14 -06:00
lila.mon.api.userGames.increment(cost.toLong)
2020-05-05 22:11:15 -06:00
env.user.repo named name flatMap {
_ ?? { user =>
gameApi.byUser(
user = user,
rated = getBoolOpt("rated", req),
playing = getBoolOpt("playing", req),
analysed = getBoolOpt("analysed", req),
withFlags = gameFlagsFromRequest(req),
nb = nb,
page = page
) map some
}
} map toApiResult
}
2016-01-21 23:45:07 -07:00
}
2013-12-30 19:00:56 -07:00
2017-02-15 17:53:15 -07:00
private val GameRateLimitPerIP = new lila.memo.RateLimit[IpAddress](
2017-01-15 05:56:49 -07:00
credits = 100,
2020-04-29 08:58:36 -06:00
duration = 1.minute,
key = "game.api.one.ip"
)
2016-08-31 15:59:31 -06:00
2020-05-05 22:11:15 -06:00
def game(id: String) =
ApiRequest { req =>
2020-10-22 07:01:30 -06:00
GameRateLimitPerIP(HTTPRequest ipAddress req, cost = 1) {
2020-05-05 22:11:15 -06:00
lila.mon.api.game.increment(1)
gameApi.one(id take lila.game.Game.gameIdSize, gameFlagsFromRequest(req)) map toApiResult
}(fuccess(Limited))
2016-08-31 15:59:31 -06:00
}
2014-06-06 03:08:43 -06:00
2018-04-18 12:52:53 -06:00
private val CrosstableRateLimitPerIP = new lila.memo.RateLimit[IpAddress](
credits = 30,
2020-04-29 08:58:36 -06:00
duration = 10.minutes,
2018-04-18 12:52:53 -06:00
key = "crosstable.api.ip"
)
def crosstable(name1: String, name2: String) =
2020-05-05 22:11:15 -06:00
ApiRequest { req =>
2020-10-22 07:01:30 -06:00
CrosstableRateLimitPerIP(HTTPRequest ipAddress req, cost = 1) {
import lila.user.User.normalize
val (u1, u2) = (normalize(name1), normalize(name2))
2021-09-18 03:53:02 -06:00
env.game.crosstableApi(u1, u2) flatMap { ct =>
(ct.results.nonEmpty && getBool("matchup", req)).?? {
env.game.crosstableApi.getMatchup(u1, u2)
} map { matchup =>
toApiResult {
lila.game.JsonView.crosstable(ct, matchup).some
}
2020-05-05 22:11:15 -06:00
}
}
}(fuccess(Limited))
}
2020-05-05 22:11:15 -06:00
def currentTournaments =
ApiRequest { implicit req =>
implicit val lang = reqLang
env.tournament.api.fetchVisibleTournaments flatMap
env.tournament.apiJsonView.apply map Data.apply
}
2020-05-05 22:11:15 -06:00
def tournament(id: String) =
ApiRequest { implicit req =>
env.tournament.tournamentRepo byId id flatMap {
_ ?? { tour =>
val page = (getInt("page", req) | 1) atLeast 1 atMost 200
env.tournament.jsonView(
tour = tour,
page = page.some,
me = none,
getUserTeamIds = _ => fuccess(Nil),
getTeamName = env.team.getTeamName.apply,
2020-05-05 22:11:15 -06:00
playerInfoExt = none,
socketVersion = none,
partial = false
)(reqLang) map some
}
} map toApiResult
}
2020-05-05 22:11:15 -06:00
def tournamentGames(id: String) =
Action.async { req =>
env.tournament.tournamentRepo byId id flatMap {
_ ?? { tour =>
val onlyUserId = get("player", req) map lila.user.User.normalize
2020-05-05 22:11:15 -06:00
val config = GameApiV2.ByTournamentConfig(
tournamentId = tour.id,
format = GameApiV2.Format byRequest req,
flags = gameC.requestPgnFlags(req, extended = false),
perSecond = MaxPerSecond(20)
)
2020-10-22 07:01:30 -06:00
GlobalConcurrencyLimitPerIP(HTTPRequest ipAddress req)(
env.api.gameApiV2.exportByTournament(config, onlyUserId)
2020-05-05 22:11:15 -06:00
) { source =>
val filename = env.api.gameApiV2.filename(tour, config.format)
Ok.chunked(source)
2021-05-06 05:18:15 -06:00
.pipe(asAttachmentStream(env.api.gameApiV2.filename(tour, config.format)))
2020-05-05 22:11:15 -06:00
.as(gameC gameContentType config)
}.fuccess
}
}
}
2020-05-05 22:11:15 -06:00
def tournamentResults(id: String) =
Action.async { implicit req =>
val csv = HTTPRequest.acceptsCsv(req) || get("as", req).has("csv")
env.tournament.tournamentRepo byId id map {
2020-05-05 22:11:15 -06:00
_ ?? { tour =>
import lila.tournament.JsonView.playerResultWrites
val source =
2020-05-05 22:11:15 -06:00
env.tournament.api
.resultStream(tour, MaxPerSecond(40), getInt("nb", req) | Int.MaxValue)
val result =
if (csv) csvStream(lila.tournament.TournamentCsv(source))
else jsonStream(source.map(lila.tournament.JsonView.playerResultWrites.writes))
result.pipe(asAttachment(env.api.gameApiV2.filename(tour, if (csv) "csv" else "ndjson")))
2020-05-05 22:11:15 -06:00
}
}
}
def tournamentTeams(id: String) =
Action.async {
env.tournament.tournamentRepo byId id flatMap {
_ ?? { tour =>
env.tournament.jsonView.apiTeamStanding(tour) map { arr =>
JsonOk(
Json.obj(
"id" -> tour.id,
"teams" -> arr
)
)
}
}
}
}
def tournamentsByOwner(name: String, status: List[Int]) =
2020-05-05 22:11:15 -06:00
Action.async { implicit req =>
implicit val lang = reqLang
(name != lila.user.User.lichessId) ?? env.user.repo.named(name) flatMap {
2020-05-05 22:11:15 -06:00
_ ?? { user =>
val nb = getInt("nb", req) | Int.MaxValue
jsonStream {
env.tournament.api
.byOwnerStream(user, status flatMap lila.tournament.Status.apply, MaxPerSecond(20), nb)
2020-05-05 22:11:15 -06:00
.mapAsync(1)(env.tournament.apiJsonView.fullJson)
}.fuccess
}
}
}
def swissGames(id: String) =
Action.async { req =>
env.swiss.api byId lila.swiss.Swiss.Id(id) flatMap {
_ ?? { swiss =>
val config = GameApiV2.BySwissConfig(
swissId = swiss.id,
format = GameApiV2.Format byRequest req,
flags = gameC.requestPgnFlags(req, extended = false),
perSecond = MaxPerSecond(20)
)
2020-10-22 07:01:30 -06:00
GlobalConcurrencyLimitPerIP(HTTPRequest ipAddress req)(
env.api.gameApiV2.exportBySwiss(config)
) { source =>
val filename = env.api.gameApiV2.filename(swiss, config.format)
Ok.chunked(source)
2021-05-06 05:18:15 -06:00
.pipe(asAttachmentStream(filename))
.as(gameC gameContentType config)
}.fuccess
}
}
}
def swissResults(id: String) = Action.async { implicit req =>
val csv = HTTPRequest.acceptsCsv(req) || get("as", req).has("csv")
env.swiss.api byId lila.swiss.Swiss.Id(id) map {
_ ?? { swiss =>
val source = env.swiss.api
.resultStream(swiss, MaxPerSecond(50), getInt("nb", req) | Int.MaxValue)
.mapAsync(8) { p =>
env.user.lightUserApi.asyncFallback(p.player.userId) map p.withUser
}
val result =
if (csv) csvStream(lila.swiss.SwissCsv(source))
else jsonStream(source.map(env.swiss.json.playerResult))
result.pipe(asAttachment(env.api.gameApiV2.filename(swiss, if (csv) "csv" else "ndjson")))
}
}
}
2020-05-05 22:11:15 -06:00
def gamesByUsersStream =
2021-09-23 00:50:55 -06:00
AnonOrScopedBody(parse.tolerantText)() { req => me =>
val max = me.fold(300) { u => if (u.id == "lichess4545") 900 else 500 }
GlobalConcurrencyLimitPerIP(HTTPRequest ipAddress req)(
addKeepAlive(
env.game.gamesByUsersStream(
userIds = req.body.split(',').view.take(max).map(lila.user.User.normalize).toSet,
withCurrentGames = getBool("withCurrentGames", req)
)
)
)(sourceToNdJsonOption).fuccess
}
2020-07-03 07:53:20 -06:00
def cloudEval =
Action.async { req =>
get("fen", req).fold(notFoundJson("Missing FEN")) { fen =>
JsonOptionOk(
env.evalCache.api.getEvalJson(
chess.variant.Variant orDefault ~get("variant", req),
chess.format.FEN(fen),
getInt("multiPv", req) | 1
)
)
}
}
2020-05-05 22:11:15 -06:00
def eventStream =
Scoped(_.Bot.Play, _.Board.Play, _.Challenge.Read) { _ => me =>
env.round.proxyRepo.urgentGames(me) flatMap { povs =>
env.challenge.api.createdByDestId(me.id) map { challenges =>
sourceToNdJsonOption(env.api.eventStream(me, povs.map(_.game), challenges))
2020-05-05 22:11:15 -06:00
}
2018-04-16 17:58:20 -06:00
}
}
private val UserActivityRateLimitPerIP = new lila.memo.RateLimit[IpAddress](
credits = 15,
2020-04-29 08:58:36 -06:00
duration = 2.minutes,
key = "user_activity.api.ip"
)
2020-05-05 22:11:15 -06:00
def activity(name: String) =
ApiRequest { implicit req =>
implicit val lang = reqLang
2020-10-22 07:01:30 -06:00
UserActivityRateLimitPerIP(HTTPRequest ipAddress req, cost = 1) {
2020-05-05 22:11:15 -06:00
lila.mon.api.activity.increment(1)
env.user.repo named name flatMap {
_ ?? { user =>
env.activity.read.recent(user) flatMap {
_.map { env.activity.jsonView(_, user) }.sequenceFu
}
2017-08-19 08:56:34 -06:00
}
2020-05-05 22:11:15 -06:00
} map toApiResult
}(fuccess(Limited))
}
private val ApiMoveStreamGlobalConcurrencyLimitPerIP =
new lila.memo.ConcurrencyLimit[IpAddress](
name = "API concurrency per IP",
key = "round.apiMoveStream.ip",
ttl = 20.minutes,
maxConcurrency = 8
)
def moveStream(gameId: String) =
Action.async { req =>
env.round.proxyRepo.gameIfPresent(gameId) map {
case None => NotFound
case Some(game) =>
ApiMoveStreamGlobalConcurrencyLimitPerIP(HTTPRequest ipAddress req)(
addKeepAlive(env.round.apiMoveStream(game, gameC.delayMovesFromReq(req)))
)(sourceToNdJsonOption)
}
}
def perfStat(username: String, perfKey: String) = ApiRequest { req =>
implicit val lang = reqLang(req)
env.perfStat.api.data(username, perfKey, none) map {
_.fold[ApiResult](NoData) { data => Data(env.perfStat.jsonView(data)) }
}
}
2020-05-05 22:11:15 -06:00
def CookieBasedApiRequest(js: Context => Fu[ApiResult]) =
Open { ctx =>
js(ctx) map toHttp
}
def ApiRequest(js: RequestHeader => Fu[ApiResult]) =
Action.async { req =>
js(req) map toHttp
}
def MobileApiRequest(js: RequestHeader => Fu[ApiResult]) =
Action.async { req =>
if (lila.api.Mobile.Api requested req) js(req) map toHttp
else fuccess(NotFound)
}
2017-01-22 13:57:12 -07:00
2019-12-05 14:51:18 -07:00
def toApiResult(json: Option[JsValue]): ApiResult = json.fold[ApiResult](NoData)(Data.apply)
2019-12-13 07:30:20 -07:00
def toApiResult(json: Seq[JsValue]): ApiResult = Data(JsArray(json))
2017-05-07 02:31:25 -06:00
2020-05-05 22:11:15 -06:00
def toHttp(result: ApiResult): Result =
result match {
case Limited => rateLimitedJson
case ClientError(msg) => BadRequest(jsonError(msg))
case NoData => NotFound
case Custom(result) => result
case Done => jsonOkResult
case Data(json) => JsonOk(json)
2020-05-05 22:11:15 -06:00
}
2018-05-07 16:29:14 -06:00
2020-01-21 18:08:24 -07:00
def jsonStream(makeSource: => Source[JsValue, _])(implicit req: RequestHeader): Result =
2020-10-22 07:01:30 -06:00
GlobalConcurrencyLimitPerIP(HTTPRequest ipAddress req)(makeSource)(sourceToNdJson)
2018-05-08 08:31:38 -06:00
def addKeepAlive(source: Source[JsValue, _]): Source[Option[JsValue], _] =
source
.map(some)
.keepAlive(60.seconds, () => none) // play's idleTimeout = 75s
def sourceToNdJson(source: Source[JsValue, _]): Result =
2020-01-21 18:08:24 -07:00
sourceToNdJsonString {
source.map { o =>
Json.stringify(o) + "\n"
}
2020-01-21 17:52:24 -07:00
}
2018-05-08 08:31:38 -06:00
def sourceToNdJsonOption(source: Source[Option[JsValue], _]): Result =
2020-01-21 18:08:24 -07:00
sourceToNdJsonString {
source.map { _ ?? Json.stringify + "\n" }
2020-01-21 17:52:24 -07:00
}
2019-12-05 14:51:18 -07:00
private def sourceToNdJsonString(source: Source[String, _]): Result =
Ok.chunked(source).as(ndJsonContentType) pipe noProxyBuffer
2020-01-21 18:08:24 -07:00
def csvStream(makeSource: => Source[String, _])(implicit req: RequestHeader): Result =
GlobalConcurrencyLimitPerIP(HTTPRequest ipAddress req)(makeSource)(sourceToCsv)
private def sourceToCsv(source: Source[String, _]): Result =
Ok.chunked(source.map(_ + "\n")).as(csvContentType) pipe noProxyBuffer
2020-01-21 17:52:24 -07:00
private[controllers] val GlobalConcurrencyLimitPerIP = new lila.memo.ConcurrencyLimit[IpAddress](
name = "API concurrency per IP",
2019-12-05 14:51:18 -07:00
key = "api.ip",
2020-04-29 08:58:36 -06:00
ttl = 1.hour,
2020-01-21 17:52:24 -07:00
maxConcurrency = 2
2019-12-05 14:51:18 -07:00
)
2020-01-21 17:52:24 -07:00
private[controllers] val GlobalConcurrencyLimitUser = new lila.memo.ConcurrencyLimit[lila.user.User.ID](
name = "API concurrency per user",
2019-12-05 14:51:18 -07:00
key = "api.user",
2020-04-29 08:58:36 -06:00
ttl = 1.hour,
2020-01-21 17:52:24 -07:00
maxConcurrency = 1
2019-12-05 14:51:18 -07:00
)
2020-01-21 17:52:24 -07:00
private[controllers] def GlobalConcurrencyLimitPerUserOption[T](
2019-12-13 07:30:20 -07:00
user: Option[lila.user.User]
2020-01-21 17:52:24 -07:00
): Option[SourceIdentity[T]] =
user.fold(some[SourceIdentity[T]](identity)) { u =>
2020-01-21 17:52:24 -07:00
GlobalConcurrencyLimitUser.compose[T](u.id)
2019-12-05 14:51:18 -07:00
}
2020-01-21 17:52:24 -07:00
private[controllers] def GlobalConcurrencyLimitPerIpAndUserOption[T](
req: RequestHeader,
me: Option[lila.user.User]
)(makeSource: => Source[T, _])(makeResult: Source[T, _] => Result): Result =
2020-10-22 07:01:30 -06:00
GlobalConcurrencyLimitPerIP.compose[T](HTTPRequest ipAddress req) flatMap { limitIp =>
2020-01-21 17:52:24 -07:00
GlobalConcurrencyLimitPerUserOption[T](me) map { limitUser =>
makeResult(limitIp(limitUser(makeSource)))
}
} getOrElse lila.memo.ConcurrencyLimit.limitedDefault(1)
private type SourceIdentity[T] = Source[T, _] => Source[T, _]
2019-12-05 14:51:18 -07:00
}
private[controllers] object Api {
sealed trait ApiResult
case class Data(json: JsValue) extends ApiResult
case class ClientError(msg: String) extends ApiResult
case object NoData extends ApiResult
case object Done extends ApiResult
case object Limited extends ApiResult
case class Custom(result: Result) extends ApiResult
2013-12-30 19:00:56 -07:00
}