Files
lila/app/controllers/Study.scala
T
Thibault Duplessis 896c90c344 split lila.mon from lila.common
to parallelize compilation
2026-05-05 11:25:21 +02:00

623 lines
24 KiB
Scala

package controllers
import play.api.libs.json.*
import play.api.mvc.*
import scalalib.Json.given
import scalalib.paginator.Paginator
import lila.analyse.Analysis
import lila.app.{ *, given }
import lila.common.HTTPRequest
import lila.core.id.RelayRoundId
import lila.core.misc.lpv.LpvEmbed
import lila.core.socket.Sri
import lila.core.study.StudyOrder
import lila.core.data.ErrorMsg
import lila.study.JsonView.JsData
import lila.study.PgnDump.WithFlags
import lila.study.Study.WithChapter
import lila.study.{ Who, Chapter, Orders, Settings, Study as StudyModel, StudyForm }
import lila.tree.Node.partitionTreeWriter
import lila.ui.Page
import lila.mon.extensions.*
final class Study(
env: Env,
editorC: => Editor,
userAnalysisC: => UserAnalysis,
apiC: => Api
) extends LilaController(env):
private def pgnDump = env.study.pgnDump
def search(text: String, page: Int, order: Option[StudyOrder]) =
OpenOrScopedBody(parse.anyContent)(_.Study.Read, _.Web.Mobile):
Reasonable(page):
WithProxy: proxy ?=>
val maxLen =
if proxy.isFloodish then 50
else if HTTPRequest.isCrawler(req).yes then 80
else if ctx.isAnon then 100
else 200
text.trim.nonEmptyOption.filter(_.sizeIs > 2).filter(_.sizeIs < maxLen) match
case None =>
for
pag <- env.study.pager.all(Orders.default, page)
_ <- preloadMembers(pag)
res <- negotiate(
Ok.page(views.study.list.all(pag, Orders.default)),
apiStudies(pag)
)
yield res
case Some(clean) =>
limit.enumeration.search(rateLimited):
env
.studySearch(clean.take(100), order | StudyOrder.relevant, page)
.flatMap: pag =>
negotiate(
Ok.page(views.study.list.search(pag, order | StudyOrder.relevant, text)),
apiStudies(pag)
)
def homeLang = LangPage(routes.Study.allDefault())(allResults(StudyOrder.hot, 1))
def allDefault(page: Int) = all(StudyOrder.hot, page)
def all(order: StudyOrder, page: Int) = OpenOrScoped(_.Study.Read, _.Web.Mobile):
allResults(order, page)
private def allResults(order: StudyOrder, page: Int)(using ctx: Context) =
Reasonable(page):
order match
case order if !Orders.withoutSelector.contains(order) =>
Redirect(routes.Study.allDefault(page))
case order =>
for
pag <- env.study.pager.all(order, page)
_ <- preloadMembers(pag)
res <- negotiate(
Ok.page(views.study.list.all(pag, order)),
apiStudies(pag)
)
yield res
def byOwnerDefault(username: UserStr, page: Int) = byOwner(username, Orders.default, page)
def byOwner(username: UserStr, order: StudyOrder, page: Int) = Open:
Found(meOrFetch(username)): owner =>
for
pag <- env.study.pager.byOwner(owner, order, page)
_ <- preloadMembers(pag)
res <- negotiate(Ok.page(views.study.list.byOwner(pag, order, owner)), apiStudies(pag))
yield res
def mine = MyStudyPager(
env.study.pager.mine,
(pag, order) => env.study.topicApi.userTopics(summon[Me]).map(views.study.list.mine(pag, order, _))
)
def minePublic = MyStudyPager(env.study.pager.minePublic, views.study.list.minePublic)
def minePrivate = MyStudyPager(env.study.pager.minePrivate, views.study.list.minePrivate)
def mineMember = MyStudyPager(
env.study.pager.mineMember,
(pag, order) => env.study.topicApi.userTopics(summon[Me]).map(views.study.list.mineMember(pag, order, _))
)
def mineLikes = MyStudyPager(env.study.pager.mineLikes, views.study.list.mineLikes)
private type StudyPager = Paginator[StudyModel.WithChaptersAndLiked]
private def MyStudyPager(
makePager: (StudyOrder, Int) => Me ?=> Fu[StudyPager],
render: (StudyPager, StudyOrder) => Context ?=> Me ?=> Fu[Page]
) = (order: StudyOrder, page: Int) =>
AuthOrScoped(_.Web.Mobile) { ctx ?=> me ?=>
for
pager <- makePager(order, page)
_ <- preloadMembers(pager)
res <- negotiate(Ok.async(render(pager, order)), apiStudies(pager))
yield res
}
def byTopic(name: String, order: StudyOrder, page: Int) = Open:
Found(lila.study.StudyTopic.fromStr(name)): topic =>
for
pag <- env.study.pager.byTopic(topic, order, page)
_ <- preloadMembers(pag)
res <- negotiate(
Ok.async:
ctx.userId
.traverse(env.study.topicApi.userTopics)
.map(views.study.list.topic.show(topic, pag, order, _))
,
apiStudies(pag)
)
yield res
private def preloadMembers(pag: Paginator[StudyModel.WithChaptersAndLiked]) =
env.user.lightUserApi.preloadMany(
pag.currentPageResults.view
.flatMap(_.study.members.members.values.take(StudyModel.previewNbMembers))
.map(_.id)
.toSeq
)
private def apiStudies(pager: Paginator[StudyModel.WithChaptersAndLiked]) =
given Writes[StudyModel.WithChaptersAndLiked] = Writes(env.study.jsonView.pagerData)
Ok(Json.obj("paginator" -> pager))
private def orRelayRedirect(id: StudyId, chapterId: Option[StudyChapterId] = None)(
f: => Fu[Result]
)(using ctx: Context): Fu[Result] =
if HTTPRequest.isRedirectable(ctx.req)
then
env.relay.api
.byIdWithTour(id.into(RelayRoundId))
.flatMap:
_.fold(f): rt =>
Redirect(chapterId.fold(rt.call)(rt.call))
else f
private def showQuery(query: Option[WithChapter])(using ctx: Context): Fu[Result] =
Found(query): oldSc =>
CanView(oldSc.study) {
if !oldSc.study.notable && HTTPRequest.isCrawler(req).yes
then notFound
else
negotiate(
html =
val noCrawler = HTTPRequest.isCrawler(ctx.req).no
for
(sc, data) <- getJsonData(oldSc, withChapters = true)
chat <- noCrawler.so(chatOf(sc.study))
sVersion <- noCrawler.so(env.study.version(sc.study.id))
streamers <- noCrawler.so(streamerCache.get(sc.study.id))
page <- renderPage(views.study.show(sc.study, sc.chapter, data, chat, sVersion, streamers))
yield Ok(page)
.withCanonical(routes.Study.chapter(sc.study.id, sc.chapter.id))
.enforceCrossSiteIsolation
,
json = for
(sc, data) <- getJsonData(
oldSc,
withChapters = getBool("chapters") || HTTPRequest.isLichobile(ctx.req)
)
chatOpt <- HTTPRequest.isXhr(ctx.req).not.so(chatOf(sc.study))
jsChat <- chatOpt.traverse: c =>
env.chat.json.mobile(c.chat, writeable = ctx.userId.so(sc.study.canChat))
yield Ok:
Json.obj(
"study" -> data.study.add("chat" -> jsChat),
"analysis" -> data.analysis
)
)
}(privateUnauthorizedFu(oldSc.study), privateForbiddenFu(oldSc.study))
.dmap(_.noCache)
private[controllers] def getJsonData(sc: WithChapter, withChapters: Boolean)(using
ctx: Context
): Fu[(WithChapter, JsData)] =
for
(studyFromDb, chapter) <- env.study.api.maybeResetAndGetChapter(sc.study, sc.chapter)
study <- env.relay.api.reconfigureStudy(studyFromDb, chapter)
previews <- withChapters.optionFu(env.study.preview.jsonList(study.id))
_ <- env.user.lightUserApi.preloadMany(study.members.ids.toList)
pov = userAnalysisC.makePov(chapter.root.fen.some, chapter.setup.variant)
analysis <- chapterAnalysis(sc)
division = analysis.isDefined.option(env.study.serverEvalMerger.divisionOf(chapter))
baseData <- env.analyse.externalEngine.withExternalEngines(
env.round.jsonView.userAnalysisJson(
pov,
ctx.pref,
chapter.root.fen.some,
chapter.setup.orientation,
owner = false,
division = division
)
)
withMembers = !study.isRelay || isGrantedOpt(_.StudyAdmin) || ctx.me.exists(study.isMember)
studyJson <- env.study.jsonView.full(study, chapter, previews, withMembers = withMembers)
lichobile = HTTPRequest.isLichobile(ctx.req)
yield WithChapter(study, chapter) -> JsData(
study = studyJson,
analysis = baseData
.add("treeParts" -> partitionTreeWriter(chapter.root, lichobile = lichobile).some)
.add("analysis" -> analysis.map { env.analyse.jsonView.bothPlayers(chapter.root.ply, _) })
)
private def chapterAnalysis(sc: WithChapter) =
sc.chapter.serverEval
.exists(_.done)
.so(env.analyse.repo.byId(Analysis.Id(sc.study.id, sc.chapter.id)))
def show(id: StudyId) = OpenOrScoped(_.Study.Read, _.Web.Mobile):
orRelayRedirect(id):
env.study.api.byIdWithChapter(id).flatMap(showQuery)
def chapter(id: StudyId, chapterId: StudyChapterId) = OpenOrScoped(_.Study.Read, _.Web.Mobile):
orRelayRedirect(id, chapterId.some):
env.study.api
.byIdWithChapter(id, chapterId)
.flatMap:
case None =>
env.study.studyRepo
.exists(id)
.flatMap:
if _ then negotiate(Redirect(routes.Study.show(id)), notFoundJson())
else showQuery(none)
case sc => showQuery(sc)
def chapterConfig(id: StudyId, chapterId: StudyChapterId) = Open:
Found(env.study.chapterRepo.byIdAndStudy(chapterId, id)): chapter =>
Ok(env.study.jsonView.chapterConfig(chapter))
private[controllers] def chatOf(study: lila.study.Study)(using ctx: Context) = {
ctx.kid.no && ctx.noBot // no public chats for kids and bots
}.optionFu:
env.chat.api.userChat
.findMine(study.id.into(ChatId))
.mon(lila.mon.chat.fetch("study"))
def createAs = AuthBody { ctx ?=> me ?=>
bindForm(StudyForm.importGame.form)(
_ => Redirect(routes.Study.byOwnerDefault(me.username)),
data =>
for
owner <- env.study.api.recentByOwnerWithChapterCount(me, 50)
contrib <- env.study.api.recentByContributorWithChapterCount(me, 50)
res <-
if owner.isEmpty && contrib.isEmpty then createStudy(data)
else
val back = HTTPRequest
.referer(ctx.req)
.orElse:
data.fen.map(fen => editorC.editorUrl(fen, data.variant | chess.variant.Variant.default))
Ok.page(views.study.create(data, owner, contrib, back))
yield res
)
}
def create = AuthBody { ctx ?=> me ?=>
bindForm(StudyForm.importGame.form)(
_ => Redirect(routes.Study.byOwnerDefault(me.username)),
createStudy
)
}
private def createStudy(data: StudyForm.importGame.Data)(using ctx: Context, me: Me) =
val cost = if !data.isNewStudy then 0 else if coachOrTitled then 1 else 2
limit.studyCreate(me.userId -> ctx.ip, rateLimited, cost):
Found(env.study.api.importGame(lila.study.StudyMaker.ImportGame(data), me, ctx.pref.showRatings)): sc =>
Redirect(routes.Study.chapter(sc.study.id, sc.chapter.id))
def apiCreate = ScopedBody(_.Study.Write) { _ ?=> me ?=>
bindForm(StudyForm.form)(
jsonFormError,
data =>
limit.studyCreate(me.userId -> ctx.ip, rateLimited, if coachOrTitled then 1 else 2):
for sc <- env.study.api.create(data)
yield JsonOk(Json.obj("id" -> sc.study.id))
)
}
private def coachOrTitled(using me: Me) = isGranted(_.Coach) || me.hasTitle
def delete(id: StudyId) = Auth { _ ?=> me ?=>
Found(env.study.api.byIdAndOwnerOrAdmin(id, me)): study =>
for
round <- env.relay.api.deleteRound(id.into(RelayRoundId))
_ <- env.study.api.delete(study)
yield round match
case None => Redirect(routes.Study.mine(StudyOrder.hot))
case Some(tour) => Redirect(routes.RelayTour.show(tour.slug, tour.id))
}
def apiChapterDelete(id: StudyId, chapterId: StudyChapterId) = ScopedBody(_.Study.Write) { _ ?=> me ?=>
Found(env.study.api.byIdAndOwnerOrAdmin(id, me)): study =>
env.study.api.deleteChapter(study.id, chapterId)(Who(me.userId, Sri("api"))).inject(NoContent)
}
def clearChat(id: StudyId) = Auth { _ ?=> me ?=>
env.study.api
.isOwnerOrAdmin(id, me)
.flatMapz:
env.chat.api.userChat.clear(id.into(ChatId))
.inject(Redirect(routes.Study.show(id)))
}
private def doImportPgn(id: StudyId, data: StudyForm.importPgn.Data, sri: Sri)(
f: (List[Chapter], Option[ErrorMsg]) => Result
)(using ctx: Context, me: Me): Future[Result] =
val chapterDatas = data.toChapterDatas
limit.studyPgnImport(me, rateLimited, cost = chapterDatas.size):
env.study.api
.importPgns(id, chapterDatas, sticky = data.sticky, ctx.pref.showRatings)(Who(me, sri))
.map(f.tupled)
def importPgn(id: StudyId) = AuthBody { ctx ?=> me ?=>
get("sri").so: sri =>
bindForm(StudyForm.importPgn.form)(
doubleJsonFormError,
data =>
doImportPgn(id, data, Sri(sri)): (_, errors) =>
errors.fold(NoContent)(BadRequest(_))
)
}
def apiImportPgn(id: StudyId) = ScopedBody(_.Study.Write) { ctx ?=> me ?=>
bindForm(StudyForm.importPgn.form)(
jsonFormError,
data =>
doImportPgn(id, data, Sri("api")): (chapters, errors) =>
import lila.study.ChapterPreview.json.given
import lila.fide.Federation.find
val previews = chapters.map(env.study.preview.fromChapter(_))
JsonOk(Json.obj("chapters" -> previews, "error" -> errors))
)
}
def admin(id: StudyId) = Auth { ctx ?=> me ?=>
Found(env.study.api.byId(id)): study =>
getBoolOpt("unfeature") match
case Some(true) if lila.study.canUnfeature =>
for
_ <- env.study.studyRepo.unfeature(id, true)
_ <- env.mod.logApi.studyUnfeature(study)
yield Redirect(HTTPRequest.referer(ctx.req) | routes.Study.allDefault().url)
case None if isGranted(_.StudyAdmin) =>
for
_ <- env.study.api.becomeAdmin(id, me)
_ <- env.relay.api.becomeStudyAdmin(id, me)
yield if HTTPRequest.isXhr(ctx.req) then NoContent else Redirect(routes.Study.show(id))
case _ => authorizationFailed
}
def embed(studyId: StudyId, chapterId: StudyChapterId) = Anon:
InEmbedContext:
def notFound = NotFound.snip(views.study.embed.notFound)
env.study.api
.byId(studyId)
.flatMap:
_.fold(notFound.toFuccess): study =>
val finalChapterId = if chapterId.value == "autochap" then study.position.chapterId else chapterId
env.api.textLpvExpand
.getChapterPgn(finalChapterId)
.map:
case Some(LpvEmbed.PublicPgn(pgn)) => Ok.snip(views.study.embed(study, finalChapterId, pgn))
case _ => notFound
def cloneStudy(id: StudyId) = Auth { ctx ?=> _ ?=>
Found(env.study.api.byId(id)): study =>
CanView(study, study.settings.cloneable.some) {
Ok.page(views.study.clone(study))
}(privateUnauthorizedFu(study), privateForbiddenFu(study))
}
def cloneApply(id: StudyId) = Auth { ctx ?=> me ?=>
limit.studyClone(me.userId -> ctx.ip, rateLimited, if coachOrTitled then 1 else 3):
Found(env.study.api.byId(id)) { prev =>
CanView(prev, prev.settings.cloneable.some) {
env.study.api
.cloneWithChat(me, prev)
.map: study =>
Redirect(routes.Study.show((study | prev).id))
}(privateUnauthorizedFu(prev), privateForbiddenFu(prev))
}
}
def pgn(id: StudyId) = Open:
pgnWithFlags(id, identity)
def apiPgn(id: StudyId) = AnonOrScoped(_.Study.Read, _.Web.Mobile): ctx ?=>
pgnWithFlags(id, identity)
def pgnWithFlags(id: StudyId, flags: Update[WithFlags])(using Context) =
Found(env.study.api.byId(id)): study =>
HeadLastModifiedAt(study.updatedAt):
val limiter = if study.isRelay then limit.relayPgn else limit.studyPgn
limiter[Fu[Result]](req.ipAddress, rateLimited, msg = id.value):
CanView(study, study.settings.shareable.some)(doPgn(study, flags))(
privateUnauthorizedFu(study),
privateForbiddenFu(study)
)
private def doPgn(study: StudyModel, flags: Update[WithFlags])(using RequestHeader) =
def makeStudySource = pgnDump.chaptersOf(study, _ => flags(pgnDump.requestPgnFlags()))
val pgnSource = akka.stream.scaladsl.Source.futureSource:
if study.isRelay
then env.relay.pgnStream.ofStudy(study).map(_ | makeStudySource)
else fuccess(makeStudySource)
Ok.chunked(pgnSource.throttle(20, 1.second))
.asAttachmentStream(s"${pgnDump.filename(study)}.pgn")
.as(pgnContentType)
.withDateHeaders(lastModified(study.updatedAt))
def chapterPgn(id: StudyId, chapterId: StudyChapterId) = Open:
doChapterPgn(id, chapterId, notFound, privateUnauthorizedFu, privateForbiddenFu)
def apiChapterPgn(id: StudyId, chapterId: StudyChapterId) =
AnonOrScoped(_.Study.Read, _.Web.Mobile): ctx ?=>
doChapterPgn(
id,
chapterId,
fuccess(NotFound("Study or chapter not found")),
_ => fuccess(Unauthorized("This study is now private")),
_ => fuccess(Forbidden("This study is now private"))
)
private def doChapterPgn(
id: StudyId,
chapterId: StudyChapterId,
studyNotFound: => Fu[Result],
studyUnauthorized: StudyModel => Fu[Result],
studyForbidden: StudyModel => Fu[Result]
)(using Context) =
env.study.api
.byIdWithChapter(id, chapterId)
.flatMap:
_.fold(studyNotFound) { case sc @ WithChapter(study, chapter) =>
CanView(study) {
def makeChapterPgn = pgnDump.ofChapter(study, pgnDump.requestPgnFlags())(chapter)
for
pgn <-
if study.isRelay
then env.relay.pgnStream.ofChapter(sc).getOrElse(makeChapterPgn)
else makeChapterPgn
analysisJson <- getBool("analysisHeader").so:
chapterAnalysis(sc).map2: analysis =>
val division = env.study.serverEvalMerger.divisionOf(chapter)
env.analyse.jsonView.analysisHeader(sc.chapter.root, division, analysis)
filename = s"${pgnDump.filename(study, chapter)}.pgn"
res = Ok(pgn.toString).as(pgnContentType).asAttachment(filename)
resWithAnalysis = analysisJson.fold(res): a =>
res.withHeaders("X-Lichess-Analysis" -> Json.stringify(a))
yield resWithAnalysis
}(studyUnauthorized(study), studyForbidden(study))
}
def apiExportPgn(username: UserStr) = OpenOrScoped(_.Study.Read, _.Web.Mobile): ctx ?=>
val name =
if username.value == "me"
then ctx.me.fold(UserName("me"))(_.username)
else username.into(UserName)
val userId = name.id
val isMe = ctx.me.exists(_.is(userId))
val makeStream = env.study.studyRepo
.sourceByOwner(userId, isMe)
.flatMapConcat(pgnDump.chaptersOf(_, _ => pgnDump.requestPgnFlags()))
.throttle(if isMe then 20 else 10, 1.second)
apiC.GlobalConcurrencyLimitPerIpAndUserOption(userId.some)(makeStream): source =>
Ok.chunked(source)
.asAttachmentStream(s"${name}-${if isMe then "all" else "public"}-studies.pgn")
.as(pgnContentType)
def apiListByOwner(username: UserStr) = OpenOrScoped(_.Study.Read, _.Web.Mobile): ctx ?=>
val isMe = ctx.is(username)
apiC.jsonDownload:
env.study.studyRepo
.sourceByOwner(username.id, isMe)
.throttle(if isMe then 50 else 20, 1.second)
.map(lila.study.JsonView.metadata)
def chapterGif(
id: StudyId,
chapterId: StudyChapterId,
theme: Option[String],
piece: Option[String],
showGlyphs: Boolean
) = Open:
Found(env.study.api.byIdWithChapter(id, chapterId)):
case WithChapter(study, chapter) =>
CanView(study) {
env.study.gifExport
.ofChapter(chapter, theme, piece, showGlyphs)
.map: stream =>
Ok.chunked(stream)
.asAttachmentStream(s"${pgnDump.filename(study, chapter)}.gif")
.as("image/gif")
.recover { case lila.core.lilaism.LilaInvalid(msg) =>
BadRequest(msg)
}
}(privateUnauthorizedFu(study), privateForbiddenFu(study))
def apiChapterTagsUpdate(studyId: StudyId, chapterId: StudyChapterId) =
AuthOrScopedBody(_.Study.Write) { _ ?=> _ ?=>
bindForm(StudyForm.chapterTagsForm)(
jsonFormError,
pgn => env.study.api.updateChapterTags(studyId, chapterId, pgn).inject(NoContent)
)
}
def apiChapterPgnMovesUpdate(studyId: StudyId, chapterId: StudyChapterId) =
AuthOrScopedBody(_.Study.Write) { _ ?=> me ?=>
bindForm(StudyForm.replaceChapterPgnMoves)(
jsonFormError,
pgnStr =>
env.study.api
.replaceChapterPgnMoves(studyId, chapterId, pgnStr)
.map:
if _ then NoContent
else JsonBadRequest(s"Invalid or forbidden chapter $studyId/$chapterId")
.recover:
case lila.study.StudyValidationException(error) => JsonBadRequest(error)
)
}
def topicAutocomplete = Anon:
get("term").filter(_.nonEmpty) match
case None => BadRequest("No search term provided")
case Some(term) =>
import lila.common.Json.given
env.study.topicApi.findLike(term, getUserStr("user").map(_.id)).map { JsonOk(_) }
def topics = OpenOrScoped():
for
popular <- env.study.topicApi.popular(50)
ofUser = ctx.userId.ifTrue(ctx.isWebAuth || ctx.oauth.exists(_.has(_.Study.Read)))
mine <- ofUser.traverse(env.study.topicApi.userTopics)
result <- negotiate(
Ok.page(views.study.list.topic.index(popular, mine, mine.map(StudyForm.topicsForm))),
Ok(Json.obj("popular" -> popular).add("mine" -> mine))
)
yield result
def setTopics = AuthBody { ctx ?=> me ?=>
bindForm(StudyForm.topicsForm)(
_ => Redirect(routes.Study.topics),
topics =>
import com.fasterxml.jackson.core.JsonParseException
try env.study.topicApi.userTopics(me, topics).inject(Redirect(routes.Study.topics))
catch case e: JsonParseException => BadRequest(e.getMessage)
)
}
def staffPicks = Open:
pageHit
FoundPage(env.cms.renderKey("studies-staff-picks")): page =>
val featured = isGrantedOpt(_.StudyAdmin).option(env.study.pager.featured.setting.form)
views.study.staffPicks(page, featured)
def staffPicksPost = SecureBody(_.StudyAdmin) { _ ?=> _ ?=>
bindForm(env.study.pager.featured.setting.form)(
_ => Redirect(routes.Study.staffPicks),
v => env.study.pager.featured.setting.setString(v.toString).inject(Redirect(routes.Study.staffPicks))
)
}
def privateUnauthorizedJson = Unauthorized(jsonError("This study is now private"))
def privateUnauthorizedFu(study: StudyModel)(using Context) = negotiate(
Unauthorized.page(views.study.privateStudy(study)),
privateUnauthorizedJson
)
def privateForbiddenJson = forbiddenJson("This study is now private")
def privateForbiddenFu(study: StudyModel)(using Context) = negotiate(
Forbidden.page(views.study.privateStudy(study)),
privateForbiddenJson
)
def CanView(study: StudyModel, userSelection: Option[Settings.UserSelection] = none)(
f: => Fu[Result]
)(unauthorized: => Fu[Result], forbidden: => Fu[Result])(using me: Option[Me]): Fu[Result] =
def withUserSelection =
if userSelection.forall(Settings.UserSelection.allows(_, study, me.map(_.userId))) then f
else forbidden
me match
case _ if !study.isPrivate => withUserSelection
case None => unauthorized
case Some(me) if study.members.contains(me.value) => withUserSelection
case _ => forbidden
private val streamerCache =
env.memo.cacheApi[StudyId, List[UserId]](64, "study.streamers"):
_.expireAfterWrite(10.seconds).buildAsyncFuture: studyId =>
env.study.findConnectedUsersIn(studyId)(env.streamer.liveApi.streamerUserIds)
def glyphs(lang: String) = Anon:
Found(play.api.i18n.Lang.get(lang)): lang =>
JsonOk:
lila.study.JsonView.glyphs(using env.translator.to(lang))
.headerCacheSeconds(3600)