Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion backend/src/api/admin_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import urllib.parse
import psycopg

from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response
from fastapi import APIRouter, FastAPI, HTTPException, Path, Query, Request, Response
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel, Field, StrictBool
Expand All @@ -28,6 +28,7 @@
staff_cookie,
)
from src.auth import admin, audit, players, roles, users, view_as
from src.skins import staff_alts
from src.auth import cases as person_cases
from src.precedent.db import PrecedentDBError
from src.coreprotect.reader import CoreProtectConfig
Expand Down Expand Up @@ -381,6 +382,29 @@ def post_view_as(player_uuid: str, request: Request):
return response


@admin_router.post("/players/{player_uuid}/staff-alt/unlink")
def post_staff_alt_unlink(request: Request, response: Response,
player_uuid: str = Path(pattern=r"^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$")):
"""Remove this account's staff alt link, if you outrank its owner; audited.

Refusals from another site and by rank are audited too; a missing capability (403)
and writes during a view (ViewAsGuard) are refused before this, as on other routes.
"""
action, target = "account.staff_alt.remove", {"player_uuid": player_uuid[:64]}
actor = _capable(request, "manage_staff_alts")
_no_store(response)

def remove(_token):
try:
staff_alts.unlink(player_uuid, actor, any_owner=True)
except staff_alts.StaffAltRefused as exc:
audit.record_refusal(actor=actor, action=action, outcome="denied", detail={"error": exc.code, **target})
raise admin.AdminError(exc.status, exc.code, "denied") from None
return staff_alts.for_player(player_uuid)

return _write(request, action, target, remove)


