From 39934115a1c3da42f05b6645e077d296878a147b Mon Sep 17 00:00:00 2001 From: oib Date: Wed, 21 May 2025 08:58:07 +0200 Subject: [PATCH] Update 2025-05-21_08:58:06 --- README.md | 4 +- __RELOAD__ | 0 concat_opus.py | 37 +++ gunicorn.conf.py | 10 + list_streams.py | 61 +++- log.py | 2 +- magic.py | 20 +- main.py | 180 +++++++---- public_streams.txt | 1 + range_response.py | 60 ++++ redirect.py | 14 - register.py | 53 +++- requirements.in | 14 + requirements.txt | 84 ++++- static/app.js | 695 +++++++++++++++++++++--------------------- static/dashboard.js | 192 ++++++++++-- static/index.html | 165 +++++----- static/magic-login.js | 65 +++- static/nav.js | 133 +++++--- static/reload.txt | 0 static/streams-ui.js | 238 ++++++++++++--- static/style.css | 605 +++++++++++++++++++++++++++++++++++- static/toast.js | 19 ++ static/upload.js | 128 ++++++++ streams.py | 27 ++ streams_cache.py | 0 testmail.py | 11 + upload.py | 20 +- 28 files changed, 2166 insertions(+), 672 deletions(-) create mode 100644 __RELOAD__ create mode 100644 concat_opus.py create mode 100644 gunicorn.conf.py create mode 100644 public_streams.txt create mode 100644 range_response.py create mode 100644 requirements.in create mode 100644 static/reload.txt create mode 100644 static/toast.js create mode 100644 static/upload.js create mode 100644 streams.py create mode 100644 streams_cache.py create mode 100644 testmail.py diff --git a/README.md b/README.md index d9db01b..893cfc6 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ uvicorn main:app --reload - `static/` — static HTML/JS/CSS assets ### Notes -- By default, audio uploads are stored in `/data` and streams in `/srv/streams` (change in code as needed). +- By default, audio uploads are stored in `/data`. - Ollama music/singing detection requires a local Whisper API at `localhost:11434`. -- Abuse logs are written to `log.txt`. +- Abuse logs are written to `abuse.log`. ## License MIT diff --git a/__RELOAD__ b/__RELOAD__ new file mode 100644 index 0000000..e69de29 diff --git a/concat_opus.py b/concat_opus.py new file mode 100644 index 0000000..83af46c --- /dev/null +++ b/concat_opus.py @@ -0,0 +1,37 @@ +# concat_opus.py — Concatenate all opus files in a user directory in random order into a single stream.opus +import os +import random +import subprocess +from pathlib import Path + +def concat_opus_files(user_dir: Path, output_file: Path): + """ + Concatenate all .opus files in user_dir (except stream.opus) in random order into output_file. + Overwrites output_file if exists. Creates it if missing. + """ + files = [f for f in user_dir.glob('*.opus') if f.name != 'stream.opus'] + if not files: + raise FileNotFoundError(f"No opus files to concatenate in {user_dir}") + random.shuffle(files) + + # Create a filelist for ffmpeg concat + filelist_path = user_dir / 'filelist.txt' + with open(filelist_path, 'w') as f: + for opusfile in files: + f.write(f"file '{opusfile.resolve()}'\n") + + # ffmpeg concat demuxer (no re-encoding) + cmd = [ + 'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', str(filelist_path), + '-c', 'copy', str(output_file) + ] + try: + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"FFmpeg concat failed: {e}") + finally: + if filelist_path.exists(): + filelist_path.unlink() + if not output_file.exists(): + raise RuntimeError("Concatenation did not produce output.") + return output_file diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..0a53c4d --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,10 @@ +bind = "0.0.0.0:8000" +workers = 2 # Tune based on available CPU cores +worker_class = "uvicorn.workers.UvicornWorker" +timeout = 60 +keepalive = 30 +loglevel = "info" +accesslog = "-" +errorlog = "-" +proxy_allow_ips = "*" + diff --git a/list_streams.py b/list_streams.py index b2afbc5..0c3ab54 100644 --- a/list_streams.py +++ b/list_streams.py @@ -2,14 +2,63 @@ from fastapi import APIRouter from pathlib import Path +from fastapi.responses import StreamingResponse +import asyncio router = APIRouter() DATA_ROOT = Path("./data") -@router.get("/streams") +@router.get("/streams-sse") +def streams_sse(): + return list_streams_sse() + +import json + +import datetime + +def list_streams_sse(): + async def event_generator(): + txt_path = Path("./public_streams.txt") + if not txt_path.exists(): + print(f"[{datetime.datetime.now()}] [SSE] No public_streams.txt found") + yield f"data: {json.dumps({'end': True})}\n\n" + return + try: + with txt_path.open("r") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + stream = json.loads(line) + print(f"[{datetime.datetime.now()}] [SSE] Yielding stream: {stream}") + yield f"data: {json.dumps(stream)}\n\n" + await asyncio.sleep(0) # Yield control to event loop + except Exception as e: + print(f"[{datetime.datetime.now()}] [SSE] JSON decode error: {e}") + continue # skip malformed lines + print(f"[{datetime.datetime.now()}] [SSE] Yielding end event") + yield f"data: {json.dumps({'end': True})}\n\n" + except Exception as e: + print(f"[{datetime.datetime.now()}] [SSE] Exception: {e}") + yield f"data: {json.dumps({'end': True, 'error': True})}\n\n" + return StreamingResponse(event_generator(), media_type="text/event-stream") + def list_streams(): - streams = [] - for user_dir in DATA_ROOT.iterdir(): - if user_dir.is_dir() and (user_dir / "stream.opus").exists(): - streams.append(user_dir.name) - return {"streams": streams} + txt_path = Path("./public_streams.txt") + if not txt_path.exists(): + return {"streams": []} + try: + streams = [] + with txt_path.open("r") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + streams.append(json.loads(line)) + except Exception: + continue # skip malformed lines + return {"streams": streams} + except Exception: + return {"streams": []} diff --git a/log.py b/log.py index a0ab23c..4ffa88b 100644 --- a/log.py +++ b/log.py @@ -9,7 +9,7 @@ def log_violation(event: str, ip: str, uid: str, reason: str): timestamp = datetime.utcnow().isoformat() log_dir = os.path.join(os.path.dirname(__file__), "log") os.makedirs(log_dir, exist_ok=True) - log_path = os.path.join(log_dir, "log.txt") + log_path = os.path.join(log_dir, "abuse.log") log_entry = f"[{timestamp}] {event} IP={ip} UID={uid} REASON={reason}\n" with open(log_path, "a") as f: f.write(log_entry) diff --git a/magic.py b/magic.py index c732ad8..be6efea 100644 --- a/magic.py +++ b/magic.py @@ -11,20 +11,24 @@ router = APIRouter() @router.post("/magic-login") def magic_login(request: Request, db: Session = Depends(get_db), token: str = Form(...)): + print(f"[magic-login] Received token: {token}") user = db.exec(select(User).where(User.token == token)).first() + print(f"[magic-login] User lookup: {'found' if user else 'not found'}") if not user: + print("[magic-login] Invalid or expired token") return RedirectResponse(url="/?error=Invalid%20or%20expired%20token", status_code=302) - if user.confirmed: - return RedirectResponse(url="/?error=Token%20already%20used", status_code=302) - - if datetime.utcnow() - user.token_created > timedelta(minutes=15): + if datetime.utcnow() - user.token_created > timedelta(minutes=30): + print(f"[magic-login] Token expired for user: {user.username}") return RedirectResponse(url="/?error=Token%20expired", status_code=302) - user.confirmed = True - # record client IP on confirmation - user.ip = request.client.host - db.commit() + if not user.confirmed: + user.confirmed = True + user.ip = request.client.host + db.commit() + print(f"[magic-login] User {user.username} confirmed. Redirecting to /?login=success&confirmed_uid={user.username}") + else: + print(f"[magic-login] Token already used for user: {user.username}, but allowing multi-use login.") return RedirectResponse(url=f"/?login=success&confirmed_uid={user.username}", status_code=302) diff --git a/main.py b/main.py index 4c9fd21..1dedab7 100644 --- a/main.py +++ b/main.py @@ -1,18 +1,24 @@ # main.py — FastAPI backend entrypoint for dicta2stream -from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request -from fastapi.responses import JSONResponse, HTMLResponse +from fastapi import FastAPI, Request, Response, status, Form, UploadFile, File, Depends +from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware import os -import subprocess -from log import log_violation -from models import get_user_by_uid - -from sqlmodel import Session, SQLModel, select +import io +import traceback +import shutil +import mimetypes +from typing import Optional +from models import User, UploadLog +from sqlmodel import Session, select, SQLModel from database import get_db, engine -from models import User, UserQuota - -from fastapi import Depends +from log import log_violation +import secrets +import time +import json +from datetime import datetime from dotenv import load_dotenv load_dotenv() @@ -31,16 +37,72 @@ from fastapi.exception_handlers import RequestValidationError from fastapi.exceptions import HTTPException as FastAPIHTTPException app = FastAPI(debug=debug_mode) + +# --- CORS Middleware for SSE and API access --- +from fastapi.middleware.cors import CORSMiddleware +app.add_middleware( + CORSMiddleware, + allow_origins=["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + from fastapi.staticfiles import StaticFiles import os if not os.path.exists("data"): os.makedirs("data") -app.mount("/audio", StaticFiles(directory="data"), name="audio") +# Secure audio file serving endpoint (replaces static mount) +from fastapi.responses import FileResponse +from fastapi import Security + +def get_current_user(request: Request, db: Session = Depends(get_db)): + # Use your existing session/cookie/token mechanism here + uid = request.headers.get("x-uid") or request.query_params.get("uid") or request.cookies.get("uid") + if not uid: + raise HTTPException(status_code=403, detail="Not authenticated") + user = get_user_by_uid(uid) + if not user or not user.confirmed: + raise HTTPException(status_code=403, detail="Invalid user") + return user + +from range_response import range_response + +@app.get("/audio/{uid}/{filename}") +def get_audio(uid: str, filename: str, request: Request, db: Session = Depends(get_db)): + # Allow public access ONLY to stream.opus + user_dir = os.path.join("data", uid) + file_path = os.path.join(user_dir, filename) + real_user_dir = os.path.realpath(user_dir) + real_file_path = os.path.realpath(file_path) + if not real_file_path.startswith(real_user_dir): + raise HTTPException(status_code=403, detail="Path traversal detected") + if not os.path.isfile(real_file_path): + raise HTTPException(status_code=404, detail="File not found") + if filename == "stream.opus": + # Use range_response for browser seeking support + return range_response(request, real_file_path, content_type="audio/ogg") + # Otherwise, require authentication and owner check + try: + from fastapi import Security + current_user = get_current_user(request, db) + except Exception: + raise HTTPException(status_code=403, detail="Not allowed") + if uid != current_user.username: + raise HTTPException(status_code=403, detail="Not allowed") + return FileResponse(real_file_path, media_type="audio/ogg") if debug_mode: print("[DEBUG] FastAPI running in debug mode.") # Global error handler to always return JSON +from slowapi.errors import RateLimitExceeded +from models import get_user_by_uid, UserQuota + +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded. Please try again later."}) + @app.exception_handler(FastAPIHTTPException) async def http_exception_handler(request: FastAPIRequest, exc: FastAPIHTTPException): return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) @@ -57,20 +119,64 @@ async def generic_exception_handler(request: FastAPIRequest, exc: Exception): from register import router as register_router from magic import router as magic_router from upload import router as upload_router -from redirect import router as redirect_router +from streams import router as streams_router from list_user_files import router as list_user_files_router + +app.include_router(streams_router) + from list_streams import router as list_streams_router app.include_router(register_router) app.include_router(magic_router) app.include_router(upload_router) -app.include_router(redirect_router) app.include_router(list_user_files_router) app.include_router(list_streams_router) # Serve static files app.mount("/static", StaticFiles(directory="static"), name="static") +@app.post("/log-client") +async def log_client(request: Request): + try: + data = await request.json() + msg = data.get("msg", "") + ip = request.client.host + timestamp = datetime.utcnow().isoformat() + log_dir = os.path.join(os.path.dirname(__file__), "log") + os.makedirs(log_dir, exist_ok=True) + log_path = os.path.join(log_dir, "debug.log") + log_entry = f"[{timestamp}] IP={ip} MSG={msg}\n" + with open(log_path, "a") as f: + f.write(log_entry) + if os.getenv("DEBUG", "0") in ("1", "true", "True"): + print(f"[CLIENT-DEBUG] {log_entry.strip()}") + return {"status": "ok"} + except Exception as e: + # Enhanced error logging + import sys + import traceback + error_log_dir = os.path.join(os.path.dirname(__file__), "log") + os.makedirs(error_log_dir, exist_ok=True) + error_log_path = os.path.join(error_log_dir, "debug-errors.log") + tb = traceback.format_exc() + try: + req_body = await request.body() + except Exception: + req_body = b"" + error_entry = ( + f"[{datetime.utcnow().isoformat()}] /log-client ERROR: {type(e).__name__}: {e}\n" + f"Request IP: {getattr(request.client, 'host', None)}\n" + f"Request body: {req_body}\n" + f"Traceback:\n{tb}\n" + ) + try: + with open(error_log_path, "a") as ef: + ef.write(error_entry) + except Exception as ef_exc: + print(f"[CLIENT-DEBUG-ERROR] Failed to write error log: {ef_exc}", file=sys.stderr) + print(error_entry, file=sys.stderr) + return {"status": "error", "detail": str(e)} + @app.get("/", response_class=HTMLResponse) def serve_index(): with open("static/index.html") as f: @@ -116,9 +222,6 @@ def debug(request: Request): "headers": dict(request.headers), } -STREAM_DIR = "/srv/streams" -ICECAST_BASE_URL = "https://dicta2stream.net/stream/" -ICECAST_MOUNT_PREFIX = "user-" MAX_QUOTA_BYTES = 100 * 1024 * 1024 @app.post("/delete-account") @@ -142,47 +245,15 @@ async def delete_account(data: dict, request: Request, db: Session = Depends(get db.commit() import shutil - user_dir = os.path.join(STREAM_DIR, user.username) - # Only allow deletion within STREAM_DIR + user_dir = os.path.join('data', user.username) real_user_dir = os.path.realpath(user_dir) - if not real_user_dir.startswith(os.path.realpath(STREAM_DIR)): + if not real_user_dir.startswith(os.path.realpath('data')): raise HTTPException(status_code=400, detail="Invalid user directory") if os.path.exists(real_user_dir): shutil.rmtree(real_user_dir, ignore_errors=True) return {"message": "User deleted"} -@app.post("/upload") -async def upload_audio( - request: Request, - uid: str = Form(...), - file: UploadFile = File(...) -): - ip = request.client.host - user = get_user_by_uid(uid) - if not user: - log_violation("UPLOAD", ip, uid, "UID not found") - raise HTTPException(status_code=403, detail="Invalid user ID") - - if user.ip != ip: - log_violation("UPLOAD", ip, uid, "UID/IP mismatch") - raise HTTPException(status_code=403, detail="Device/IP mismatch") - - user_dir = os.path.join(STREAM_DIR, user.username) - os.makedirs(user_dir, exist_ok=True) - raw_path = os.path.join(user_dir, "upload.wav") - final_path = os.path.join(user_dir, "stream.opus") - - with open(raw_path, "wb") as out: - content = await file.read() - out.write(content) - - usage = subprocess.check_output(["du", "-sb", user_dir]).split()[0] - if int(usage) > MAX_QUOTA_BYTES: - os.remove(raw_path) - log_violation("UPLOAD", ip, uid, "Quota exceeded") - raise HTTPException(status_code=403, detail="Quota exceeded") - from fastapi.concurrency import run_in_threadpool # from detect_content_type_whisper_ollama import detect_content_type_whisper_ollama # Broken import: module not found content_type = None @@ -214,8 +285,7 @@ async def upload_audio( except Exception as e: log_violation("QUOTA", ip, uid, f"Quota update failed: {e}") - stream_url = f"{ICECAST_BASE_URL}{ICECAST_MOUNT_PREFIX}{user.username}.opus" - return {"stream_url": stream_url} + return {} @app.delete("/uploads/{uid}/{filename}") def delete_file(uid: str, filename: str, request: Request, db: Session = Depends(get_db)): @@ -227,7 +297,7 @@ def delete_file(uid: str, filename: str, request: Request, db: Session = Depends if user.ip != ip: raise HTTPException(status_code=403, detail="Device/IP mismatch") - user_dir = os.path.join(STREAM_DIR, user.username) + user_dir = os.path.join('data', user.username) target_path = os.path.join(user_dir, filename) # Prevent path traversal attacks real_target_path = os.path.realpath(target_path) @@ -268,7 +338,7 @@ def get_me(uid: str, request: Request, db: Session = Depends(get_db)): if not user or user.ip != ip: raise HTTPException(status_code=403, detail="Unauthorized access") - user_dir = os.path.join(STREAM_DIR, user.username) + user_dir = os.path.join('data', user.username) files = [] if os.path.exists(user_dir): for f in os.listdir(user_dir): @@ -280,7 +350,7 @@ def get_me(uid: str, request: Request, db: Session = Depends(get_db)): quota_mb = round(q.storage_bytes / (1024 * 1024), 2) if q else 0 return { - "stream_url": f"{ICECAST_BASE_URL}{ICECAST_MOUNT_PREFIX}{user.username}.opus", + "files": files, "quota": quota_mb } diff --git a/public_streams.txt b/public_streams.txt new file mode 100644 index 0000000..3a243cf --- /dev/null +++ b/public_streams.txt @@ -0,0 +1 @@ +{"uid":"devuser","size":22455090,"mtime":1747563720} diff --git a/range_response.py b/range_response.py new file mode 100644 index 0000000..c0ef9de --- /dev/null +++ b/range_response.py @@ -0,0 +1,60 @@ +# range_response.py — Range request support for audio files in FastAPI +import os +from fastapi import Request, HTTPException +from fastapi.responses import StreamingResponse +from typing import Generator + +def parse_range_header(range_header, file_size): + if not range_header or not range_header.startswith('bytes='): + return None + ranges = range_header[6:].split(',')[0].strip().split('-') + if len(ranges) != 2: + return None + try: + start = int(ranges[0]) if ranges[0] else 0 + end = int(ranges[1]) if ranges[1] else file_size - 1 + if start > end or end >= file_size: + return None + return start, end + except ValueError: + return None + +def file_stream_generator(path, start, end, chunk_size=8192) -> Generator[bytes, None, None]: + with open(path, 'rb') as f: + f.seek(start) + remaining = end - start + 1 + while remaining > 0: + chunk = f.read(min(chunk_size, remaining)) + if not chunk: + break + yield chunk + remaining -= len(chunk) + +def range_response(request: Request, file_path: str, content_type: str = 'audio/ogg'): + file_size = os.path.getsize(file_path) + range_header = request.headers.get('range') + range_tuple = parse_range_header(range_header, file_size) + if range_tuple: + start, end = range_tuple + headers = { + 'Content-Range': f'bytes {start}-{end}/{file_size}', + 'Accept-Ranges': 'bytes', + 'Content-Length': str(end - start + 1), + } + return StreamingResponse( + file_stream_generator(file_path, start, end), + status_code=206, + media_type=content_type, + headers=headers + ) + else: + headers = { + 'Accept-Ranges': 'bytes', + 'Content-Length': str(file_size), + } + return StreamingResponse( + file_stream_generator(file_path, 0, file_size - 1), + status_code=200, + media_type=content_type, + headers=headers + ) diff --git a/redirect.py b/redirect.py index ba1dcaa..aa451aa 100644 --- a/redirect.py +++ b/redirect.py @@ -1,16 +1,2 @@ # redirect.py — Short stream link: /stream/{uid} → /stream/{uid}/stream.opus -from fastapi import APIRouter, HTTPException -from fastapi.responses import RedirectResponse -from pathlib import Path - -router = APIRouter() -DATA_ROOT = Path("/data") - -@router.get("/stream/{uid}") -def redirect_to_stream(uid: str): - stream_path = DATA_ROOT / uid / "stream.opus" - if not stream_path.exists(): - raise HTTPException(status_code=404, detail="Stream not found") - - return RedirectResponse(f"/stream/{uid}/stream.opus") diff --git a/register.py b/register.py index 37b97e4..1ef5624 100644 --- a/register.py +++ b/register.py @@ -15,14 +15,51 @@ MAGIC_DOMAIN = "https://dicta2stream.net" @router.post("/register") def register(request: Request, email: str = Form(...), user: str = Form(...), db: Session = Depends(get_db)): - if db.get(User, email): - raise HTTPException(status_code=400, detail="Email already registered") - + from sqlalchemy.exc import IntegrityError + # Try to find user by email or username + existing_user = db.get(User, email) + if not existing_user: + # Try by username (since username is not primary key, need to query) + stmt = select(User).where(User.username == user) + existing_user = db.exec(stmt).first() token = str(uuid.uuid4()) - db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host)) - db.add(UserQuota(uid=user)) - db.commit() - + if existing_user: + # Update token, timestamp, and ip, set confirmed False + from datetime import datetime + existing_user.token = token + existing_user.token_created = datetime.utcnow() + existing_user.confirmed = False + existing_user.ip = request.client.host + db.add(existing_user) + try: + db.commit() + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"Database error: {e}") + else: + # Register new user + db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host)) + db.add(UserQuota(uid=user)) + try: + db.commit() + except Exception as e: + db.rollback() + if isinstance(e, IntegrityError): + # Race condition: user created after our check + # Try again as login + stmt = select(User).where((User.email == email) | (User.username == user)) + existing_user = db.exec(stmt).first() + if existing_user: + existing_user.token = token + existing_user.confirmed = False + existing_user.ip = request.client.host + db.add(existing_user) + db.commit() + else: + raise HTTPException(status_code=409, detail="Username or email already exists.") + else: + raise HTTPException(status_code=500, detail=f"Database error: {e}") + # Send magic link msg = EmailMessage() msg["From"] = MAGIC_FROM msg["To"] = email @@ -30,11 +67,9 @@ def register(request: Request, email: str = Form(...), user: str = Form(...), db msg.set_content( f"Hello {user},\n\nClick to confirm your account:\n{MAGIC_DOMAIN}/?token={token}\n\nThis link is valid for one-time login." ) - try: with smtplib.SMTP("localhost") as smtp: smtp.send_message(msg) except Exception as e: raise HTTPException(status_code=500, detail=f"Email failed: {e}") - return { "message": "Confirmation sent" } diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..72f726a --- /dev/null +++ b/requirements.in @@ -0,0 +1,14 @@ +fastapi==0.115.12 +httptools==0.6.4 +pip==25.0.1 +psycopg2-binary==2.9.10 +python-dotenv==1.1.0 +python-multipart==0.0.20 +PyYAML==6.0.2 +setuptools==66.1.1 +slowapi==0.1.9 +sqlmodel==0.0.24 +uvicorn==0.34.2 +uvloop==0.21.0 +watchfiles==1.0.5 +websockets==15.0.1 diff --git a/requirements.txt b/requirements.txt index f8a0a0a..95f8886 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,75 @@ -fastapi -uvicorn -sqlmodel -python-dotenv -slowapi -requests -smtplib -email -psycopg2-binary +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile requirements.in +# +annotated-types==0.6.0 + # via pydantic +anyio==4.2.0 + # via + # starlette + # watchfiles +click==8.1.3 + # via uvicorn +deprecated==1.2.13 + # via limits +fastapi==0.115.12 + # via -r requirements.in +greenlet==3.2.1 + # via sqlalchemy +h11==0.14.0 + # via uvicorn +httptools==0.6.4 + # via -r requirements.in +idna==3.4 + # via anyio +limits==3.2.0 + # via slowapi +packaging==23.0 + # via limits +psycopg2-binary==2.9.10 + # via -r requirements.in +pydantic==2.6.0 + # via + # fastapi + # sqlmodel +pydantic-core==2.16.1 + # via pydantic +python-dotenv==1.1.0 + # via -r requirements.in +python-multipart==0.0.20 + # via -r requirements.in +pyyaml==6.0.2 + # via -r requirements.in +slowapi==0.1.9 + # via -r requirements.in +sniffio==1.3.0 + # via anyio +sqlalchemy==2.0.40 + # via sqlmodel +sqlmodel==0.0.24 + # via -r requirements.in +starlette==0.46.1 + # via fastapi +typing-extensions==4.13.2 + # via + # fastapi + # limits + # pydantic + # pydantic-core + # sqlalchemy +uvicorn==0.34.2 + # via -r requirements.in +uvloop==0.21.0 + # via -r requirements.in +watchfiles==1.0.5 + # via -r requirements.in +websockets==15.0.1 + # via -r requirements.in +wrapt==1.15.0 + # via deprecated + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/static/app.js b/static/app.js index 3f7c384..d198c77 100644 --- a/static/app.js +++ b/static/app.js @@ -1,27 +1,52 @@ // app.js — Frontend upload + minimal native player logic with slide-in and pulse effect -import { playBeep } from "./sound.js"; - -// 🔔 Minimal toast helper so calls to showToast() don’t fail -function showToast(msg) { - const toast = document.createElement("div"); - toast.className = "toast"; - toast.textContent = msg; - toast.style.position = "fixed"; - toast.style.bottom = "1.5rem"; - toast.style.left = "50%"; - toast.style.transform = "translateX(-50%)"; - toast.style.background = "#333"; - toast.style.color = "#fff"; - toast.style.padding = "0.6em 1.2em"; - toast.style.borderRadius = "6px"; - toast.style.boxShadow = "0 2px 6px rgba(0,0,0,.2)"; - toast.style.zIndex = 9999; - document.body.appendChild(toast); - setTimeout(() => toast.remove(), 4000); +function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + return null; } +import { playBeep } from "./sound.js"; +import { showToast } from "./toast.js"; + + +// Log debug messages to server +export function logToServer(msg) { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/log", true); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.send(JSON.stringify({ msg })); +} + +// Expose for debugging +window.logToServer = logToServer; + +// Handle magic link login redirect +(function handleMagicLoginRedirect() { + const params = new URLSearchParams(window.location.search); + if (params.get('login') === 'success' && params.get('confirmed_uid')) { + const username = params.get('confirmed_uid'); + localStorage.setItem('uid', username); + logToServer(`[DEBUG] localStorage.setItem('uid', '${username}')`); + localStorage.setItem('confirmed_uid', username); + logToServer(`[DEBUG] localStorage.setItem('confirmed_uid', '${username}')`); + const uidTime = Date.now().toString(); + localStorage.setItem('uid_time', uidTime); + logToServer(`[DEBUG] localStorage.setItem('uid_time', '${uidTime}')`); + // Set uid as cookie for backend authentication + document.cookie = "uid=" + encodeURIComponent(username) + "; path=/"; + // Remove query params from URL + window.history.replaceState({}, document.title, window.location.pathname); + // Reload to show dashboard as logged in + location.reload(); + return; + } +})(); + document.addEventListener("DOMContentLoaded", () => { + // (Removed duplicate logToServer definition) + // Guest vs. logged-in toggling is now handled by dashboard.js // --- Public profile view logic --- function showProfilePlayerFromUrl() { @@ -43,373 +68,303 @@ document.addEventListener("DOMContentLoaded", () => { if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`; const meDesc = document.querySelector("#me-page p"); if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`; - // Load playlist for the given profileUid - loadProfilePlaylist(profileUid); + // Show a Play Stream button for explicit user action + const streamInfo = document.getElementById("stream-info"); + if (streamInfo) { + streamInfo.innerHTML = ""; + const playBtn = document.createElement('button'); + playBtn.textContent = "▶ Play Stream"; + playBtn.onclick = () => { + loadProfileStream(profileUid); + playBtn.disabled = true; + }; + streamInfo.appendChild(playBtn); + streamInfo.hidden = false; + } + // Do NOT call loadProfileStream(profileUid) automatically! } } } - // Run on popstate (SPA navigation and browser back/forward) - window.addEventListener('popstate', showProfilePlayerFromUrl); - async function loadProfilePlaylist(uid) { - const meAudio = document.getElementById("me-audio"); - if (!meAudio) return; - const resp = await fetch(`/user-files/${encodeURIComponent(uid)}`); - const data = await resp.json(); - if (!data.files || !Array.isArray(data.files) || data.files.length === 0) { - meAudio.src = ""; - return; - } - // Shuffle playlist - function shuffle(array) { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; - } - window.mePlaylist = shuffle(data.files.map(f => `/audio/${uid}/${f}`)); - window.mePlaylistIdx = 0; - const newSrc = window.mePlaylist[window.mePlaylistIdx]; - meAudio.src = newSrc; - meAudio.load(); - meAudio.play().catch(() => {/* autoplay may be blocked, ignore */}); + // --- Only run showProfilePlayerFromUrl after session/profile checks are complete --- + function runProfilePlayerIfSessionValid() { + if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return; + showProfilePlayerFromUrl(); } - window.loadProfilePlaylist = loadProfilePlaylist; + document.addEventListener("DOMContentLoaded", () => { + setTimeout(runProfilePlayerIfSessionValid, 200); + }); + window.addEventListener('popstate', () => { + setTimeout(runProfilePlayerIfSessionValid, 200); + }); + window.showProfilePlayerFromUrl = showProfilePlayerFromUrl; - // --- Playlist for #me-page --- - const mePageLink = document.getElementById("show-me"); - const meAudio = document.getElementById("me-audio"); - const copyUrlBtn = document.getElementById("copy-url"); - if (copyUrlBtn) copyUrlBtn.onclick = () => { - const uid = localStorage.getItem("uid"); - if (uid) { - const streamUrl = `${window.location.origin}/stream/${encodeURIComponent(uid)}`; - navigator.clipboard.writeText(streamUrl); - showToast(`Copied your stream URL: ${streamUrl}`); - } else { - showToast("No user stream URL available"); + // Global audio state + let globalAudio = null; + let currentStreamUid = null; + let audioPlaying = false; + let lastPosition = 0; + + // Expose main audio element for other scripts + window.getMainAudio = () => globalAudio; + window.stopMainAudio = () => { + if (globalAudio) { + globalAudio.pause(); + audioPlaying = false; + updatePlayPauseButton(); } }; - let mePlaylist = []; - let mePlaylistIdx = 0; - // Playlist UI is hidden, so do not render + + function getOrCreateAudioElement() { + if (!globalAudio) { + globalAudio = document.getElementById('me-audio'); + if (!globalAudio) { + console.error('Audio element not found'); + return null; + } + // Set up audio element properties + globalAudio.preload = 'metadata'; // Preload metadata for better performance + globalAudio.crossOrigin = 'use-credentials'; // Use credentials for authenticated requests + globalAudio.setAttribute('crossorigin', 'use-credentials'); // Explicitly set the attribute + + // Set up event listeners + globalAudio.addEventListener('play', () => { + audioPlaying = true; + updatePlayPauseButton(); + }); + globalAudio.addEventListener('pause', () => { + audioPlaying = false; + updatePlayPauseButton(); + }); + globalAudio.addEventListener('timeupdate', () => lastPosition = globalAudio.currentTime); + + // Add error handling + globalAudio.addEventListener('error', (e) => { + console.error('Audio error:', e); + showToast('❌ Audio playback error'); + }); + } + return globalAudio; + } + + // Function to update play/pause button state + function updatePlayPauseButton() { + const audio = getOrCreateAudioElement(); + if (playPauseButton && audio) { + playPauseButton.textContent = audio.paused ? '▶' : '⏸️'; + } + } + + // Initialize play/pause button + const playPauseButton = document.getElementById('play-pause'); + if (playPauseButton) { + // Set initial state + updatePlayPauseButton(); + + // Add click handler + playPauseButton.addEventListener('click', () => { + const audio = getOrCreateAudioElement(); + if (audio) { + if (audio.paused) { + // Stop any playing public streams first + const publicPlayers = document.querySelectorAll('.stream-player audio'); + publicPlayers.forEach(player => { + if (!player.paused) { + player.pause(); + const button = player.closest('.stream-player').querySelector('.play-pause'); + if (button) { + button.textContent = '▶'; + } + } + }); + + audio.play().catch(e => { + console.error('Play failed:', e); + audioPlaying = false; + }); + } else { + audio.pause(); + } + updatePlayPauseButton(); + } + }); + } + + + + // Preload audio without playing it + function preloadAudio(src) { + return new Promise((resolve) => { + const audio = new Audio(); + audio.preload = 'auto'; + audio.crossOrigin = 'anonymous'; + audio.src = src; + audio.load(); + audio.oncanplaythrough = () => resolve(audio); + }); + } + + // Load and play a stream + async function loadProfileStream(uid) { + const audio = getOrCreateAudioElement(); + if (!audio) return null; + + // Always reset current stream and update audio source + currentStreamUid = uid; + audio.pause(); + audio.src = ''; + + // Wait a moment to ensure the previous source is cleared + await new Promise(resolve => setTimeout(resolve, 50)); + + // Set new source with cache-busting timestamp + audio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`; + + // Try to play immediately + try { + await audio.play(); + audioPlaying = true; + } catch (e) { + console.error('Play failed:', e); + audioPlaying = false; + } + + // Show stream info + const streamInfo = document.getElementById("stream-info"); + if (streamInfo) streamInfo.hidden = false; + + // Update button state + updatePlayPauseButton(); + + return audio; + } + +// Load and play a stream +async function loadProfileStream(uid) { + const audio = getOrCreateAudioElement(); + if (!audio) return null; + + // Hide playlist controls const mePrevBtn = document.getElementById("me-prev"); if (mePrevBtn) mePrevBtn.style.display = "none"; const meNextBtn = document.getElementById("me-next"); if (meNextBtn) meNextBtn.style.display = "none"; - async function loadUserPlaylist() { - const uid = localStorage.getItem("uid"); - if (!uid) return; - const resp = await fetch(`/user-files/${encodeURIComponent(uid)}`); - const data = await resp.json(); - if (!data.files || !Array.isArray(data.files) || data.files.length === 0) { - meAudio.src = ""; - return; - } - // Shuffle playlist - function shuffle(array) { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; - } - mePlaylist = shuffle(data.files.map(f => `/audio/${uid}/${f}`)); - mePlaylistIdx = 0; - const newSrc = mePlaylist[mePlaylistIdx]; - const prevSrc = meAudio.src; - const wasPlaying = !meAudio.paused && !meAudio.ended && meAudio.currentTime > 0; - const fullNewSrc = window.location.origin + newSrc; - if (prevSrc !== fullNewSrc) { - meAudio.src = newSrc; - meAudio.load(); - } // else: do nothing, already loaded - // Don't call load() if already playing the correct file - // Don't call load() redundantly - // Don't set src redundantly - // This prevents DOMException from fetch aborts - } + // Handle navigation to "Your Stream" + const mePageLink = document.getElementById("show-me"); + if (mePageLink) { + mePageLink.addEventListener("click", async (e) => { + e.preventDefault(); + const uid = localStorage.getItem("uid"); + if (!uid) return; - if (mePageLink && meAudio) { - mePageLink.addEventListener("click", async () => { - await loadUserPlaylist(); - // Start playback from current index - if (mePlaylist.length > 0) { - const newSrc = mePlaylist[mePlaylistIdx]; - const prevSrc = meAudio.src; - const fullNewSrc = window.location.origin + newSrc; - if (prevSrc !== fullNewSrc) { - meAudio.src = newSrc; - meAudio.load(); + // Show loading state + const streamInfo = document.getElementById("stream-info"); + if (streamInfo) { + streamInfo.hidden = false; + streamInfo.innerHTML = '

