diff --git a/app/main.py b/app/main.py index 45f6385..6dfd3f7 100644 --- a/app/main.py +++ b/app/main.py @@ -15,22 +15,26 @@ Endpoints: """ import asyncio import json +import hashlib +import hmac import os import secrets import shutil import subprocess import threading import time +import urllib.parse import uuid from pathlib import Path from typing import Optional from fastapi import ( FastAPI, UploadFile, File, Form, HTTPException, Depends, - BackgroundTasks, Request, status + BackgroundTasks, Request, Response as FastAPIResponse, status, Cookie ) from fastapi.responses import ( - FileResponse, HTMLResponse, StreamingResponse, JSONResponse, Response + FileResponse, HTMLResponse, StreamingResponse, JSONResponse, Response, + RedirectResponse ) from fastapi.staticfiles import StaticFiles from fastapi.security import HTTPBasic, HTTPBasicCredentials @@ -223,6 +227,15 @@ def dedup_remove(filename: str, tv_station: str): AUTH_USER = os.environ.get("AUTH_USER", "sebastjan") AUTH_PASS = os.environ.get("AUTH_PASS", "change-me-in-coolify-env") +# Google Sign-In (https://developers.google.com/identity/gsi/web) +GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "").strip() +ALLOWED_EMAILS = [e.strip().lower() for e in os.environ.get("ALLOWED_EMAILS", "").split(",") if e.strip()] +SESSION_SECRET = os.environ.get("SESSION_SECRET", "").strip() or hashlib.sha256( + f"{AUTH_USER}|{AUTH_PASS}|reels-app-default-secret".encode() +).hexdigest() +SESSION_COOKIE_NAME = "reels_session" +SESSION_TTL_SECONDS = 30 * 24 * 3600 # 30 dni + MAX_UPLOAD_MB = int(os.environ.get("MAX_UPLOAD_MB", "2000")) # Nextcloud upload (folxspeed/REELS/) @@ -233,21 +246,114 @@ NEXTCLOUD_FOLDER = os.environ.get("NEXTCLOUD_FOLDER", "folxspeed/REELS") # ──────────────────────────────────────────────────────────────── -# Auth +# Auth — Google Sign-In + basic auth fallback (za API/cron) # ──────────────────────────────────────────────────────────────── -security = HTTPBasic() +security = HTTPBasic(auto_error=False) -def check_auth(creds: HTTPBasicCredentials = Depends(security)): - correct_user = secrets.compare_digest(creds.username, AUTH_USER) - correct_pass = secrets.compare_digest(creds.password, AUTH_PASS) - if not (correct_user and correct_pass): +def _email_allowed(email: str) -> bool: + """Email mora biti v ALLOWED_EMAILS whitelistu.""" + if not email: + return False + return email.lower().strip() in ALLOWED_EMAILS + + +def _make_session_token(email: str) -> str: + """Sign session: base64url(email|expiry|hmac_sha256).""" + import base64 + expiry = int(time.time()) + SESSION_TTL_SECONDS + payload = f"{email}|{expiry}" + sig = hmac.new(SESSION_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() + full = f"{payload}|{sig}" + return base64.urlsafe_b64encode(full.encode()).decode().rstrip("=") + + +def _verify_session_token(token: str) -> Optional[str]: + """Returns email if valid+not expired, else None.""" + if not token: + return None + try: + import base64 + pad = len(token) % 4 + if pad: + token += "=" * (4 - pad) + decoded = base64.urlsafe_b64decode(token.encode()).decode() + parts = decoded.split("|") + if len(parts) != 3: + return None + email, expiry, sig = parts + expected = hmac.new(SESSION_SECRET.encode(), f"{email}|{expiry}".encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, expected): + return None + if int(expiry) < int(time.time()): + return None + return email + except Exception: + return None + + +def _verify_google_id_token(id_token: str) -> Optional[dict]: + """Verify Google ID token via tokeninfo endpoint. Returns claims dict or None.""" + if not id_token or not GOOGLE_CLIENT_ID: + return None + try: + import urllib.request, urllib.error, json as _json + url = f"https://oauth2.googleapis.com/tokeninfo?id_token={urllib.parse.quote(id_token)}" + with urllib.request.urlopen(url, timeout=10) as resp: + data = _json.loads(resp.read().decode()) + if data.get("aud") != GOOGLE_CLIENT_ID: + print(f"⚠️ Google token aud mismatch: {data.get('aud')!r}", flush=True) + return None + if data.get("iss") not in ("accounts.google.com", "https://accounts.google.com"): + return None + if int(data.get("exp", 0)) < int(time.time()): + return None + if data.get("email_verified") not in (True, "true"): + return None + return data + except Exception as e: + print(f"⚠️ Google token verify error: {e}", flush=True) + return None + + +def check_auth( + request: Request, + creds: Optional[HTTPBasicCredentials] = Depends(security), +): + """Auth dependency. Accepts (in order): + 1) Valid session cookie (set by Google Sign-In flow) + 2) HTTP Basic with AUTH_USER/AUTH_PASS (legacy + cron + scripts) + + On failure: redirect browser GETs to /login, return 401 to API clients. + """ + # 1) Session cookie + token = request.cookies.get(SESSION_COOKIE_NAME) + if token: + email = _verify_session_token(token) + if email and _email_allowed(email): + return email + + # 2) Basic auth fallback + if creds and creds.username and creds.password: + correct_user = secrets.compare_digest(creds.username, AUTH_USER) + correct_pass = secrets.compare_digest(creds.password, AUTH_PASS) + if correct_user and correct_pass: + return creds.username + + # 3) Failure — redirect browsers, 401 API + accept = request.headers.get("accept", "") + is_browser_get = request.method == "GET" and "text/html" in accept + if is_browser_get and GOOGLE_CLIENT_ID: + # Browser → /login (raise s 303 ne dela v Depends, zato uporabljamo HTTPException) raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Napačno geslo", - headers={"WWW-Authenticate": "Basic"}, + status_code=status.HTTP_303_SEE_OTHER, + headers={"Location": "/login"}, ) - return creds.username + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Basic"}, + ) # ──────────────────────────────────────────────────────────────── @@ -1285,6 +1391,191 @@ async def _resume_job_async(job_id): print(f" ❌ Resume failed for {job_id}: {e}") +# ──────────────────────────────────────────────────────────────── +# Google Sign-In endpointi +# ──────────────────────────────────────────────────────────────── +@app.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, error: Optional[str] = None): + """Login stran z Google Sign-In gumbom.""" + # Če že prijavljen, redirect na / + token = request.cookies.get(SESSION_COOKIE_NAME) + if token and _verify_session_token(token): + email = _verify_session_token(token) + if email and _email_allowed(email): + return RedirectResponse(url="/", status_code=303) + + error_html = "" + if error == "not_allowed": + error_html = '
❌ Tvoj račun nima dostopa. Kontaktiraj Sebastjana, da te doda.
' + elif error == "invalid_token": + error_html = '
❌ Neveljaven Google token. Probaj še enkrat.
' + elif error == "no_client_id": + error_html = '
⚠️ Google Sign-In ni konfiguriran. Nastavi GOOGLE_CLIENT_ID env var.
' + + if not GOOGLE_CLIENT_ID: + gsi_html = '
⚠️ Google Sign-In ni konfiguriran (GOOGLE_CLIENT_ID manjka).
' + else: + gsi_html = f""" +
+
+ """ + + html = f""" + + + + +Prijava — reels.biba.live + + + + +
+

