Files
2026-05-05 11:25:21 +02:00

633 lines
25 KiB
Scala

package controllers
import akka.stream.scaladsl.*
import play.api.http.ContentTypes
import play.api.libs.EventSource
import play.api.libs.json.*
import play.api.mvc.*
import scalalib.paginator.Paginator
import lila.app.{ *, given }
import lila.common.HTTPRequest
import lila.common.Json.given
import lila.core.user.LightPerf
import lila.core.userId.UserSearch
import lila.game.GameFilter
import lila.mod.UserWithModlog
import lila.rating.PerfType
import lila.rating.UserPerfsExt.best8Perfs
import lila.security.UserLogins
import lila.user.WithPerfsAndEmails
import lila.mon.extensions.*
final class User(
override val env: Env,
roundC: => Round,
gameC: => Game,
modC: => Mod
) extends LilaController(env):
import env.relation.api as relationApi
import env.gameSearch.userGameSearch
import env.user.lightUserApi
def tv(username: UserStr) = Open:
Found(meOrFetch(username)): user =>
env.round.currentlyPlaying
.exec(user.id)
.orElse(env.round.lastPlayed(user.id))
.flatMap:
_.fold(fuccess(Redirect(routes.User.show(username)))): pov =>
ctx.me.filterNot(_ => pov.game.bothPlayersHaveMoved).flatMap { Pov(pov.game, _) } match
case Some(mine) => Redirect(routes.Round.player(mine.fullId))
case _ => roundC.watch(pov, userTv = user.some)
def tvExport(username: UserStr) = Anon:
env.game.cached
.lastPlayedPlayingId(username.id)
.orElse(env.game.gameRepo.quickLastPlayedId(username.id))
.flatMap:
case None => NotFound("No ongoing game")
case Some(gameId) => gameC.exportGame(gameId)
private def apiGames(u: UserModel, filter: String, page: Int)(using BodyContext[?]) =
userGames(u, filter, page).flatMap(env.game.userGameApi.jsPaginator).map { res =>
Ok(res ++ Json.obj("filter" -> GameFilter.all.name))
}
private val userShowHtmlRateLimit =
env.security.ipTrust.rateLimit(3_000, 1.day, "user.show.html.ip", _.antiScraping(dch = 10, others = 4))
def show(username: UserStr) = OpenBody:
EnabledUser(username): u =>
negotiate(
renderShow(u),
apiGames(u, GameFilter.all.name, 1)
)
def search(term: String) = Open: _ ?=>
UserStr.read(term) match
case Some(username) => Redirect(routes.User.show(username)).toFuccess
case _ if isGrantedOpt(_.UserSearch) => Redirect(s"${routes.Mod.search}?q=$term").toFuccess
case _ => notFound
private def renderShow(u: UserModel, status: Results.Status = Results.Ok)(using Context): Fu[Result] =
WithProxy: proxy ?=>
limit.enumeration.userProfile(rateLimited):
def fetchActivity = (ctx.isAuth || !proxy.isFloodish).so(env.activity.read.recentAndPreload(u))
if HTTPRequest.isSynchronousHttp(ctx.req)
then
val cost =
if isGrantedOpt(_.UserModView) then 0
else if env.socket.isOnline.exec(u.id) then 1
else 2
userShowHtmlRateLimit(rateLimited, cost = cost):
for
as <- fetchActivity
nbs <- env.userNbGames(u, withCrosstable = false)
info <- env.userInfo.fetch(u, nbs)
_ <- env.userInfo.preloadTeams(info)
social <- env.socialInfo(u)
page <- renderPage:
lila.mon.chronoSync(lila.mon.user.segment("renderSync")):
views.user.show.page.activity(as, info, social)
yield status(page).withCanonical(routes.User.show(u.username))
else
for
withPerfs <- env.user.perfsRepo.withPerfs(u)
as <- fetchActivity
snip = lila.ui.Snippet(views.activity(withPerfs, as))
yield status(snip)
def download(username: UserStr) = OpenBody:
val user =
meOrFetch(username).dmap(_.filter(u => u.enabled.yes || ctx.is(u) || isGrantedOpt(_.GamesModView)))
FoundPage(user):
views.user.download(_)
def gamesAll(username: UserStr, page: Int) = games(username, GameFilter.all.name, page)
def games(username: UserStr, filter: String, page: Int) = OpenBody:
Reasonable(page):
WithProxy: proxy ?=>
limit.enumeration.userProfile(rateLimited):
EnabledUser(username): u =>
val isSearch = filter == GameFilter.search.name
if isSearch && ctx.isAnon
then
negotiate(
Unauthorized.page(views.gameSearch.login(u.count.game)),
Unauthorized(jsonError("Login required"))
)
else
negotiate(
html = for
nbs <- env.userNbGames(u, withCrosstable = true)
filters = lila.app.mashup.GameFilterMenu(u, nbs, filter, ctx.isAuth)
pag <- env.gamePaginator(user = u, nbs = nbs.some, filter = filters.current, page = page)
_ <- lightUserApi.preloadMany(pag.currentPageResults.flatMap(_.userIds))
_ <- env.tournament.cached.nameCache.preloadMany {
pag.currentPageResults.flatMap((_: GameModel).tournamentId).map(tid => tid -> ctx.lang)
}
res <-
if HTTPRequest.isSynchronousHttp(ctx.req) then
for
info <- env.userInfo.fetch(u, nbs, withUblog = !isSearch)
_ <- env.team.cached.lightCache.preloadMany(info.teamIds)
social <- env.socialInfo(u)
searchForm = (filters.current == GameFilter.search).option(
lila.app.mashup.GameFilterMenu.searchForm(userGameSearch, filters.current)
)
res <- Ok.page:
views.user.show.page.games(info, pag, filters, searchForm, social)
yield res
else Ok.snip(views.user.show.gamesContent(u, nbs, pag, filters, filter)).toFuccess
yield res.withCanonical(routes.User.games(u.username, filters.current.name)),
json = apiGames(u, filter, page)
)
private def EnabledUser(username: UserStr)(f: UserModel => Fu[Result])(using ctx: Context): Fu[Result] =
if username.id.isGhost
then
negotiate(
Ok.page(views.user.show.page.deleted(false)),
notFoundJson("Deleted user")
)
else
def notFound(canCreate: Boolean) = negotiate(
NotFound.page(views.user.show.page.deleted(canCreate)),
NotFound(jsonError("No such player, or account closed"))
)
meOrFetch(username).flatMap:
case None =>
env.api.anySearch
.redirect(username.value)
.flatMap:
case Some(url) => Redirect(url).toFuccess
case None if isGrantedOpt(_.AccountInfo) => ctx.useMe(modC.searchTerm(username.value))
case None => notFound(true)
case Some(u) if u.enabled.yes || isGrantedOpt(_.AccountInfo) => f(u)
case u => notFound(u.isEmpty)
def showMini(username: UserStr) = Open:
Found(env.user.api.withPerfs(username)): user =>
ctx.userId
.so(relationApi.fetchRelation(_, user.id))
.flatMap: relation =>
if user.enabled.yes || isGrantedOpt(_.AccountInfo)
then
(
ctx.userId.so(relationApi.fetchBlocks(user.id, _)),
ctx.userId.traverse(env.game.crosstableApi(user.id, _)),
ctx.isAuth.so(env.pref.api.followable(user.id)),
ctx.useMe(env.clas.api.clas.realName(user.id))
).flatMapN: (blocked, crosstable, followable, realName) =>
negotiate(
html = for
pov <- ctx.isnt(user).so(env.round.currentlyPlaying.exec(user.user.id))
ping = env.socket.isOnline.exec(user.id).so(env.socket.getLagRating(user.id))
snip <- Ok.snip:
views.user.mini(user, pov, blocked, followable, relation, ping, crosstable, realName)
yield snip.headerCacheSeconds(5),
json =
import lila.game.JsonView.given
Ok:
Json.obj(
"crosstable" -> crosstable,
"perfs" -> lila.user.JsonView.perfsJson(user.perfs, user.perfs.best8Perfs)
)
)
else Ok(views.user.bits.miniClosed(user.user, relation))
def online = Anon:
val max = 50
negotiateJson:
env.user.cached.getTop50Online.map: users =>
Ok:
Json.toJson:
users
.take(getInt("nb").fold(10)(_.min(max)))
.map: u =>
env.user.jsonView.full(u.user, u.perfs.some, withProfile = true)
def ratingHistory(username: UserStr) = Open:
EnabledUser(username): u =>
env.history
.ratingChartApi(u)
.dmap: // send an empty JSON array if no history JSON is available
_ | lila.core.data.SafeJsonStr("[]")
.dmap(jsonStr => Ok(jsonStr).as(JSON))
private def userGames(
u: UserModel,
filterName: String,
page: Int
)(using ctx: BodyContext[?]): Fu[Paginator[GameModel]] =
limit.userGames(
ctx.ip,
fuccess(Paginator.empty[GameModel]),
cost = page,
msg = s"on ${u.username}"
):
lila.mon.http.userGamesCost.increment(page.toLong)
for
pagFromDb <- env.gamePaginator(user = u, nbs = none, filter = GameFilter(filterName), page = page)
pag <- pagFromDb.mapFutureResults(env.round.proxyRepo.upgradeIfPresent)
_ <- env.tournament.cached.nameCache.preloadMany:
pag.currentPageResults.flatMap(_.tournamentId).map(_ -> ctx.lang)
_ <- lightUserApi.preloadMany(pag.currentPageResults.flatMap(_.userIds))
yield pag
def list = Open:
env.user.cached.top10.get {}.flatMap { leaderboards =>
negotiate(
html = for
nbAllTime <- env.user.cached.top10NbGame.get {}
tourneyWinners <- env.tournament.winners.all.map(_.top)
topOnline <- env.user.cached.getTop50Online
_ <- lightUserApi.preloadMany(tourneyWinners.map(_.userId))
page <- renderPage:
views.user.list(tourneyWinners, topOnline, leaderboards, nbAllTime)
yield Ok(page),
json =
given OWrites[LightPerf] = OWrites(env.user.jsonView.lightPerfIsOnline)
import lila.user.JsonView.leaderboardsWrites
JsonOk(leaderboards)
)
}
def apiList = Anon:
env.user.cached.top10.get {}.map { leaderboards =>
import env.user.jsonView.lightPerfIsOnlineWrites
import lila.user.JsonView.leaderboardsWrites
JsonOk(leaderboards)
}
// redirect /player/top/:nb/:perfKey to /user/top/:perfKey
// TODO move to a NotFound general handler?
// to avoid adding (yet another) route
def topBcRedirect(@annotation.unused nb: Int, perfKey: PerfKey) = Anon:
Redirect(routes.User.top(perfKey))
def top(perfKey: PerfKey, page: Int) = Open:
Reasonable(page, Max(20)):
env.user.cached
.topPerfPager(perfKey, page)
.flatMap: pager =>
negotiate(
Ok.page(views.user.list.top(perfKey, pager)),
topNbJson(pager.currentPageResults)
)
def topNbApi(nb: Int, perfKey: PerfKey) = Anon:
if nb == 1 && perfKey == PerfKey.standard then
env.user.cached.top10.get {}.map { leaderboards =>
import env.user.jsonView.lightPerfIsOnlineWrites
import lila.user.JsonView.leaderboardStandardTopOneWrites
JsonOk(leaderboards)
}
else env.user.cached.firstPageOf(perfKey).dmap(_.take(nb)).map(topNbJson)
private def topNbJson(users: Seq[LightPerf]) =
given OWrites[LightPerf] = OWrites(env.user.jsonView.lightPerfIsOnline)
Ok(Json.obj("users" -> users))
def mod(username: UserStr) = Secure(_.UserModView) { ctx ?=> _ ?=>
modZoneOrRedirect(username)
}
protected[controllers] def modZoneOrRedirect(username: UserStr)(using
ctx: Context,
me: Me
): Fu[Result] =
if HTTPRequest.isEventSource(ctx.req) then renderModZone(username)
else modC.redirect(username)
private def modZoneSegment(fu: Fu[Frag], name: String, user: UserModel): Source[Frag, ?] =
Source.futureSource:
fu.monSuccess(lila.mon.mod.zoneSegment(name))
.logFailure(lila.log("modZoneSegment").branch(s"$name ${user.id}"))
.map(Source.single)
protected[controllers] def loginsTableData(
user: UserModel,
userLogins: UserLogins,
max: Int
)(using Context): Fu[UserLogins.TableData[UserWithModlog]] =
val familyUserIds = user.id :: userLogins.otherUserIds
for
((notes, bans), othersWithEmail) <-
isGrantedOpt(_.ModNote)
.so(
env.user.noteApi
.byUsersForMod(familyUserIds)
.logTimeIfGt(s"${user.username} noteApi.forMod", 2.seconds)
)
.zip(env.playban.api.bansOf(familyUserIds).logTimeIfGt(s"${user.username} playban.bans", 2.seconds))
.zip(lila.security.UserLogins.withMeSortedWithEmails(env.user.repo, user, userLogins))
otherUsers <- env.user.perfsRepo.withPerfs(othersWithEmail.others.map(_.user))
otherUsers <- env.mod.logApi.withModlogs(otherUsers)
others = othersWithEmail.withUsers(otherUsers)
yield UserLogins.TableData(userLogins, others, notes, bans, max)
protected[controllers] def renderModZone(username: UserStr)(using ctx: Context, me: Me): Fu[Result] =
env.report.api.inquiries
.ofModId(me)
.zip(env.user.api.withPerfsAndEmails(username).orFail(s"No such user $username"))
.flatMap { case (inquiry, WithPerfsAndEmails(user, emails)) =>
import views.mod.user as ui
import lila.ui.ScalatagsExtensions.{ emptyFrag, given }
given lila.mod.IpRender.RenderIp = env.mod.ipRender.apply
val nbOthers = getInt("nbOthers") | 100
val timeline = isGranted(_.AccountInfo).so[Fu[Frag]]:
env.api.modTimeline
.load(user, withPlayBans = true)
.map: tl =>
if inquiry.exists(_.isPlay)
then views.mod.timeline.renderPlay(tl)
else views.mod.timeline.renderGeneral(tl)
.map(lila.mod.ui.mzSection("timeline")(_))
val plan =
isGranted(_.Admin).so(
env.plan.api
.recentChargesOf(user)
.map(views.user.mod.plan(user))
.dmap(_ | emptyFrag)
): Fu[Frag]
val student = isGranted(_.AccountInfo).so:
env.clas.api.student.findManaged(user).map2(views.user.mod.student).dmap(~_)
val reportLog = isGranted(_.SeeReport).so:
for
reports <- env.report.api.by(user, Max(30))
_ <- lightUserApi.preloadMany(reports.flatMap(_.userIds))
yield ui.reportLog(user, reports)
val prefs = isGranted(_.CheatHunter).so:
env.pref.api
.get(user)
.map: prefs =>
ui.prefs(user, prefs.hasKeyboardMove, prefs.hasVoice)
val appeal = isGranted(_.Appeals).so:
env.appeal.api.byId(user).mapz(views.appeal.ui.modSection(lila.mod.ui.mzSection("appeal")))
val rageSit = isGranted(_.CheatHunter).so:
env.playban.api
.rageSitOf(user.id)
.zip(env.playban.api.bans(user.id))
.map(ui.showRageSitAndPlaybans)
val actions =
for flags <- env.user.repo.closedFlags(user)
yield ui.actions(user, emails, flags, env.mod.presets.getPmPresets)
val userLoginsFu = env.security.userLogins(user, nbOthers)
val othersAndLogins = for
userLogins <- userLoginsFu
appeals <- env.appeal.api.byUserIds(user.id :: userLogins.otherUserIds)
data <- loginsTableData(user, userLogins, nbOthers)
render = () => views.user.mod.otherUsers(user, data, appeals)
yield (render, data)
val otherUsers = isGranted(_.ViewPrintNoIP).so[Fu[Frag]]:
othersAndLogins.map(_._1())
val identification = (isGranted(_.AccountInfo) || isGranted(_.ViewPrintNoIP)).so:
for
logins <- userLoginsFu
others <- othersAndLogins
yield views.user.mod.identification(logins, others._2.othersPartiallyLoaded)
val kaladin = isGranted(_.MarkEngine).so:
env.irwin.kaladinApi.get(user).map(_.flatMap(_.response).so(views.irwin.kaladin.report))
val irwin = isGranted(_.MarkEngine).so:
env.irwin.irwinApi.reports.withPovs(user).mapz(views.irwin.report)
val assess = isGranted(_.MarkEngine)
.so(env.mod.assessApi.getPlayerAggregateAssessmentWithGames(user.id))
.flatMapz: as =>
lightUserApi
.preloadMany(as.games.flatMap(_.userIds))
.inject(ui.assessments(user, as))
val oauthTokens = isGranted(_.AccountInfo).so:
env.oAuth.tokenApi.modRelevantTokens(user.id).map(views.user.mod.oauthTokens)
val teacher = isGranted(_.AccountInfo).so:
env.clas.api.clas.countOf(user).map(ui.teacher(user))
given EventSource.EventDataExtractor[Frag] = EventSource.EventDataExtractor[Frag](_.render)
Ok.chunked:
Source
.single(ui.menu)
.merge(modZoneSegment(actions, "actions", user))
.merge(modZoneSegment(reportLog, "reportLog", user))
.merge(modZoneSegment(timeline, "timeline", user))
.merge(modZoneSegment(plan, "plan", user))
.merge(modZoneSegment(student, "student", user))
.merge(modZoneSegment(teacher, "teacher", user))
.merge(modZoneSegment(prefs, "prefs", user))
.merge(modZoneSegment(appeal, "appeal", user))
.merge(modZoneSegment(rageSit, "rageSit", user))
.merge(modZoneSegment(otherUsers, "others", user))
.merge(modZoneSegment(identification, "identification", user))
.merge(modZoneSegment(kaladin, "kaladin", user))
.merge(modZoneSegment(irwin, "irwin", user))
.merge(modZoneSegment(assess, "assess", user))
.merge(modZoneSegment(oauthTokens, "oauthTokens", user))
.via(EventSource.flow)
.log("User.renderModZone")
.as(ContentTypes.EVENT_STREAM)
.noProxyBuffer
}
protected[controllers] def renderModZoneActions(username: UserStr)(using Context, Me) =
env.user.api
.withPerfsAndEmails(username)
.orFail(s"No such user $username")
.flatMap:
case WithPerfsAndEmails(user, emails) =>
for flags <- env.user.repo.closedFlags(user)
yield Ok.snip:
views.mod.user.actions(user, emails, flags, env.mod.presets.getPmPresets)
def writeNote(username: UserStr) = AuthBody { ctx ?=> me ?=>
bindForm(lila.user.UserForm.note)(
err => BadRequest(err.errors.toString).toFuccess,
data =>
doWriteNote(username, data): user =>
if getBool("inquiry") then
Ok.snipAsync:
env.user.noteApi.toUserForMod(user.id).map {
views.mod.inquiryUi.noteZone(user, _)
}
else
Ok.snipAsync:
env.user.noteApi.getForMyPermissions(user).map {
views.user.noteUi.zone(user, _)
}
)
}
def apiReadNote(username: UserStr) = Scoped() { _ ?=> me ?=>
Found(meOrFetch(username)):
env.user.noteApi
.getForMyPermissions(_)
.flatMap:
lila.user.JsonView.notes(_)(using lightUserApi)
.map(JsonOk)
}
def apiWriteNote(username: UserStr) = ScopedBody() { ctx ?=> me ?=>
bindForm(lila.user.UserForm.apiNote)(
doubleJsonFormError,
data => doWriteNote(username, data)(_ => jsonOkResult)
)
}
private def doWriteNote(
username: UserStr,
data: lila.user.UserForm.NoteData
)(f: UserModel => Fu[Result])(using Context, Me) =
Found(meOrFetch(username)): user =>
val isMod = data.mod && isGranted(_.ModNote)
for
_ <- env.user.noteApi.write(user.id, data.text, isMod, dox = isMod && data.dox)
result <- f(user)
yield result
def deleteNote(id: String) = Auth { ctx ?=> me ?=>
Found(env.user.noteApi.byId(id)): note =>
(note.isFrom(me) && !note.mod).so:
env.user.noteApi.delete(note._id).inject(Redirect(routes.User.show(note.to).url + "?note"))
}
def setDoxNote(id: String, dox: Boolean) = Secure(_.Admin) { ctx ?=> _ ?=>
Found(env.user.noteApi.byId(id)): note =>
note.mod.so:
env.user.noteApi.setDox(note._id, dox).inject(Redirect(routes.User.show(note.to).url + "?note"))
}
def opponents = Auth { ctx ?=> me ?=>
val user = meOrFetch(getUserStr("u")).map(_.filter(u => ctx.is(u) || isGrantedOpt(_.BoostHunter)))
Found(user): user =>
for
usersAndGames <- env.game.favoriteOpponents(user.id)
withPerfs <- env.user.api.listWithPerfs(usersAndGames._1F, includeClosed = false)
ops = withPerfs.toList.zip(usersAndGames._2F)
followables <- env.pref.api.followables(ops.map(_._1.id))
relateds <-
ops
.zip(followables)
.sequentially { case ((u, nb), followable) =>
relationApi
.fetchRelation(user.id, u.id)
.map:
lila.relation.Related(u, nb.some, followable, _)
}
page <- renderPage(views.relation.opponents(user, relateds))
yield Ok(page)
}
def perfStat(username: UserStr, perfKey: PerfKey) = Open:
Found(env.perfStat.api.data(username, perfKey, computeIfNeeded = HTTPRequest.isCrawler(req).no)): data =>
negotiate(
Ok.async:
env.history
.ratingChartApi(data.user.user)
.map:
views.user.perfStatPage(data, _)
,
JsonOk:
getBool("graph")
.optionFu:
env.history.ratingChartApi.singlePerf(data.user.user, data.stat.perfType.key)
.map: graph =>
env.perfStat.jsonView(data).add("graph", graph)
)
def autocomplete = OpenOrScoped(): ctx ?=>
NoTor:
if getBool("exists") then
val name = get("term").orZero
env.security.forms.signup.firstUsernameError(name) match
case Some(error) => BadRequest(jsonError(error))
case None => UserStr.read(name).so(env.user.repo.existsSec).map(JsonOk)
else
get("term")
.flatMap(UserSearch.read)
.fold(BadRequest("No search term provided").toFuccess): term =>
for
userIds <- (get("tour"), get("swiss"), get("team")) match
case (Some(tourId), _, _) =>
env.tournament.playerRepo.searchPlayers(TourId(tourId), term, 10)
case (_, Some(swissId), _) =>
env.swiss.api.searchPlayers(SwissId(swissId), term, 10)
case (_, _, Some(teamId)) => env.team.api.searchMembersAs(TeamId(teamId), term, 10)
case _ =>
ctx.me.ifTrue(getBool("friend")) match
case Some(follower) =>
env.relation.api.searchFollowedBy(follower, term, 10).flatMap { userIds =>
val remaining = 10 - userIds.length
if remaining > 0 then
env.user.cached.userIdsLike(term).map { extraUserIds =>
userIds ++ (extraUserIds.diff(userIds)).take(remaining)
}
else fuccess(userIds)
}
case None if getBool("teacher") =>
env.user.repo.userIdsLikeWithRole(term, lila.core.perm.Permission.Teacher.dbKey)
case None =>
for
found <- env.user.cached.userIdsLike(term)
closed <- isGrantedOpt(_.AccountInfo).so(env.user.repo.userIdsLikeClosed(term))
yield found ::: closed
result <-
if getBool("names") then
for users <- lightUserApi.asyncMany(userIds)
yield Json.toJson(users.flatMap(_.map(_.name)))
else if getBool("object") then
for users <- lightUserApi.asyncMany(userIds)
yield Json.obj:
"result" -> JsArray(users.collect { case Some(u) =>
lila.common.Json.lightUser
.write(u)
.add("online" -> env.socket.isOnline.exec(u.id))
})
else fuccess(Json.toJson(userIds))
yield JsonOk(result)
def ratingDistribution(perfKey: PerfKey, username: Option[UserStr] = None) = Open:
Found(perfKey.some.filter(lila.rating.PerfType.isLeaderboardable)): perfKey =>
env.perfStat.api
.weeklyRatingDistribution(perfKey)
.flatMap: data =>
WithMyPerfs:
username match
case Some(name) =>
EnabledUser(name): u =>
env.user.perfsRepo
.withPerfs(u)
.flatMap: u =>
Ok.page(views.user.perfStat.ratingDistribution(perfKey, data, u.some))
case _ => Ok.page(views.user.perfStat.ratingDistribution(perfKey, data, none))
def myself = Auth { _ ?=> me ?=>
Redirect(routes.User.show(me.username))
}
def redirect(path: String) = Open:
staticRedirect(path) |
UserStr.read(path).so(tryRedirect).getOrElse(notFound)
def tryRedirect(username: UserStr)(using Context): Fu[Option[Result]] =
meOrFetch(username).map:
_.filter(_.enabled.yes || isGrantedOpt(_.SeeReport)).map: user =>
Redirect(routes.User.show(user.username))