Load a document to get started
- Paste text, upload a PDF, or enter a URL -diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..67d3cf4 --- /dev/null +++ b/auth.py @@ -0,0 +1,58 @@ +from passlib.context import CryptContext +from jose import JWTError, jwt +from datetime import datetime, timedelta +from typing import Optional +from fastapi import Depends, HTTPException, status, Cookie +from sqlalchemy.orm import Session +from models import get_db, User + +SECRET_KEY = "speechify-jwt-secret-2025-change-in-prod" +ALGORITHM = "HS256" +TOKEN_EXPIRE_HOURS = 48 + +pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd.verify(plain, hashed) + + +def create_token(user_id: int, username: str, role: str) -> str: + expire = datetime.utcnow() + timedelta(hours=TOKEN_EXPIRE_HOURS) + return jwt.encode( + {"sub": str(user_id), "username": username, "role": role, "exp": expire}, + SECRET_KEY, + algorithm=ALGORITHM, + ) + + +def decode_token(token: str) -> Optional[dict]: + try: + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError: + return None + + +def get_current_user( + token: Optional[str] = Cookie(None, alias="auth_token"), + db: Session = Depends(get_db), +) -> User: + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + payload = decode_token(token) + if not payload: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + user = db.query(User).filter(User.id == int(payload["sub"])).first() + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return user + + +def require_admin(user: User = Depends(get_current_user)) -> User: + if user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required") + return user diff --git a/main.py b/main.py index 22d9421..6461818 100644 --- a/main.py +++ b/main.py @@ -1,139 +1,212 @@ -from fastapi import FastAPI, UploadFile, File, HTTPException +from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Response, Request from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, Response +from fastapi.responses import FileResponse, RedirectResponse from pydantic import BaseModel -import httpx -import re -import os +from sqlalchemy.orm import Session +from typing import Optional +import httpx, re, uuid, requests as req_lib +from datetime import datetime -app = FastAPI(title="Speechify Clone") +from models import get_db, init_db, User, Document, Progress, Setting +from auth import ( + hash_password, verify_password, create_token, get_current_user, require_admin +) +# ── CONFIG ────────────────────────────────────────────────────────── LITELLM_BASE = "http://localhost:4000" -LITELLM_KEY = "sk-c5c58adcc8dda288067f034ee927be23509142d274fe48418c25009dfb507a52" +LITELLM_KEY = "sk-c5c58adcc8dda288067f034ee927be23509142d274fe48418c25009dfb507a52" +TURNSTILE_SECRET = "0x4AAAAAAC0VtE0VOo0vpGoTvmHSk8yRN4Y" TTS_MODELS = { "openai-tts-1": { "name": "OpenAI TTS", "quality": "Standard", "voices": [ - {"id": "alloy", "name": "Alloy"}, - {"id": "echo", "name": "Echo"}, - {"id": "fable", "name": "Fable"}, - {"id": "onyx", "name": "Onyx"}, - {"id": "nova", "name": "Nova"}, + {"id": "alloy", "name": "Alloy"}, + {"id": "ash", "name": "Ash"}, + {"id": "coral", "name": "Coral"}, + {"id": "echo", "name": "Echo"}, + {"id": "fable", "name": "Fable"}, + {"id": "nova", "name": "Nova"}, + {"id": "onyx", "name": "Onyx"}, + {"id": "sage", "name": "Sage"}, {"id": "shimmer", "name": "Shimmer"}, - ] + ], }, "openai-tts-1-hd": { "name": "OpenAI TTS HD", "quality": "High Definition", "voices": [ - {"id": "alloy", "name": "Alloy"}, - {"id": "echo", "name": "Echo"}, - {"id": "fable", "name": "Fable"}, - {"id": "onyx", "name": "Onyx"}, - {"id": "nova", "name": "Nova"}, + {"id": "alloy", "name": "Alloy"}, + {"id": "ash", "name": "Ash"}, + {"id": "coral", "name": "Coral"}, + {"id": "echo", "name": "Echo"}, + {"id": "fable", "name": "Fable"}, + {"id": "nova", "name": "Nova"}, + {"id": "onyx", "name": "Onyx"}, + {"id": "sage", "name": "Sage"}, {"id": "shimmer", "name": "Shimmer"}, - ] + ], }, "elevenlabs-v3": { "name": "ElevenLabs v3", "quality": "Premium", "voices": [ - {"id": "Rachel", "name": "Rachel"}, - {"id": "Drew", "name": "Drew"}, - {"id": "Clyde", "name": "Clyde"}, - {"id": "Paul", "name": "Paul"}, - {"id": "Domi", "name": "Domi"}, - {"id": "Dave", "name": "Dave"}, - {"id": "Fin", "name": "Fin"}, - {"id": "Bella", "name": "Bella"}, - {"id": "Antoni", "name": "Antoni"}, - {"id": "Thomas", "name": "Thomas"}, + {"id": "Rachel", "name": "Rachel"}, + {"id": "Drew", "name": "Drew"}, + {"id": "Clyde", "name": "Clyde"}, + {"id": "Paul", "name": "Paul"}, + {"id": "Domi", "name": "Domi"}, + {"id": "Dave", "name": "Dave"}, + {"id": "Fin", "name": "Fin"}, + {"id": "Bella", "name": "Bella"}, + {"id": "Antoni", "name": "Antoni"}, + {"id": "Thomas", "name": "Thomas"}, {"id": "Charlie", "name": "Charlie"}, - {"id": "Emily", "name": "Emily"}, - {"id": "Elli", "name": "Elli"}, - {"id": "Callum", "name": "Callum"}, + {"id": "Emily", "name": "Emily"}, + {"id": "Elli", "name": "Elli"}, + {"id": "Callum", "name": "Callum"}, {"id": "Patrick", "name": "Patrick"}, - {"id": "Harry", "name": "Harry"}, - {"id": "Liam", "name": "Liam"}, + {"id": "Harry", "name": "Harry"}, + {"id": "Liam", "name": "Liam"}, {"id": "Dorothy", "name": "Dorothy"}, - {"id": "Josh", "name": "Josh"}, - {"id": "Arnold", "name": "Arnold"}, - {"id": "Adam", "name": "Adam"}, - {"id": "Sam", "name": "Sam"}, - ] + {"id": "Josh", "name": "Josh"}, + {"id": "Arnold", "name": "Arnold"}, + {"id": "Adam", "name": "Adam"}, + {"id": "Sam", "name": "Sam"}, + ], }, "elevenlabs-multilingual": { "name": "ElevenLabs Multilingual", "quality": "Premium Multilingual", "voices": [ - {"id": "Rachel", "name": "Rachel"}, - {"id": "Drew", "name": "Drew"}, - {"id": "Clyde", "name": "Clyde"}, - {"id": "Paul", "name": "Paul"}, - {"id": "Domi", "name": "Domi"}, - {"id": "Dave", "name": "Dave"}, - {"id": "Fin", "name": "Fin"}, - {"id": "Bella", "name": "Bella"}, - {"id": "Antoni", "name": "Antoni"}, - {"id": "Thomas", "name": "Thomas"}, + {"id": "Rachel", "name": "Rachel"}, + {"id": "Drew", "name": "Drew"}, + {"id": "Clyde", "name": "Clyde"}, + {"id": "Paul", "name": "Paul"}, + {"id": "Domi", "name": "Domi"}, + {"id": "Dave", "name": "Dave"}, + {"id": "Fin", "name": "Fin"}, + {"id": "Bella", "name": "Bella"}, + {"id": "Antoni", "name": "Antoni"}, + {"id": "Thomas", "name": "Thomas"}, {"id": "Charlie", "name": "Charlie"}, - {"id": "Emily", "name": "Emily"}, - {"id": "Elli", "name": "Elli"}, - {"id": "Callum", "name": "Callum"}, + {"id": "Emily", "name": "Emily"}, + {"id": "Elli", "name": "Elli"}, + {"id": "Callum", "name": "Callum"}, {"id": "Patrick", "name": "Patrick"}, - {"id": "Harry", "name": "Harry"}, - {"id": "Liam", "name": "Liam"}, + {"id": "Harry", "name": "Harry"}, + {"id": "Liam", "name": "Liam"}, {"id": "Dorothy", "name": "Dorothy"}, - {"id": "Josh", "name": "Josh"}, - {"id": "Arnold", "name": "Arnold"}, - {"id": "Adam", "name": "Adam"}, - {"id": "Sam", "name": "Sam"}, - ] + {"id": "Josh", "name": "Josh"}, + {"id": "Arnold", "name": "Arnold"}, + {"id": "Adam", "name": "Adam"}, + {"id": "Sam", "name": "Sam"}, + ], }, "vertex-gemini-2.5-flash-tts": { "name": "Gemini 2.5 Flash TTS", "quality": "Fast", "voices": [ - {"id": "Aoede", "name": "Aoede"}, - {"id": "Charon", "name": "Charon"}, - {"id": "Fenrir", "name": "Fenrir"}, - {"id": "Kore", "name": "Kore"}, - {"id": "Puck", "name": "Puck"}, - {"id": "Orbit", "name": "Orbit"}, - {"id": "Zephyr", "name": "Zephyr"}, - {"id": "Autonoe", "name": "Autonoe"}, - {"id": "Enceladus", "name": "Enceladus"}, - {"id": "Laomedeia", "name": "Laomedeia"}, - {"id": "Achernar", "name": "Achernar"}, + {"id": "Aoede", "name": "Aoede"}, + {"id": "Charon", "name": "Charon"}, + {"id": "Fenrir", "name": "Fenrir"}, + {"id": "Kore", "name": "Kore"}, + {"id": "Puck", "name": "Puck"}, + {"id": "Orbit", "name": "Orbit"}, + {"id": "Zephyr", "name": "Zephyr"}, + {"id": "Autonoe", "name": "Autonoe"}, + {"id": "Enceladus", "name": "Enceladus"}, + {"id": "Laomedeia", "name": "Laomedeia"}, + {"id": "Achernar", "name": "Achernar"}, {"id": "Rasalgethi", "name": "Rasalgethi"}, - {"id": "Schedar", "name": "Schedar"}, - {"id": "Sulafat", "name": "Sulafat"}, - ] + {"id": "Schedar", "name": "Schedar"}, + {"id": "Sulafat", "name": "Sulafat"}, + ], }, "vertex-gemini-2.5-pro-tts": { "name": "Gemini 2.5 Pro TTS", "quality": "High Quality", "voices": [ - {"id": "Aoede", "name": "Aoede"}, - {"id": "Charon", "name": "Charon"}, - {"id": "Fenrir", "name": "Fenrir"}, - {"id": "Kore", "name": "Kore"}, - {"id": "Puck", "name": "Puck"}, - {"id": "Orbit", "name": "Orbit"}, - {"id": "Zephyr", "name": "Zephyr"}, - {"id": "Autonoe", "name": "Autonoe"}, - {"id": "Enceladus", "name": "Enceladus"}, - {"id": "Laomedeia", "name": "Laomedeia"}, - {"id": "Achernar", "name": "Achernar"}, + {"id": "Aoede", "name": "Aoede"}, + {"id": "Charon", "name": "Charon"}, + {"id": "Fenrir", "name": "Fenrir"}, + {"id": "Kore", "name": "Kore"}, + {"id": "Puck", "name": "Puck"}, + {"id": "Orbit", "name": "Orbit"}, + {"id": "Zephyr", "name": "Zephyr"}, + {"id": "Autonoe", "name": "Autonoe"}, + {"id": "Enceladus", "name": "Enceladus"}, + {"id": "Laomedeia", "name": "Laomedeia"}, + {"id": "Achernar", "name": "Achernar"}, {"id": "Rasalgethi", "name": "Rasalgethi"}, - {"id": "Schedar", "name": "Schedar"}, - {"id": "Sulafat", "name": "Sulafat"}, - ] + {"id": "Schedar", "name": "Schedar"}, + {"id": "Sulafat", "name": "Sulafat"}, + ], }, } +# ── APP ───────────────────────────────────────────────────────────── +app = FastAPI(title="Speechify Clone") + +@app.on_event("startup") +def startup(): + init_db() + db = next(get_db()) + # Create default admin if no users exist + if db.query(User).count() == 0: + admin = User( + username="admin", + email="admin@speechify.local", + password_hash=hash_password("admin123"), + role="admin", + ) + db.add(admin) + db.commit() + print("\n" + "="*50) + print("Default admin created: admin / admin123") + print("="*50 + "\n") + db.close() + + +# ── HELPERS ───────────────────────────────────────────────────────── +def verify_turnstile(token: str, ip: str = "") -> bool: + try: + r = req_lib.post( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + data={"secret": TURNSTILE_SECRET, "response": token, "remoteip": ip}, + timeout=10, + ) + return r.json().get("success", False) + except Exception: + return False + + +def get_setting(db: Session, key: str, default: str = "") -> str: + s = db.query(Setting).filter(Setting.key == key).first() + return s.value if s else default + + +def set_setting(db: Session, key: str, value: str): + s = db.query(Setting).filter(Setting.key == key).first() + if s: + s.value = value + else: + db.add(Setting(key=key, value=value)) + db.commit() + + +# ── PYDANTIC MODELS ────────────────────────────────────────────────── +class LoginRequest(BaseModel): + username: str + password: str + turnstile_token: str + +class RegisterRequest(BaseModel): + username: str + email: str + password: str + turnstile_token: str class TTSRequest(BaseModel): text: str @@ -141,23 +214,306 @@ class TTSRequest(BaseModel): voice: str speed: float = 1.0 - class URLRequest(BaseModel): url: str +class SaveDocRequest(BaseModel): + title: str + content: str + source_type: str = "text" +class ProgressRequest(BaseModel): + sentence_index: int + +class NextcloudConfigRequest(BaseModel): + url: str + username: str + password: str + +class NextcloudDownloadRequest(BaseModel): + path: str + +class UserUpdateRequest(BaseModel): + role: Optional[str] = None + is_active: Optional[bool] = None + +class SettingsRequest(BaseModel): + nextcloud_url: Optional[str] = None + nextcloud_username: Optional[str] = None + nextcloud_password: Optional[str] = None + + +# ── AUTH ROUTES ───────────────────────────────────────────────────── +@app.post("/api/auth/login") +async def login(req: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)): + ip = request.client.host if request.client else "" + if not verify_turnstile(req.turnstile_token, ip): + raise HTTPException(status_code=400, detail="Turnstile verification failed") + + user = db.query(User).filter(User.username == req.username).first() + if not user or not verify_password(req.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid credentials") + if not user.is_active: + raise HTTPException(status_code=403, detail="Account disabled") + + token = create_token(user.id, user.username, user.role) + response.set_cookie("auth_token", token, httponly=True, samesite="lax", max_age=172800) + return {"username": user.username, "role": user.role} + + +@app.post("/api/auth/register") +async def register(req: RegisterRequest, response: Response, request: Request, db: Session = Depends(get_db)): + ip = request.client.host if request.client else "" + if not verify_turnstile(req.turnstile_token, ip): + raise HTTPException(status_code=400, detail="Turnstile verification failed") + + if db.query(User).filter(User.username == req.username).first(): + raise HTTPException(status_code=400, detail="Username already taken") + if db.query(User).filter(User.email == req.email).first(): + raise HTTPException(status_code=400, detail="Email already registered") + + user = User( + username=req.username, + email=req.email, + password_hash=hash_password(req.password), + role="user", + ) + db.add(user) + db.commit() + db.refresh(user) + + token = create_token(user.id, user.username, user.role) + response.set_cookie("auth_token", token, httponly=True, samesite="lax", max_age=172800) + return {"username": user.username, "role": user.role} + + +@app.post("/api/auth/logout") +async def logout(response: Response): + response.delete_cookie("auth_token") + return {"ok": True} + + +@app.get("/api/auth/me") +async def me(user: User = Depends(get_current_user)): + return {"id": user.id, "username": user.username, "email": user.email, "role": user.role} + + +# ── ADMIN ROUTES ───────────────────────────────────────────────────── +@app.get("/api/admin/users") +async def list_users(admin: User = Depends(require_admin), db: Session = Depends(get_db)): + users = db.query(User).all() + return [{"id": u.id, "username": u.username, "email": u.email, "role": u.role, + "is_active": u.is_active, "created_at": u.created_at.isoformat()} for u in users] + + +@app.patch("/api/admin/users/{user_id}") +async def update_user(user_id: int, req: UserUpdateRequest, + admin: User = Depends(require_admin), db: Session = Depends(get_db)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if req.role is not None: + user.role = req.role + if req.is_active is not None: + user.is_active = req.is_active + db.commit() + return {"ok": True} + + +@app.delete("/api/admin/users/{user_id}") +async def delete_user(user_id: int, admin: User = Depends(require_admin), db: Session = Depends(get_db)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + db.delete(user) + db.commit() + return {"ok": True} + + +@app.get("/api/admin/settings") +async def get_settings(admin: User = Depends(require_admin), db: Session = Depends(get_db)): + return { + "nextcloud_url": get_setting(db, "nextcloud_url"), + "nextcloud_username": get_setting(db, "nextcloud_username"), + "nextcloud_password": get_setting(db, "nextcloud_password"), + } + + +@app.post("/api/admin/settings") +async def save_settings(req: SettingsRequest, admin: User = Depends(require_admin), db: Session = Depends(get_db)): + if req.nextcloud_url is not None: set_setting(db, "nextcloud_url", req.nextcloud_url) + if req.nextcloud_username is not None: set_setting(db, "nextcloud_username", req.nextcloud_username) + if req.nextcloud_password is not None: set_setting(db, "nextcloud_password", req.nextcloud_password) + return {"ok": True} + + +# ── DOCUMENT ROUTES ────────────────────────────────────────────────── +@app.get("/api/documents") +async def list_documents(user: User = Depends(get_current_user), db: Session = Depends(get_db)): + docs = db.query(Document).filter(Document.user_id == user.id).order_by(Document.created_at.desc()).all() + return [{"id": d.id, "title": d.title, "source_type": d.source_type, + "created_at": d.created_at.isoformat()} for d in docs] + + +@app.post("/api/documents") +async def save_document(req: SaveDocRequest, user: User = Depends(get_current_user), db: Session = Depends(get_db)): + doc = Document( + id=str(uuid.uuid4()), + user_id=user.id, + title=req.title, + content=req.content, + source_type=req.source_type, + ) + db.add(doc) + db.commit() + return {"id": doc.id} + + +@app.put("/api/documents/{doc_id}") +async def update_document(doc_id: str, req: SaveDocRequest, + user: User = Depends(get_current_user), db: Session = Depends(get_db)): + doc = db.query(Document).filter(Document.id == doc_id, Document.user_id == user.id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + doc.title = req.title + doc.content = req.content + db.commit() + return {"id": doc.id} + + +@app.get("/api/documents/{doc_id}") +async def get_document(doc_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)): + doc = db.query(Document).filter(Document.id == doc_id, Document.user_id == user.id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + return {"id": doc.id, "title": doc.title, "content": doc.content, "source_type": doc.source_type} + + +@app.delete("/api/documents/{doc_id}") +async def delete_document(doc_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)): + doc = db.query(Document).filter(Document.id == doc_id, Document.user_id == user.id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + db.query(Progress).filter(Progress.document_id == doc_id).delete() + db.delete(doc) + db.commit() + return {"ok": True} + + +# ── PROGRESS ROUTES ────────────────────────────────────────────────── +@app.get("/api/progress/{doc_id}") +async def get_progress(doc_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)): + p = db.query(Progress).filter(Progress.user_id == user.id, Progress.document_id == doc_id).first() + return {"sentence_index": p.sentence_index if p else 0} + + +@app.post("/api/progress/{doc_id}") +async def save_progress(doc_id: str, req: ProgressRequest, + user: User = Depends(get_current_user), db: Session = Depends(get_db)): + p = db.query(Progress).filter(Progress.user_id == user.id, Progress.document_id == doc_id).first() + if p: + p.sentence_index = req.sentence_index + p.updated_at = datetime.utcnow() + else: + db.add(Progress(user_id=user.id, document_id=doc_id, sentence_index=req.sentence_index)) + db.commit() + return {"ok": True} + + +# ── NEXTCLOUD ROUTES ───────────────────────────────────────────────── +@app.get("/api/nextcloud/files") +async def list_nextcloud_files(path: str = "/", user: User = Depends(get_current_user), db: Session = Depends(get_db)): + nc_url = get_setting(db, "nextcloud_url") + nc_user = get_setting(db, "nextcloud_username") + nc_pass = get_setting(db, "nextcloud_password") + if not nc_url: + raise HTTPException(status_code=400, detail="Nextcloud not configured") + + dav_url = f"{nc_url.rstrip('/')}/remote.php/dav/files/{nc_user}/{path.lstrip('/')}" + try: + r = req_lib.request( + "PROPFIND", dav_url, + auth=(nc_user, nc_pass), + headers={"Depth": "1"}, + timeout=15, + ) + if r.status_code not in (207, 200): + raise HTTPException(status_code=r.status_code, detail="Nextcloud error") + + # Parse WebDAV XML response + from xml.etree import ElementTree as ET + ns = {"d": "DAV:"} + tree = ET.fromstring(r.text) + items = [] + for resp in tree.findall("d:response", ns): + href = resp.find("d:href", ns).text + prop = resp.find(".//d:prop", ns) + display_name = prop.find("d:displayname", ns) + content_type = prop.find("d:getcontenttype", ns) + is_collection = prop.find("d:resourcetype/d:collection", ns) is not None + + name = display_name.text if display_name is not None else href.split("/")[-1] + mime = content_type.text if content_type is not None else "" + if not name: + continue + + # Extract path relative to user's root + dav_root = f"/remote.php/dav/files/{nc_user}/" + rel_path = href.split(dav_root)[-1] if dav_root in href else href + + if is_collection or mime == "application/pdf" or rel_path.lower().endswith(".pdf"): + items.append({ + "name": name, + "path": rel_path, + "is_dir": is_collection, + "mime": mime, + }) + return items + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/nextcloud/download") +async def download_nextcloud_file(req: NextcloudDownloadRequest, + user: User = Depends(get_current_user), db: Session = Depends(get_db)): + nc_url = get_setting(db, "nextcloud_url") + nc_user = get_setting(db, "nextcloud_username") + nc_pass = get_setting(db, "nextcloud_password") + if not nc_url: + raise HTTPException(status_code=400, detail="Nextcloud not configured") + + try: + import fitz + dav_url = f"{nc_url.rstrip('/')}/remote.php/dav/files/{nc_user}/{req.path.lstrip('/')}" + r = req_lib.get(dav_url, auth=(nc_user, nc_pass), timeout=30) + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail="File download failed") + + doc = fitz.open(stream=r.content, filetype="pdf") + pages = [page.get_text() for page in doc] + text = re.sub(r'\n{3,}', '\n\n', "\n\n".join(pages)).strip() + title = req.path.split("/")[-1] + return {"text": text, "title": title} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── VOICES ─────────────────────────────────────────────────────────── @app.get("/api/voices") -async def get_voices(): +async def get_voices(_: User = Depends(get_current_user)): return TTS_MODELS +# ── TTS ────────────────────────────────────────────────────────────── @app.post("/api/tts") -async def text_to_speech(req: TTSRequest): - payload = { - "model": req.model, - "input": req.text, - "voice": req.voice, - } +async def text_to_speech(req: TTSRequest, _: User = Depends(get_current_user)): + payload = {"model": req.model, "input": req.text, "voice": req.voice} if req.speed != 1.0: payload["speed"] = req.speed @@ -167,82 +523,72 @@ async def text_to_speech(req: TTSRequest): json=payload, headers={"Authorization": f"Bearer {LITELLM_KEY}"}, ) - if resp.status_code != 200: - raise HTTPException(status_code=resp.status_code, detail=resp.text) + if resp.status_code != 200: + raise HTTPException(status_code=resp.status_code, detail=resp.text) - return Response( - content=resp.content, - media_type="audio/mpeg", - ) + from fastapi.responses import Response + return Response(content=resp.content, media_type="audio/mpeg") +# ── URL EXTRACTION ──────────────────────────────────────────────────── @app.post("/api/extract-url") -async def extract_from_url(req: URLRequest): +async def extract_from_url(req: URLRequest, _: User = Depends(get_current_user)): try: from bs4 import BeautifulSoup import html2text async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client: - resp = await client.get( - req.url, - headers={"User-Agent": "Mozilla/5.0 (compatible; SpeechifyClone/1.0)"}, - ) + resp = await client.get(req.url, headers={"User-Agent": "Mozilla/5.0"}) soup = BeautifulSoup(resp.text, "html.parser") - for tag in soup(["script", "style", "nav", "footer", "header", "aside", "iframe"]): tag.decompose() - main = ( - soup.find("article") - or soup.find("main") - or soup.find(id="content") - or soup.find(class_="content") - or soup.body - ) - + main = soup.find("article") or soup.find("main") or soup.find(id="content") or soup.body h = html2text.HTML2Text() h.ignore_links = True h.ignore_images = True h.body_width = 0 - - text = h.handle(str(main)) - text = re.sub(r'\n{3,}', '\n\n', text).strip() + text = re.sub(r'\n{3,}', '\n\n', h.handle(str(main))).strip() title = soup.title.string.strip() if soup.title else req.url - return {"text": text, "title": title} except Exception as e: raise HTTPException(status_code=400, detail=str(e)) +# ── PDF EXTRACTION ──────────────────────────────────────────────────── @app.post("/api/extract-pdf") -async def extract_from_pdf(file: UploadFile = File(...)): +async def extract_from_pdf(file: UploadFile = File(...), _: User = Depends(get_current_user)): + """ + Reads the binary PDF file, extracts all text content from each page, + and returns it as plain text that can be fed to TTS. + """ try: import fitz # PyMuPDF - content = await file.read() doc = fitz.open(stream=content, filetype="pdf") - - pages = [] - for page in doc: - pages.append(page.get_text()) - - text = "\n\n".join(pages) - text = re.sub(r'\n{3,}', '\n\n', text).strip() - + pages = [page.get_text() for page in doc] + text = re.sub(r'\n{3,}', '\n\n', "\n\n".join(pages)).strip() return {"text": text, "title": file.filename} except Exception as e: raise HTTPException(status_code=400, detail=str(e)) +# ── STATIC / PAGES ──────────────────────────────────────────────────── app.mount("/static", StaticFiles(directory="static"), name="static") +@app.get("/login") +async def login_page(): + return FileResponse("static/login.html") + + @app.get("/") -async def root(): +async def root(request: Request): + # Let the frontend handle auth redirect via /api/auth/me return FileResponse("static/index.html") -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8080, reload=True) +@app.get("/admin") +async def admin_page(): + return FileResponse("static/admin.html") diff --git a/models.py b/models.py new file mode 100644 index 0000000..390c616 --- /dev/null +++ b/models.py @@ -0,0 +1,61 @@ +from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean, ForeignKey, UniqueConstraint +from sqlalchemy.orm import DeclarativeBase, sessionmaker +from datetime import datetime +import uuid + +DATABASE_URL = "sqlite:///./speechify.db" +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, nullable=False, index=True) + email = Column(String(100), unique=True, nullable=False) + password_hash = Column(String(255), nullable=False) + role = Column(String(10), default="user") # "admin" or "user" + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + + +class Document(Base): + __tablename__ = "documents" + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + title = Column(String(500)) + content = Column(Text) + source_type = Column(String(20)) # text, pdf, url, nextcloud + created_at = Column(DateTime, default=datetime.utcnow) + + +class Progress(Base): + __tablename__ = "progress" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + document_id = Column(String(36), ForeignKey("documents.id"), nullable=False) + sentence_index = Column(Integer, default=0) + updated_at = Column(DateTime, default=datetime.utcnow) + __table_args__ = (UniqueConstraint("user_id", "document_id", name="uq_user_doc"),) + + +class Setting(Base): + __tablename__ = "settings" + key = Column(String(100), primary_key=True) + value = Column(Text) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + Base.metadata.create_all(bind=engine) diff --git a/requirements.txt b/requirements.txt index 0c325b7..363fa9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,6 @@ pymupdf beautifulsoup4 html2text requests +sqlalchemy +passlib[bcrypt] +python-jose[cryptography] diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 0000000..b5e5297 --- /dev/null +++ b/static/admin.html @@ -0,0 +1,197 @@ + + +
+ + +| ID | Username | Role | Status | Joined | Actions |
|---|