- Email verification: send link on register via SMTP2GO (noreply@pedshub.com), verify endpoint, beautiful HTML email - Unverified users see yellow banner in app with resend button; also shown on settings page - User settings page (/settings): personal Nextcloud credentials (override admin-wide config) - Admin and per-user Nextcloud config merged (user settings take priority) - Groq PlayAI TTS: 20 English voices + 2 Arabic voices via groq-playai-tts / groq-playai-tts-arabic - Added groq-playai-tts and groq-playai-tts-arabic to litellm config - Favicon SVG (purple play button) across all pages - Settings link in user dropdown menu - verify-ok.html and verify-fail.html result pages - DB migration: added email_verified column, verification_tokens and user_settings tables Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
789 lines
33 KiB
Python
789 lines
33 KiB
Python
from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Response, Request
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import FileResponse, RedirectResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
from typing import Optional
|
|
import httpx, re, uuid, requests as req_lib, secrets, smtplib
|
|
from datetime import datetime
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
|
|
from models import get_db, init_db, User, Document, Progress, Setting, VerificationToken, UserSetting
|
|
from auth import (
|
|
hash_password, verify_password, create_token, get_current_user, require_admin
|
|
)
|
|
|
|
# ── EMAIL CONFIG ─────────────────────────────────────────────────────
|
|
SMTP_HOST = "mail.smtp2go.com"
|
|
SMTP_PORT = 587
|
|
SMTP_USER = "danvics.com"
|
|
SMTP_PASS = "YFZ2L8F06YUYBfBR"
|
|
SMTP_FROM = "noreply@pedshub.com"
|
|
APP_NAME = "Speechify"
|
|
APP_URL = "https://read.pedshub.com"
|
|
|
|
# ── CONFIG ──────────────────────────────────────────────────────────
|
|
LITELLM_BASE = "http://localhost:4000"
|
|
LITELLM_KEY = "sk-c5c58adcc8dda288067f034ee927be23509142d274fe48418c25009dfb507a52"
|
|
TURNSTILE_SECRET = "0x4AAAAAAC0VtE0VOo0vpGoTvmHSk8yRN4Y"
|
|
|
|
TTS_MODELS = {
|
|
"openai-tts-1": {
|
|
"name": "OpenAI TTS",
|
|
"quality": "Standard",
|
|
"voices": [
|
|
{"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": "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": "Charlie", "name": "Charlie"},
|
|
{"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": "Dorothy", "name": "Dorothy"},
|
|
{"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": "Charlie", "name": "Charlie"},
|
|
{"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": "Dorothy", "name": "Dorothy"},
|
|
{"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": "Rasalgethi", "name": "Rasalgethi"},
|
|
{"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": "Rasalgethi", "name": "Rasalgethi"},
|
|
{"id": "Schedar", "name": "Schedar"},
|
|
{"id": "Sulafat", "name": "Sulafat"},
|
|
],
|
|
},
|
|
"groq-playai-tts": {
|
|
"name": "Groq PlayAI TTS",
|
|
"quality": "Ultra-fast",
|
|
"voices": [
|
|
{"id": "Fritz-PlayAI", "name": "Fritz"},
|
|
{"id": "Celeste-PlayAI", "name": "Celeste"},
|
|
{"id": "Ava-PlayAI", "name": "Ava"},
|
|
{"id": "Atlas-PlayAI", "name": "Atlas"},
|
|
{"id": "Jade-PlayAI", "name": "Jade"},
|
|
{"id": "Perseus-PlayAI", "name": "Perseus"},
|
|
{"id": "Luna-PlayAI", "name": "Luna"},
|
|
{"id": "Caelus-PlayAI", "name": "Caelus"},
|
|
{"id": "Orion-PlayAI", "name": "Orion"},
|
|
{"id": "Aaliyah-PlayAI", "name": "Aaliyah"},
|
|
{"id": "Adelaide-PlayAI", "name": "Adelaide"},
|
|
{"id": "Briggs-PlayAI", "name": "Briggs"},
|
|
{"id": "Deedee-PlayAI", "name": "Deedee"},
|
|
{"id": "Gail-PlayAI", "name": "Gail"},
|
|
{"id": "Jennifer-PlayAI", "name": "Jennifer"},
|
|
{"id": "Mamaw-PlayAI", "name": "Mamaw"},
|
|
{"id": "Mitch-PlayAI", "name": "Mitch"},
|
|
{"id": "Nia-PlayAI", "name": "Nia"},
|
|
{"id": "Quinn-PlayAI", "name": "Quinn"},
|
|
{"id": "Thunder-PlayAI", "name": "Thunder"},
|
|
],
|
|
},
|
|
"groq-playai-tts-arabic": {
|
|
"name": "Groq PlayAI Arabic",
|
|
"quality": "Ultra-fast · Arabic",
|
|
"voices": [
|
|
{"id": "Ahmad-PlayAI", "name": "Ahmad"},
|
|
{"id": "Amira-PlayAI", "name": "Amira"},
|
|
],
|
|
},
|
|
}
|
|
|
|
# ── 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()
|
|
|
|
|
|
# ── EMAIL ────────────────────────────────────────────────────────────
|
|
def send_email(to: str, subject: str, html: str):
|
|
try:
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = subject
|
|
msg["From"] = f"{APP_NAME} <{SMTP_FROM}>"
|
|
msg["To"] = to
|
|
msg.attach(MIMEText(html, "html"))
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as s:
|
|
s.starttls()
|
|
s.login(SMTP_USER, SMTP_PASS)
|
|
s.sendmail(SMTP_FROM, to, msg.as_string())
|
|
except Exception as e:
|
|
print(f"Email error: {e}")
|
|
|
|
|
|
def verification_email_html(username: str, link: str) -> str:
|
|
return f"""<!DOCTYPE html>
|
|
<html><head><meta charset="UTF-8"/></head>
|
|
<body style="margin:0;padding:0;background:#0d0d0d;font-family:'Helvetica Neue',Arial,sans-serif;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0d0d0d;padding:40px 0;">
|
|
<tr><td align="center">
|
|
<table width="520" cellpadding="0" cellspacing="0" style="background:#161616;border:1px solid #232323;border-radius:16px;overflow:hidden;">
|
|
<!-- Header -->
|
|
<tr><td style="background:#7c3aed;padding:28px 40px;text-align:center;">
|
|
<div style="display:inline-flex;align-items:center;gap:10px;">
|
|
<svg width="32" height="32" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="30" height="30" rx="9" fill="rgba(255,255,255,0.2)"/>
|
|
<polygon points="11,8 23,15 11,22" fill="white"/>
|
|
</svg>
|
|
<span style="font-size:20px;font-weight:700;color:#fff;letter-spacing:-0.3px;">{APP_NAME}</span>
|
|
</div>
|
|
</td></tr>
|
|
<!-- Body -->
|
|
<tr><td style="padding:40px;">
|
|
<p style="font-size:22px;font-weight:700;color:#e8e8e8;margin:0 0 12px;">Verify your email</p>
|
|
<p style="font-size:15px;color:#888;line-height:1.6;margin:0 0 28px;">
|
|
Hi <strong style="color:#e8e8e8;">{username}</strong>, thanks for signing up!<br/>
|
|
Click the button below to verify your email address and activate your account.
|
|
</p>
|
|
<table cellpadding="0" cellspacing="0" width="100%"><tr><td align="center">
|
|
<a href="{link}" style="display:inline-block;background:#7c3aed;color:#fff;text-decoration:none;
|
|
font-size:15px;font-weight:600;padding:14px 36px;border-radius:10px;letter-spacing:0.1px;">
|
|
Verify Email Address
|
|
</a>
|
|
</td></tr></table>
|
|
<p style="font-size:13px;color:#555;margin:28px 0 0;line-height:1.6;">
|
|
If you didn't create an account, you can safely ignore this email.<br/>
|
|
This link will remain valid as long as your account exists.
|
|
</p>
|
|
</td></tr>
|
|
<!-- Footer -->
|
|
<tr><td style="border-top:1px solid #232323;padding:20px 40px;text-align:center;">
|
|
<p style="font-size:12px;color:#444;margin:0;">{APP_NAME} · <a href="{APP_URL}" style="color:#7c3aed;text-decoration:none;">{APP_URL}</a></p>
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body></html>"""
|
|
|
|
|
|
def create_verification_token(db: Session, user_id: int) -> str:
|
|
# Remove old tokens for this user
|
|
db.query(VerificationToken).filter(VerificationToken.user_id == user_id).delete()
|
|
token = secrets.token_urlsafe(32)
|
|
db.add(VerificationToken(user_id=user_id, token=token))
|
|
db.commit()
|
|
return token
|
|
|
|
|
|
# ── 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()
|
|
|
|
|
|
def get_user_setting(db: Session, user_id: int, key: str, default: str = "") -> str:
|
|
s = db.query(UserSetting).filter(UserSetting.user_id == user_id, UserSetting.key == key).first()
|
|
return s.value if s else default
|
|
|
|
|
|
def set_user_setting(db: Session, user_id: int, key: str, value: str):
|
|
s = db.query(UserSetting).filter(UserSetting.user_id == user_id, UserSetting.key == key).first()
|
|
if s:
|
|
s.value = value
|
|
else:
|
|
db.add(UserSetting(user_id=user_id, key=key, value=value))
|
|
db.commit()
|
|
|
|
|
|
def resolve_nextcloud(db: Session, user: User):
|
|
"""Return (url, username, password) using user settings if set, else admin settings."""
|
|
url = get_user_setting(db, user.id, "nc_url") or get_setting(db, "nextcloud_url")
|
|
uname = get_user_setting(db, user.id, "nc_user") or get_setting(db, "nextcloud_username")
|
|
pw = get_user_setting(db, user.id, "nc_pass") or get_setting(db, "nextcloud_password")
|
|
return url, uname, pw
|
|
|
|
|
|
# ── 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
|
|
model: str
|
|
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 UserSettingsRequest(BaseModel):
|
|
nc_url: Optional[str] = None
|
|
nc_user: Optional[str] = None
|
|
nc_pass: Optional[str] = None
|
|
|
|
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, "email_verified": user.email_verified}
|
|
|
|
|
|
@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",
|
|
email_verified=False,
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
# Send verification email
|
|
vtoken = create_verification_token(db, user.id)
|
|
link = f"{APP_URL}/api/auth/verify?token={vtoken}"
|
|
html = verification_email_html(user.username, link)
|
|
import threading
|
|
threading.Thread(target=send_email, args=(user.email, f"Verify your {APP_NAME} account", html), daemon=True).start()
|
|
|
|
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, "email_verified": False}
|
|
|
|
|
|
@app.get("/api/auth/verify")
|
|
async def verify_email(token: str, db: Session = Depends(get_db)):
|
|
vt = db.query(VerificationToken).filter(VerificationToken.token == token).first()
|
|
if not vt:
|
|
return FileResponse("static/verify-fail.html")
|
|
user = db.query(User).filter(User.id == vt.user_id).first()
|
|
if user:
|
|
user.email_verified = True
|
|
db.commit()
|
|
db.delete(vt)
|
|
db.commit()
|
|
return FileResponse("static/verify-ok.html")
|
|
|
|
|
|
@app.post("/api/auth/resend-verification")
|
|
async def resend_verification(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
if user.email_verified:
|
|
return {"ok": True, "message": "Already verified"}
|
|
vtoken = create_verification_token(db, user.id)
|
|
link = f"{APP_URL}/api/auth/verify?token={vtoken}"
|
|
html = verification_email_html(user.username, link)
|
|
import threading
|
|
threading.Thread(target=send_email, args=(user.email, f"Verify your {APP_NAME} account", html), daemon=True).start()
|
|
return {"ok": True}
|
|
|
|
|
|
@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, "email_verified": user.email_verified,
|
|
}
|
|
|
|
|
|
# ── 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}
|
|
|
|
|
|
# ── USER SETTINGS ROUTES ─────────────────────────────────────────────
|
|
@app.get("/api/user/settings")
|
|
async def get_user_settings(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
return {
|
|
"nc_url": get_user_setting(db, user.id, "nc_url"),
|
|
"nc_user": get_user_setting(db, user.id, "nc_user"),
|
|
# Don't return password
|
|
}
|
|
|
|
|
|
@app.post("/api/user/settings")
|
|
async def save_user_settings(req: UserSettingsRequest,
|
|
user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
|
if req.nc_url is not None: set_user_setting(db, user.id, "nc_url", req.nc_url)
|
|
if req.nc_user is not None: set_user_setting(db, user.id, "nc_user", req.nc_user)
|
|
if req.nc_pass is not None: set_user_setting(db, user.id, "nc_pass", req.nc_pass)
|
|
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, nc_user, nc_pass = resolve_nextcloud(db, user)
|
|
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, nc_user, nc_pass = resolve_nextcloud(db, user)
|
|
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(_: User = Depends(get_current_user)):
|
|
return TTS_MODELS
|
|
|
|
|
|
# ── TTS ──────────────────────────────────────────────────────────────
|
|
@app.post("/api/tts")
|
|
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
|
|
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
resp = await client.post(
|
|
f"{LITELLM_BASE}/v1/audio/speech",
|
|
json=payload,
|
|
headers={"Authorization": f"Bearer {LITELLM_KEY}"},
|
|
)
|
|
if resp.status_code != 200:
|
|
raise HTTPException(status_code=resp.status_code, detail=resp.text)
|
|
|
|
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, _: 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"})
|
|
|
|
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.body
|
|
h = html2text.HTML2Text()
|
|
h.ignore_links = True
|
|
h.ignore_images = True
|
|
h.body_width = 0
|
|
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(...), _: 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 = [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("/settings")
|
|
async def settings_page():
|
|
return FileResponse("static/settings.html")
|
|
|
|
@app.get("/")
|
|
async def root(request: Request):
|
|
return FileResponse("static/index.html")
|
|
|
|
@app.get("/admin")
|
|
async def admin_page():
|
|
return FileResponse("static/admin.html")
|