🎬 reels.biba.live

+
Prijava z Google računom
+ {error_html} +
+ {gsi_html} +
+ +
+ + +""" + return html + + +class GoogleCallbackRequest(BaseModel): + credential: str # Google ID token (JWT) + + +@app.post("/auth/google/callback") +async def google_callback(payload: GoogleCallbackRequest): + """Verify Google ID token in set session cookie.""" + if not GOOGLE_CLIENT_ID: + raise HTTPException(503, "no_client_id") + + claims = _verify_google_id_token(payload.credential) + if not claims: + raise HTTPException(401, "invalid_token") + + email = claims.get("email", "").lower() + if not _email_allowed(email): + raise HTTPException(403, "not_allowed") + + # Set session cookie + token = _make_session_token(email) + resp = JSONResponse({"ok": True, "email": email, "name": claims.get("name", "")}) + resp.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + max_age=SESSION_TTL_SECONDS, + httponly=True, + secure=True, + samesite="lax", + path="/", + ) + print(f"✅ Login: {email} ({claims.get('name','?')})", flush=True) + return resp + + +@app.get("/auth/me") +async def auth_me(request: Request): + """Vrne trenutno prijavljenega uporabnika (za frontend header).""" + token = request.cookies.get(SESSION_COOKIE_NAME) + if token: + email = _verify_session_token(token) + if email and _email_allowed(email): + return {"email": email, "method": "google"} + # Tudi basic auth (če cron uporablja) + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Basic "): + try: + import base64 + decoded = base64.b64decode(auth_header[6:]).decode() + user, _, pwd = decoded.partition(":") + if secrets.compare_digest(user, AUTH_USER) and secrets.compare_digest(pwd, AUTH_PASS): + return {"email": user, "method": "basic"} + except Exception: + pass + raise HTTPException(401, "Not logged in") + + +@app.post("/logout") +async def logout(): + """Pobriše session cookie + redirect na /login.""" + resp = RedirectResponse(url="/login", status_code=303) + resp.delete_cookie(SESSION_COOKIE_NAME, path="/") + return resp + + +@app.get("/logout") +async def logout_get(): + """GET varianta za linke.""" + resp = RedirectResponse(url="/login", status_code=303) + resp.delete_cookie(SESSION_COOKIE_NAME, path="/") + return resp + + @app.get("/", response_class=HTMLResponse) async def index(user: str = Depends(check_auth)): html = (Path(__file__).parent.parent / "templates" / "index.html").read_text() diff --git a/templates/index.html b/templates/index.html index a772aa1..21613ab 100644 --- a/templates/index.html +++ b/templates/index.html @@ -313,9 +313,15 @@ -
-

1] reels clipper

- biba.live +
+
+

1] reels clipper

+ biba.live +
+
@@ -1163,6 +1169,18 @@ // Toggle pokaži/skrij že naložene + iskalnik document.addEventListener("DOMContentLoaded", () => { + // Naloži user info v header + fetch("/auth/me").then(r => r.ok ? r.json() : null).then(u => { + if (u && u.email) { + const ui = document.getElementById("user-info"); + const ue = document.getElementById("user-email"); + if (ui && ue) { + ue.textContent = u.email; + ui.style.display = "flex"; + } + } + }).catch(() => {}); + const toggle = $("#show-uploaded"); if (toggle) toggle.addEventListener("change", refreshJobs); const search = $("#jobs-search");