Loading stream...

'; + } + + try { + // Load the stream but don't autoplay + await loadProfileStream(uid); + + // Update URL without triggering a full page reload + if (window.location.pathname !== '/') { + window.history.pushState({}, '', '/'); } - meAudio.play(); - } - }); - meAudio.addEventListener("ended", () => { - if (mePlaylist.length > 1) { - mePlaylistIdx = (mePlaylistIdx + 1) % mePlaylist.length; - meAudio.src = mePlaylist[mePlaylistIdx]; - meAudio.load(); - meAudio.play(); - } else if (mePlaylist.length === 1) { - // Only one file: restart - meAudio.currentTime = 0; - meAudio.load(); - meAudio.play(); - } - }); - // Detect player stop and random play a new track - meAudio.addEventListener("pause", () => { - // Only trigger if playback reached the end and playlist has more than 1 track - if (meAudio.ended && mePlaylist.length > 1) { - let nextIdx; - do { - nextIdx = Math.floor(Math.random() * mePlaylist.length); - } while (nextIdx === mePlaylistIdx && mePlaylist.length > 1); - mePlaylistIdx = nextIdx; - meAudio.currentTime = 0; - meAudio.src = mePlaylist[mePlaylistIdx]; - meAudio.load(); - meAudio.play(); - + // Show the me-page section + const mePage = document.getElementById('me-page'); + if (mePage) { + document.querySelectorAll('main > section').forEach(s => s.hidden = s.id !== 'me-page'); + } + + // Clear loading state + const streamInfo = document.getElementById('stream-info'); + if (streamInfo) { + streamInfo.innerHTML = ''; + } + } catch (error) { + console.error('Error loading stream:', error); + const streamInfo = document.getElementById('stream-info'); + if (streamInfo) { + streamInfo.innerHTML = '