# Request validation runs before the handlers above, so malformed writes are
# audited here: the action, the target and which fields failed, never values.
_WRITE_ROUTES = (
Expand Down
78 changes: 74 additions & 4 deletions backend/src/api/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,20 @@
from urllib.parse import urlencode, urlsplit

from fastapi import APIRouter, HTTPException, Request, Response
from fastapi import Path as PathParam
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field

from src.api.prod_guard import is_production
from src.auth import account_overview, guild_check, preview_sign_in, users, view_as
from src.auth import account_overview, guild_check, preview_sign_in, roles, users, view_as
from src.auth.config import AuthConfig
from src.auth import microsoft_link
from src.auth.discord import DiscordClient, DiscordError
from src.auth.microsoft import MicrosoftClient, MicrosoftConfig, MicrosoftError
from src.patreon import linking as patreon_linking
from src.patreon import service as patreon_service
from src.patreon.config import Config as PatreonConfig
from src.skins import staff_alts
from src.skins.codes import CodeError, start_linked_profile_session
from src.skins.discord_link import (
LinkError,
Expand Down Expand Up @@ -480,6 +482,8 @@ def get_account(request: Request, response: Response):
"minecraft": minecraft,
"microsoft_link": MicrosoftConfig.from_env().usable,
"patreon": _patreon(user),
# Staff only (or anyone who still has alts): src/skins/staff_alts.py.
"staff_alts": staff_alts.account_view(user),
"viewing_as": (
{"staff_name": user["acting_name"], "expires_at": user["expires_at"]}
if user["acting_user_id"] is not None else None
Expand Down Expand Up @@ -510,11 +514,17 @@ def get_account_overview(request: Request, response: Response):


@auth_router.get("/account/minecraft/head")
def get_minecraft_head(request: Request):
"""The linked player's skin face. Only your own, so the site is no open Mojang proxy."""
def get_minecraft_head(request: Request, u: str | None = None):
"""The linked player's skin face, or with ?u= one of your staff alts'. Only your own
accounts, so the site is no open Mojang proxy.

Any other ?u= gets your own face, not an error: the page has always sent ?u=<your own
uuid> as a cache key, so a u that is not one of your alts means your linked account.
"""
config = _config()
user = current_user(request, config)
png = account_overview.head(_linked_player(user))
alt = (u or "").strip()
png = account_overview.head(alt if alt and staff_alts.owns(user, alt) else _linked_player(user))
if png is None:
raise HTTPException(404, detail="head_unavailable")
return Response(content=png, media_type="image/png", headers={"Cache-Control": "private, max-age=3600"})
Expand Down Expand Up @@ -587,6 +597,7 @@ def minecraft_link(request: Request, body: CodeBody, response: Response):
"This Discord account is already linked to a different Minecraft player": "discord_taken",
"This Minecraft account is already linked to a different Discord account": "minecraft_taken",
"This link attempt is no longer valid": "expired",
staff_alts.STAFF_ALT_TAKEN: "minecraft_taken",
}


Expand Down Expand Up @@ -692,6 +703,65 @@ def minecraft_unlink(request: Request, response: Response):
return {"minecraft": None}


# --------------------
# Staff alts (src/skins/staff_alts.py)
# --------------------

_UUID_PATH = r"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$"


def _staff_alt_user(request: Request, config: AuthConfig) -> dict:
"""A same-origin request from signed-in staff, within the link attempt limit.

No fresh guild check, unlike linking your own account: an alt counts only through
the owner's own link, whose guild-leave grace ends it.
"""
require_same_origin(request, config)
user = current_user(request, config)
if not roles.is_staff(user["role"]):
raise HTTPException(403, detail="forbidden")
_check_link_rate(user["user_id"])
return user


@auth_router.post("/account/staff-alts/preview")
def staff_alt_preview(request: Request, body: CodeBody, response: Response):
"""Name the account a /linkdiscord code from an alt would add, without using the code."""
config = _config()
user = _staff_alt_user(request, config)
_no_store(response)
try:
return staff_alts.preview(normalise_code(body.code), user["discord_user_id"])
except LinkError as e:
raise HTTPException(400, detail=str(e)) from e


@auth_router.post("/account/staff-alts/link")
def staff_alt_link(request: Request, body: CodeBody, response: Response):
config = _config()
user = _staff_alt_user(request, config)
_no_store(response)
try:
staff_alts.link(normalise_code(body.code), user)
except LinkError as e:
raise HTTPException(400, detail=str(e)) from e
return {"staff_alts": staff_alts.account_view(user)}


@auth_router.post("/account/staff-alts/{player_uuid}/unlink")
def staff_alt_unlink(request: Request, response: Response, player_uuid: str = PathParam(pattern=_UUID_PATH)):
"""Remove one of your staff alts; also open to owners no longer on staff."""
config = _config()
require_same_origin(request, config)
user = current_user(request, config)
_no_store(response)
try:
staff_alts.unlink(player_uuid, user)
except staff_alts.StaffAltRefused as e:
raise HTTPException(e.status, detail=e.code) from e
return {"staff_alts": staff_alts.account_view(user)}


@auth_router.post("/account/patreon/unlink")
def patreon_unlink(request: Request, response: Response):
config = _config()
Expand Down
9 changes: 8 additions & 1 deletion backend/src/auth/players.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from src import database
from src.characters.roster import list_roster
from src.characters.wardrobe import active_face_png, active_figure_png, WardrobeError
from src.skins import staff_alts

from . import audit, discord_names, roles, users

Expand Down Expand Up @@ -388,14 +389,18 @@ def profile(config: CoreProtectConfig, text: str) -> dict:
except Unavailable as exc:
error = exc
links, roster = _site_rows(key)
alt_links = staff_alts.for_player(key)
# A link whose handle is unknown: ask Discord now (with a bot token), so the profile shows it.
if links and not (links[0]["account_username"] or links[0]["discord_username"]):
if discord_names.refresh_one(links[0]["discord_user_id"]):
links, roster = _site_rows(key)
if not accounts and not links and not roster and error is None:
if not accounts and not links and not roster and not alt_links["staff_alt_of"] and error is None:
raise PlayerError(404, "player_not_found")
link = links[0] if links else None
current = accounts[-1]["user"] if accounts else (link["minecraft_name"] if link else None)
if current is None and alt_links["staff_alt_of"]:
# An alt never seen in game: the name it had when linked.
current = staff_alts.alt_name(key)
sheets = {
(realm, c["id"]): c
for uuid, realm in {(c["player_uuid"], c["realm_id"]) for c in roster}
Expand All @@ -410,6 +415,8 @@ def profile(config: CoreProtectConfig, text: str) -> dict:
"online": sessions.is_fresh(event, now, config.ping_seconds),
"discord": _discord(link),
"account": _account(link),
# Whose staff alt this account is, and its own alts (src/skins/staff_alts.py).
**alt_links,
"characters": [
{**sheets.get((c["realm_id"], c["character_id"]), {}),
**{k: c[k] for k in ("realm_id", "character_id", "name", "race", "class", "updated_at")},
Expand Down
2 changes: 2 additions & 0 deletions backend/src/auth/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"change_luckperms": "admin",
# Moderation case log; the Discord bot reaches it with its own key.
"use_precedent": "mod",
# Remove anyone's staff alt link (src/skins/staff_alts.py); audited.
"manage_staff_alts": "admin",
# Read-only lookup of skin and drink codes.
"inspect_codes": "mod",
# Reports players send from the /diagnostic page.
Expand Down
Loading
Loading