CC3 R3 M4+: avatar upload, PUT /api/auth/me, /uploads mount
Backend (auth/auth_v2.py + pgz_sport_api.py):
- POST /api/auth/me/avatar (multipart, jpeg/png/webp ≤5 MB) -> /uploads/avatars/{userid}_{ts}.ext
- DELETE /api/auth/me/avatar (uklanja datoteku + briše users.avatar_url)
- PUT /api/auth/me (UpdateMeReq: ime/prezime/full_name/telefon/phone/preferred_language/oib)
- GET /api/auth/me proširen s avatar_url, two_factor_enabled, gdpr_consent_at, google_picture
- StaticFiles mount /uploads -> /opt/pgz-sport/uploads
- DB: ALTER TABLE pgz_sport.users ADD COLUMN avatar_url TEXT
- Audit: profile.update, profile.avatar_upload, profile.avatar_delete
Backups: _backups/auth_v2.py.cc3_pre_avatar.*, pgz_sport_api.py.cc3_pre_avatar.*
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+85
-1
@@ -391,7 +391,8 @@ def logout(request: Request, user = Depends(require_user)):
|
|||||||
def me(user = Depends(require_user)):
|
def me(user = Depends(require_user)):
|
||||||
enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
|
enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
|
||||||
klub_id, savez_id, must_change_pwd, aktivan, status,
|
klub_id, savez_id, must_change_pwd, aktivan, status,
|
||||||
last_login, oib, telefon, phone, preferred_language, created_at
|
last_login, oib, telefon, phone, preferred_language, created_at,
|
||||||
|
avatar_url, gdpr_consent_at, google_picture
|
||||||
FROM pgz_sport.users WHERE id=%s""", (user["id"],))
|
FROM pgz_sport.users WHERE id=%s""", (user["id"],))
|
||||||
if not enriched:
|
if not enriched:
|
||||||
raise HTTPException(404, "User not found")
|
raise HTTPException(404, "User not found")
|
||||||
@@ -399,11 +400,94 @@ def me(user = Depends(require_user)):
|
|||||||
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id
|
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id
|
||||||
FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
|
FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
|
||||||
WHERE ur.user_id=%s AND ur.active=true""", (user["id"],))
|
WHERE ur.user_id=%s AND ur.active=true""", (user["id"],))
|
||||||
|
try:
|
||||||
|
twofa = db_one("SELECT secret IS NOT NULL AS enabled FROM pgz_sport.user_2fa WHERE user_id=%s",
|
||||||
|
(user["id"],)) or {"enabled": False}
|
||||||
|
except Exception:
|
||||||
|
twofa = {"enabled": False}
|
||||||
return {**enriched,
|
return {**enriched,
|
||||||
"tier": _tier_for(enriched.get("user_type") or ""),
|
"tier": _tier_for(enriched.get("user_type") or ""),
|
||||||
"must_change_pwd": bool(enriched.get("must_change_pwd")),
|
"must_change_pwd": bool(enriched.get("must_change_pwd")),
|
||||||
|
"two_factor_enabled": bool(twofa.get("enabled")),
|
||||||
**tenant, "roles": roles}
|
**tenant, "roles": roles}
|
||||||
|
|
||||||
|
class UpdateMeReq(BaseModel):
|
||||||
|
ime: Optional[str] = None
|
||||||
|
prezime: Optional[str] = None
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
telefon: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
preferred_language: Optional[str] = None
|
||||||
|
oib: Optional[str] = None
|
||||||
|
|
||||||
|
@router.put("/me")
|
||||||
|
def update_me(req: UpdateMeReq, request: Request, user = Depends(require_user)):
|
||||||
|
fields = []
|
||||||
|
vals: List[Any] = []
|
||||||
|
for k in ("ime","prezime","full_name","telefon","phone","preferred_language","oib"):
|
||||||
|
v = getattr(req, k)
|
||||||
|
if v is not None:
|
||||||
|
fields.append(f"{k}=%s")
|
||||||
|
vals.append(v.strip() if isinstance(v, str) else v)
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(400, "Nema polja za ažuriranje")
|
||||||
|
vals.append(user["id"])
|
||||||
|
db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)}, updated_at=now() WHERE id=%s", tuple(vals))
|
||||||
|
ip, ua = _client(request)
|
||||||
|
audit(user["id"], "profile.update", meta={"fields": [f.split("=")[0] for f in fields]}, ip=ip, ua=ua)
|
||||||
|
return me(user)
|
||||||
|
|
||||||
|
# ─────────────────────────── AVATAR UPLOAD ───────────────────────────
|
||||||
|
import shutil, pathlib
|
||||||
|
from fastapi import UploadFile, File
|
||||||
|
|
||||||
|
UPLOAD_ROOT = pathlib.Path("/opt/pgz-sport/uploads")
|
||||||
|
AVATAR_DIR = UPLOAD_ROOT / "avatars"
|
||||||
|
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ALLOWED_AVATAR_MIME = {"image/jpeg","image/jpg","image/png","image/webp"}
|
||||||
|
ALLOWED_AVATAR_EXT = {".jpg",".jpeg",".png",".webp"}
|
||||||
|
MAX_AVATAR_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||||
|
|
||||||
|
@router.post("/me/avatar")
|
||||||
|
async def upload_my_avatar(request: Request, file: UploadFile = File(...), user = Depends(require_user)):
|
||||||
|
ct = (file.content_type or "").lower()
|
||||||
|
if ct not in ALLOWED_AVATAR_MIME:
|
||||||
|
raise HTTPException(400, f"Nedozvoljen tip slike: {ct} — jpeg/png/webp")
|
||||||
|
ext = pathlib.Path(file.filename or "").suffix.lower()
|
||||||
|
if ext not in ALLOWED_AVATAR_EXT:
|
||||||
|
ext = {"image/jpeg":".jpg","image/jpg":".jpg","image/png":".png","image/webp":".webp"}.get(ct, ".jpg")
|
||||||
|
data = await file.read()
|
||||||
|
if len(data) > MAX_AVATAR_BYTES:
|
||||||
|
raise HTTPException(413, f"Slika prevelika ({len(data)} B > {MAX_AVATAR_BYTES})")
|
||||||
|
if len(data) < 32:
|
||||||
|
raise HTTPException(400, "Slika prazna ili neispravna")
|
||||||
|
safe_name = f"{int(user['id'])}_{int(time.time())}{ext}"
|
||||||
|
target = AVATAR_DIR / safe_name
|
||||||
|
with open(target, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
try: os.chmod(target, 0o644)
|
||||||
|
except Exception: pass
|
||||||
|
avatar_url = f"/uploads/avatars/{safe_name}"
|
||||||
|
db_exec("UPDATE pgz_sport.users SET avatar_url=%s, updated_at=now() WHERE id=%s",
|
||||||
|
(avatar_url, user["id"]))
|
||||||
|
ip, ua = _client(request)
|
||||||
|
audit(user["id"], "profile.avatar_upload",
|
||||||
|
meta={"file": safe_name, "size": len(data), "mime": ct}, ip=ip, ua=ua)
|
||||||
|
return {"status":"ok", "avatar_url": avatar_url, "size": len(data), "mime": ct}
|
||||||
|
|
||||||
|
@router.delete("/me/avatar")
|
||||||
|
def delete_my_avatar(request: Request, user = Depends(require_user)):
|
||||||
|
cur = db_one("SELECT avatar_url FROM pgz_sport.users WHERE id=%s", (user["id"],))
|
||||||
|
if cur and cur.get("avatar_url"):
|
||||||
|
p = AVATAR_DIR / pathlib.Path(cur["avatar_url"]).name
|
||||||
|
try:
|
||||||
|
if p.exists() and p.is_relative_to(AVATAR_DIR): p.unlink()
|
||||||
|
except Exception: pass
|
||||||
|
db_exec("UPDATE pgz_sport.users SET avatar_url=NULL, updated_at=now() WHERE id=%s", (user["id"],))
|
||||||
|
ip, ua = _client(request)
|
||||||
|
audit(user["id"], "profile.avatar_delete", ip=ip, ua=ua)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
@router.post("/password/change")
|
@router.post("/password/change")
|
||||||
def change_password(req: ChangePwdReq, request: Request, user = Depends(require_user)):
|
def change_password(req: ChangePwdReq, request: Request, user = Depends(require_user)):
|
||||||
if len(req.new_password) < 8:
|
if len(req.new_password) < 8:
|
||||||
|
|||||||
@@ -1683,8 +1683,34 @@ def serve_platform():
|
|||||||
if p.exists(): return FileResponse(p)
|
if p.exists(): return FileResponse(p)
|
||||||
return {"error": "platform.html not found"}
|
return {"error": "platform.html not found"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/app")
|
||||||
|
@app.get("/app/")
|
||||||
|
def serve_app():
|
||||||
|
p = HTML_DIR / "app.html"
|
||||||
|
return FileResponse(p) if p.exists() else {"error":"app.html not found"}
|
||||||
|
|
||||||
|
@app.get("/audit")
|
||||||
|
@app.get("/audit/")
|
||||||
|
def serve_audit():
|
||||||
|
p = HTML_DIR / "audit.html"
|
||||||
|
return FileResponse(p) if p.exists() else {"error":"audit.html not found"}
|
||||||
|
|
||||||
|
@app.get("/kpi")
|
||||||
|
@app.get("/kpi/")
|
||||||
|
def serve_kpi():
|
||||||
|
p = HTML_DIR / "kpi.html"
|
||||||
|
return FileResponse(p) if p.exists() else {"error":"kpi.html not found"}
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(HTML_DIR)), name="static")
|
||||||
|
|
||||||
|
# User-uploaded files (avatars, etc.) — served at /uploads/*
|
||||||
|
import pathlib as _pl
|
||||||
|
_UPLOAD_DIR = _pl.Path("/opt/pgz-sport/uploads")
|
||||||
|
_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
(_UPLOAD_DIR / "avatars").mkdir(parents=True, exist_ok=True)
|
||||||
|
app.mount("/uploads", StaticFiles(directory=str(_UPLOAD_DIR)), name="uploads")
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root(request: Request):
|
def root(request: Request):
|
||||||
host = request.headers.get("host", "")
|
host = request.headers.get("host", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user