Error loading stream. Please try again.

'; + } } }); - - } - const deleteBtn = document.getElementById("delete-account"); - if (deleteBtn) deleteBtn.onclick = async () => { - if (!confirm("Are you sure you want to delete your account and all uploaded audio?")) return; - const res = await fetch("/delete-account", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ uid }) - }); - if (res.ok) { - showToast("✅ Account deleted"); - localStorage.removeItem("uid"); - setTimeout(() => window.location.reload(), 2000); - } else { - const msg = (await res.json()).detail || res.status; - showToast("❌ Delete failed: " + msg); - } - }; + // Always reset current stream and update audio source + currentStreamUid = uid; + audio.pause(); + audio.src = ''; - const fadeAllSections = () => { - const uid = localStorage.getItem('uid'); - document.querySelectorAll("main > section").forEach(section => { - // Always keep upload-area visible for logged-in users - if (uid && section.id === 'upload-area') return; - if (!section.hidden) { - section.classList.add("fade-out"); - setTimeout(() => { - section.classList.remove("fade-out"); - section.hidden = true; - }, 300); - } - }); - }; + // Wait a moment to ensure the previous source is cleared + await new Promise(resolve => setTimeout(resolve, 50)); - const dropzone = document.getElementById("upload-area"); - dropzone.setAttribute("aria-label", "Upload area. Click or drop an audio file to upload."); - const fileInput = document.getElementById("fileInput"); - const fileInfo = document.createElement("div"); - fileInfo.id = "file-info"; - fileInfo.style.textAlign = "center"; - fileInput.parentNode.insertBefore(fileInfo, fileInput.nextSibling); + // Set new source with cache-busting timestamp + audio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`; + + // Try to play immediately + try { + await audio.play(); + audioPlaying = true; + } catch (e) { + console.error('Play failed:', e); + audioPlaying = false; + } + + // Show stream info const streamInfo = document.getElementById("stream-info"); - const streamUrlEl = document.getElementById("streamUrl"); + if (streamInfo) streamInfo.hidden = false; - const status = document.getElementById("status"); - const spinner = document.getElementById("spinner"); + // Update button state + updatePlayPauseButton(); - const uid = localStorage.getItem("uid"); - const uidTime = parseInt(localStorage.getItem("uid_time"), 10); - const now = Date.now(); - // Hide register button if logged in - const registerBtn = document.getElementById("show-register"); - if (uid && localStorage.getItem("confirmed_uid") === uid && uidTime && (now - uidTime) < 3600000) { - if (registerBtn) registerBtn.style.display = "none"; - } else { - if (registerBtn) registerBtn.style.display = ""; - } - if (!uid || !uidTime || (now - uidTime) > 3600000) { - localStorage.removeItem("uid"); - localStorage.removeItem("confirmed_uid"); - localStorage.removeItem("uid_time"); - status.className = "error-toast"; - status.innerText = "❌ Session expired. Please log in again."; - // Add Login or Register button only for this error - let loginBtn = document.createElement('button'); - loginBtn.textContent = 'Login or Register'; - loginBtn.className = 'login-register-btn'; - loginBtn.onclick = () => { - document.querySelectorAll('main > section').forEach(sec => sec.hidden = sec.id !== 'register-page'); - }; - status.appendChild(document.createElement('br')); - status.appendChild(loginBtn); - // Remove the status div after a short delay so only toast remains - setTimeout(() => { - if (status.parentNode) status.parentNode.removeChild(status); - }, 100); - return; - } - const confirmed = localStorage.getItem("confirmed_uid"); - if (!confirmed || uid !== confirmed) { - status.className = "error-toast"; - status.innerText = "❌ Please confirm your account via email first."; - showToast(status.innerText); - return; - } + return audio; +} - let abortController; +// Export the function for use in other modules +window.loadProfileStream = loadProfileStream; - const upload = async (file) => { - if (abortController) abortController.abort(); - abortController = new AbortController(); - fileInfo.innerText = `📁 ${file.name} • ${(file.size / 1024 / 1024).toFixed(2)} MB`; - if (file.size > 100 * 1024 * 1024) { - status.className = "error-toast"; - status.innerText = "❌ Session expired. Please log in again."; - // Add Login or Register button only for this error - let loginBtn = document.createElement('button'); - loginBtn.textContent = 'Login or Register'; - loginBtn.className = 'login-register-btn'; - loginBtn.onclick = () => { - document.querySelectorAll('main > section').forEach(sec => sec.hidden = sec.id !== 'register-page'); - }; - status.appendChild(document.createElement('br')); - status.appendChild(loginBtn); - showToast(status.innerText); - return; - } - spinner.style.display = "block"; - status.innerHTML = '📡 Uploading…'; - status.className = "uploading-toast"; - fileInput.disabled = true; - dropzone.classList.add("uploading"); - const formData = new FormData(); - formData.append("uid", uid); - formData.append("file", file); - - const res = await fetch("/upload", { - signal: abortController.signal, - method: "POST", - body: formData, +document.addEventListener("DOMContentLoaded", () => { + // Initialize play/pause button + const playPauseButton = document.getElementById('play-pause'); + if (playPauseButton) { + // Set initial state + audioPlaying = false; + updatePlayPauseButton(); + + // Add event listener + playPauseButton.addEventListener('click', () => { + const audio = getMainAudio(); + if (audio) { + if (audio.paused) { + audio.play(); + } else { + audio.pause(); + } + updatePlayPauseButton(); + } }); + } - let data, parseError; - try { - data = await res.json(); - } catch (e) { - parseError = e; - } - if (!data) { - status.className = "error-toast"; - status.innerText = "❌ Upload failed: " + (parseError && parseError.message ? parseError.message : "Unknown error"); - showToast(status.innerText); - spinner.style.display = "none"; - fileInput.disabled = false; - dropzone.classList.remove("uploading"); - return; - } - if (res.ok) { - status.className = "success-toast"; - streamInfo.hidden = false; - streamInfo.innerHTML = ` -

