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
16 changes: 16 additions & 0 deletions backend/src/api/drinks_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
get_drink_catalog,
get_drink_for_plugin,
get_drink_submission_for_owner,
owner_drink_texture,
list_deletable_drinks,
list_drinks_pending_apply,
list_pending_drinks,
Expand Down Expand Up @@ -194,6 +195,21 @@ def get_submission(
return row


@drinks_router.get("/submissions/{submission_id}/texture")
def get_owner_texture(
submission_id: str,
authorization: str | None = Header(default=None),
):
"""The owner's custom texture for a drink, for its Profile picture."""
session = _owner_session_from_auth(authorization)
if not is_safe_segment(submission_id):
raise HTTPException(status_code=404, detail="Submission not found")
path = owner_drink_texture(submission_id, session["player_uuid"])
if path is None:
raise HTTPException(status_code=404, detail="No texture")
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "private, max-age=300"})


@drinks_router.get("/staff/pending")
def staff_pending(
x_staff_key: str | None = Header(default=None, alias=HEADER_STAFF_KEY),
Expand Down
25 changes: 25 additions & 0 deletions backend/src/api/skins_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,31 @@ def get_thumbnail(
)


# The owner's uploads only; staff sheets and server files stay staff-side.
_OWNER_FILE_SUFFIXES = (".png", ".json")
_OWNER_HIDDEN_FILES = frozenset({"meta.json", "review_sheet.png", "name_preview.png"})


@skins_router.get("/submissions/{submission_id}/files/{filename}")
def owner_file(
submission_id: str,
filename: str,
authorization: str | None = Header(default=None),
):
"""A texture or model the owner uploaded, for the 3D viewer on their skin's page."""
session = _session_from_auth(authorization)
name = filename.lower()
if name in _OWNER_HIDDEN_FILES or not name.endswith(_OWNER_FILE_SUFFIXES):
raise HTTPException(status_code=404, detail="File not found")
if get_submission_for_owner(submission_id, session["player_uuid"]) is None:
raise HTTPException(status_code=404, detail="Submission not found")
path = _resolve_submission_file_safely(submission_id, filename)
if path is None:
raise HTTPException(status_code=404, detail="File not found")
media = "image/png" if name.endswith(".png") else "application/json"
return FileResponse(path, media_type=media, headers={"Cache-Control": "private, max-age=300"})


@skins_router.post("/submissions/{submission_id}/approve")
def post_approve(
submission_id: str,
Expand Down
72 changes: 72 additions & 0 deletions backend/src/api/test_skin_owner_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""GET /skins/submissions/{id}/files/{name} serves a skin's uploads to its owner only."""

from __future__ import annotations

import importlib
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch

_BACKEND = Path(__file__).resolve().parents[2]
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))

from fastapi import HTTPException

OWNER = "owner-uuid"


class SkinOwnerFileTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.skins_dir = Path(self.tmp.name).resolve() / "skins"
sub = self.skins_dir / "abc_staff"
sub.mkdir(parents=True)
for name in ("abc_staff.png", "abc_staff.json", "meta.json", "review_sheet.png", "notes.txt"):
(sub / name).write_bytes(b"x")
self.routes = importlib.import_module("src.api.skins_routes")

def owner_row(submission_id: str, player_uuid: str):
return {"id": submission_id} if submission_id == "abc_staff" and player_uuid == OWNER else None

def resolver(submission_id: str, filename: str):
path = self.skins_dir / submission_id / filename
return path if path.is_file() else None

for p in (
patch.object(self.routes, "_session_from_auth", lambda auth: {"player_uuid": auth}),
patch.object(self.routes, "get_submission_for_owner", owner_row),
patch.object(self.routes, "skins_db", SimpleNamespace(SKINS_DIR=self.skins_dir)),
patch.dict(self.routes.owner_file.__globals__, {"resolve_submission_file": resolver}),
):
p.start()
self.addCleanup(p.stop)

def status(self, submission_id: str, filename: str, who: str = OWNER) -> int:
try:
return self.routes.owner_file(submission_id, filename, who).status_code
except HTTPException as e:
return e.status_code

def test_owner_gets_textures_and_models(self) -> None:
self.assertEqual(200, self.status("abc_staff", "abc_staff.png"))
self.assertEqual(200, self.status("abc_staff", "abc_staff.json"))