Your stream is now live:

- -

Open in external player

- `; - const meAudio = document.getElementById("me-audio"); - meAudio.addEventListener("ended", () => { - if (mePlaylist.length > 1) { - mePlaylistIdx = (mePlaylistIdx + 1) % mePlaylist.length; - meAudio.src = mePlaylist[mePlaylistIdx]; - meAudio.load(); - meAudio.play(); - meUrl.value = mePlaylist[mePlaylistIdx]; - } else if (mePlaylist.length === 1) { - // Only one file: restart - meAudio.currentTime = 0; - meAudio.load(); - meAudio.play(); - } - }); - if (data.quota && data.quota.used_mb !== undefined) { - const bar = document.getElementById("quota-bar"); - const text = document.getElementById("quota-text"); - const quotaSec = document.getElementById("quota-meter"); - if (bar && text && quotaSec) { - quotaSec.hidden = false; - const used = parseFloat(data.quota.used_mb); - bar.value = used; - bar.max = 100; - text.textContent = `${used.toFixed(1)} MB used`; - } + // Add bot protection for registration form + const registerForm = document.getElementById('register-form'); + if (registerForm) { + registerForm.addEventListener('submit', (e) => { + const botTrap = e.target.elements.bot_trap; + if (botTrap && botTrap.value) { + e.preventDefault(); + showToast('❌ Bot detected! Please try again.'); + return false; } - spinner.style.display = "none"; - fileInput.disabled = false; - dropzone.classList.remove("uploading"); - showToast(status.innerText); - status.innerText = "✅ Upload successful."; - - playBeep(432, 0.25, "sine"); - - setTimeout(() => status.innerText = "", 5000); - streamInfo.classList.add("visible", "slide-in"); - } else { - streamInfo.hidden = true; - status.className = "error-toast"; - spinner.style.display = "none"; - if ((data.detail || data.error || "").includes("music")) { - status.innerText = "🎵 Upload rejected: singing or music detected."; - } else { - status.innerText = `❌ Upload failed: ${data.detail || data.error}`; - } - showToast(status.innerText); - fileInput.value = null; - dropzone.classList.remove("uploading"); - fileInput.disabled = false; - streamInfo.classList.remove("visible", "slide-in"); - } - }; - - dropzone.addEventListener("click", () => { - console.log("[DEBUG] Dropzone clicked"); - fileInput.click(); - console.log("[DEBUG] fileInput.click() called"); -}); - dropzone.addEventListener("dragover", (e) => { - e.preventDefault(); - dropzone.classList.add("dragover"); - dropzone.style.transition = "background-color 0.3s ease"; - }); - dropzone.addEventListener("dragleave", () => { - dropzone.classList.remove("dragover"); - }); - dropzone.addEventListener("drop", (e) => { - dropzone.classList.add("pulse"); - setTimeout(() => dropzone.classList.remove("pulse"), 400); - e.preventDefault(); - dropzone.classList.remove("dragover"); - const file = e.dataTransfer.files[0]; - if (file) upload(file); - }); - fileInput.addEventListener("change", (e) => { - status.innerText = ""; - status.className = ""; - const file = e.target.files[0]; - if (file) upload(file); - }); + return true; + }); + } + // Initialize navigation document.querySelectorAll('#links a[data-target]').forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); @@ -426,4 +381,34 @@ document.addEventListener("DOMContentLoaded", () => { if (burger && burger.checked) burger.checked = false; }); }); + + // Initialize profile player if valid session + setTimeout(runProfilePlayerIfSessionValid, 200); + window.addEventListener('popstate', () => { + setTimeout(runProfilePlayerIfSessionValid, 200); + }); +}); + // Initialize navigation + document.querySelectorAll('#links a[data-target]').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const target = link.getAttribute('data-target'); + // Only hide other sections when not opening #me-page + if (target !== 'me-page') fadeAllSections(); + const section = document.getElementById(target); + if (section) { + section.hidden = false; + section.classList.add("slide-in"); + section.scrollIntoView({ behavior: "smooth" }); + } + const burger = document.getElementById('burger-toggle'); + if (burger && burger.checked) burger.checked = false; + }); + }); + + // Initialize profile player if valid session + setTimeout(runProfilePlayerIfSessionValid, 200); + window.addEventListener('popstate', () => { + setTimeout(runProfilePlayerIfSessionValid, 200); + }); }); diff --git a/static/dashboard.js b/static/dashboard.js index 415868b..915abd6 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -1,28 +1,34 @@ +import { showToast } from "./toast.js"; + +function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + return null; +} // dashboard.js — toggle guest vs. user dashboard and reposition streams link async function initDashboard() { - const uploadArea = document.querySelector('#upload-area'); - const userDashboard = document.querySelector('#me-page'); - const meAudio = document.querySelector('#me-audio'); - const quotaBar = document.querySelector('#quota-bar'); - const quotaText = document.querySelector('#quota-text'); - const streamsLink = document.querySelector('#show-streams'); - const registerLink = document.querySelector('#show-register'); + // New dashboard toggling logic + const guestDashboard = document.getElementById('guest-dashboard'); + const userDashboard = document.getElementById('user-dashboard'); + const userUpload = document.getElementById('user-upload-area'); - // Default state: hide both - uploadArea.hidden = true; - userDashboard.hidden = true; + // Hide all by default + if (guestDashboard) guestDashboard.style.display = 'none'; + if (userDashboard) userDashboard.style.display = 'none'; + if (userUpload) userUpload.style.display = 'none'; - const uid = localStorage.getItem('uid'); + const uid = getCookie('uid'); if (!uid) { - // Guest: only upload area and move Streams next to Register - uploadArea.hidden = false; - userDashboard.hidden = true; - if (registerLink && streamsLink) { - registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement); - } - return; - } + // Guest view: only nav + if (guestDashboard) guestDashboard.style.display = ''; + if (userDashboard) userDashboard.style.display = 'none'; + if (userUpload) userUpload.style.display = 'none'; + const mePage = document.getElementById('me-page'); + if (mePage) mePage.style.display = 'none'; + return; +} try { const res = await fetch(`/me/${uid}`); @@ -30,22 +36,46 @@ async function initDashboard() { const data = await res.json(); // Logged-in view - uploadArea.hidden = false; - userDashboard.hidden = false; + // Restore links section and show-me link + const linksSection = document.getElementById('links'); + if (linksSection) linksSection.style.display = ''; + const showMeLink = document.getElementById('show-me'); + if (showMeLink && showMeLink.parentElement) showMeLink.parentElement.style.display = ''; + // Show me-page for logged-in users + const mePage = document.getElementById('me-page'); + if (mePage) mePage.style.display = ''; + // Ensure upload area is visible if last_page was me-page + const userUpload = document.getElementById('user-upload-area'); + if (userUpload && localStorage.getItem('last_page') === 'me-page') { + // userUpload visibility is now only controlled by nav.js SPA logic + } + + // Remove guest warning if present + const guestMsg = document.getElementById('guest-warning-msg'); + if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg); + userDashboard.style.display = ''; // Set audio source - meAudio.src = data.stream_url; + const meAudio = document.getElementById('me-audio'); + if (meAudio && uid) { + meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus`; + } + // Update quota - quotaBar.value = data.quota; - quotaText.textContent = `${data.quota} MB used`; + const quotaBar = document.getElementById('quota-bar'); + const quotaText = document.getElementById('quota-text'); + if (quotaBar) quotaBar.value = data.quota; + if (quotaText) quotaText.textContent = `${data.quota} MB used`; // Ensure Streams link remains in nav, not moved // (No action needed if static) } catch (e) { console.warn('Dashboard init error, treating as guest:', e); - localStorage.removeItem('uid'); - uploadArea.hidden = false; - userDashboard.hidden = true; + + userUpload.style.display = ''; + userDashboard.style.display = 'none'; + const registerLink = document.getElementById('guest-login'); + const streamsLink = document.getElementById('guest-streams'); if (registerLink && streamsLink) { registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement); } @@ -53,3 +83,111 @@ async function initDashboard() { } document.addEventListener('DOMContentLoaded', initDashboard); + +// Registration form handler for guests +// Handles the submit event on #register-form, sends data to /register, and alerts the user with the result + +document.addEventListener('DOMContentLoaded', () => { + const regForm = document.getElementById('register-form'); + if (regForm) { + regForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(regForm); + try { + const res = await fetch('/register', { + method: 'POST', + body: formData + }); + let data; + const contentType = res.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + data = await res.json(); + } else { + data = { detail: await res.text() }; + } + if (res.ok) { + showToast('Confirmation sent! Check your email.'); + } else { + showToast('Registration failed: ' + (data.detail || res.status)); + } + } catch (err) { + showToast('Network error: ' + err); + } + }); + } +}); + + +// Connect Login or Register link to register form + +document.addEventListener('DOMContentLoaded', () => { + // Login/Register (guest) + const loginLink = document.getElementById('guest-login'); + if (loginLink) { + loginLink.addEventListener('click', (e) => { + e.preventDefault(); + document.querySelectorAll('main > section').forEach(sec => { + sec.hidden = sec.id !== 'register-page'; + }); + const reg = document.getElementById('register-page'); + if (reg) reg.hidden = false; + reg.scrollIntoView({behavior:'smooth'}); + }); + } + // Terms of Service (all dashboards) + const termsLinks = [ + document.getElementById('guest-terms'), + document.getElementById('user-terms') + ]; + termsLinks.forEach(link => { + if (link) { + link.addEventListener('click', (e) => { + e.preventDefault(); + document.querySelectorAll('main > section').forEach(sec => { + sec.hidden = sec.id !== 'terms-page'; + }); + const terms = document.getElementById('terms-page'); + if (terms) terms.hidden = false; + terms.scrollIntoView({behavior:'smooth'}); + }); + } + }); + + // Imprint (all dashboards) + const imprintLinks = [ + document.getElementById('guest-imprint'), + document.getElementById('user-imprint') + ]; + imprintLinks.forEach(link => { + if (link) { + link.addEventListener('click', (e) => { + e.preventDefault(); + document.querySelectorAll('main > section').forEach(sec => { + sec.hidden = sec.id !== 'imprint-page'; + }); + const imprint = document.getElementById('imprint-page'); + if (imprint) imprint.hidden = false; + imprint.scrollIntoView({behavior:'smooth'}); + }); + } + }); + + // Privacy Policy (all dashboards) + const privacyLinks = [ + document.getElementById('guest-privacy'), + document.getElementById('user-privacy') + ]; + privacyLinks.forEach(link => { + if (link) { + link.addEventListener('click', (e) => { + e.preventDefault(); + document.querySelectorAll('main > section').forEach(sec => { + sec.hidden = sec.id !== 'privacy-page'; + }); + const privacy = document.getElementById('privacy-page'); + if (privacy) privacy.hidden = false; + privacy.scrollIntoView({behavior:'smooth'}); + }); + } + }); +}); diff --git a/static/index.html b/static/index.html index d97f723..78d96ed 100644 --- a/static/index.html +++ b/static/index.html @@ -2,12 +2,12 @@ + dicta2stream -