def test_other_players_get_nothing(self) -> None:
self.assertEqual(404, self.status("abc_staff", "abc_staff.png", who="someone-else"))

def test_staff_and_server_files_stay_hidden(self) -> None:
for name in ("meta.json", "review_sheet.png", "name_preview.png", "notes.txt"):
self.assertEqual(404, self.status("abc_staff", name), name)

def test_traversal_is_refused(self) -> None:
for sid, name in (("..", "abc_staff.png"), ("abc_staff", "..\\..\\x.png"), ("..\\..", "x.png")):
self.assertEqual(404, self.status(sid, name), (sid, name))


if __name__ == "__main__":
unittest.main()
1 change: 1 addition & 0 deletions backend/src/characters/lore_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -1988,6 +1988,7 @@ def list_player_custom_items(
"character_id": cid,
"character_name": name_by_id.get(cid) or cid,
"kit_key": kit_key,
"kit_id": str(row["kit_id"] or "") if "kit_id" in keys else "",
"display_name": str(row["display_name"] or kit_key),
"state": state,
"submission_id": row["submission_id"],
Expand Down
22 changes: 22 additions & 0 deletions backend/src/skins/drinks.py
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,28 @@ def get_drink_submission_for_owner(
return _public_row(row)


def owner_drink_texture(submission_id: str, player_uuid: str) -> Path | None:
"""The owner's custom drink texture: the upload, else the shared texture it reuses."""
row = _get_row(submission_id)
if row is None or str(row["player_uuid"]).lower() != str(player_uuid).lower():
return None
root = db.DRINKS_DIR.resolve()
candidates = [_submissions_root() / submission_id / "texture.png"]
texture_id = str(row["texture_id"] or "").strip()
if texture_id:
with connect() as conn:
tex = conn.execute(
"SELECT png_path FROM drink_textures WHERE id = ?", (texture_id,)
).fetchone()
if tex is not None and tex["png_path"]:
candidates.append(db.DRINKS_DIR / str(tex["png_path"]))
for path in candidates:
resolved = path.resolve()
if resolved.is_file() and resolved.is_relative_to(root):
return resolved
return None


def list_drink_submissions_for_player(
player_uuid: str,
realm_id: str | None = None,
Expand Down
49 changes: 49 additions & 0 deletions backend/src/skins/test_drinks.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,55 @@ def test_texture_review_sheet(self) -> None:
assert data is not None
self.assertTrue(data.startswith(b"\x89PNG"))

def test_owner_drink_texture(self) -> None:
from skins.db import connect
from skins.drinks import create_drink_submission, owner_drink_texture

self._link_player()
self._seed_catalog()
upsert_rpc_player_meta(
{"player_uuid": "player-1", "allow_drink_texture": True}
)
session = self._issue_and_redeem()
out = create_drink_submission(
session,
{
"name": "Tex Brew",
"ingredients": [{"id": "grape", "amount": 1}],
"effects": [],
},
png_bytes=TINY_PNG,
)
path = owner_drink_texture(out["id"], "PLAYER-1")
self.assertIsNotNone(path)
assert path is not None
self.assertEqual(TINY_PNG, path.read_bytes())
self.assertIsNone(owner_drink_texture(out["id"], "someone-else"))

# A drink reusing a texture has no upload of its own; the shared file stands in.
path.unlink()
with connect() as conn:
texture_id = conn.execute(
"SELECT texture_id FROM drink_submissions WHERE id = ?", (out["id"],)
).fetchone()[0]
self.assertTrue(texture_id)
shared = owner_drink_texture(out["id"], "player-1")
self.assertIsNotNone(shared)
assert shared is not None
self.assertEqual(f"{texture_id}.png", shared.name)

def test_owner_drink_texture_colour_only(self) -> None:
from skins.drinks import create_drink_submission, owner_drink_texture

self._link_player()
self._seed_catalog()
session = self._issue_and_redeem()
out = create_drink_submission(
session,
{"name": "Plain Ale", "ingredients": [{"id": "grape", "amount": 1}], "color": "#AA3344"},
)
self.assertIsNone(owner_drink_texture(out["id"], "player-1"))

def test_review_sheet_missing_appearance(self) -> None:
from skins.db import connect
from skins.drink_review_sheet import (
Expand Down
72 changes: 0 additions & 72 deletions frontend/app/components/drinks/StatusCard.tsx

This file was deleted.

Loading
Loading