From ee3ffcfe904ca502b69911cf6b485d3dea67a4c3 Mon Sep 17 00:00:00 2001 From: ifedan-ed Date: Sat, 11 Apr 2026 04:12:25 +0200 Subject: [PATCH] Add email verification, user Nextcloud settings, Groq TTS, favicon, and settings page - 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 --- main.py | 221 +++++++++++++++++++++++++++++++++++++--- models.py | 24 ++++- speechify.db | Bin 0 -> 61440 bytes static/admin.html | 1 + static/app.js | 18 ++++ static/favicon.svg | 4 + static/index.html | 9 ++ static/login.html | 1 + static/settings.html | 204 +++++++++++++++++++++++++++++++++++++ static/style.css | 14 +++ static/verify-fail.html | 35 +++++++ static/verify-ok.html | 35 +++++++ 12 files changed, 551 insertions(+), 15 deletions(-) create mode 100644 speechify.db create mode 100644 static/favicon.svg create mode 100644 static/settings.html create mode 100644 static/verify-fail.html create mode 100644 static/verify-ok.html diff --git a/main.py b/main.py index 6461818..5409020 100644 --- a/main.py +++ b/main.py @@ -4,14 +4,25 @@ 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 +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 +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" @@ -144,6 +155,40 @@ TTS_MODELS = { {"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 ───────────────────────────────────────────────────────────── @@ -169,6 +214,76 @@ def startup(): 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""" + + + + +
+ + + + + + + +
+
+ + + + + {APP_NAME} +
+
+

Verify your email

+

+ Hi {username}, thanks for signing up!
+ Click the button below to verify your email address and activate your account. +

+
+ + Verify Email Address + +
+

+ If you didn't create an account, you can safely ignore this email.
+ This link will remain valid as long as your account exists. +

+
+

{APP_NAME} · {APP_URL}

+
+
+""" + + +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: @@ -196,6 +311,28 @@ def set_setting(db: Session, key: str, value: str): 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 @@ -230,6 +367,11 @@ class NextcloudConfigRequest(BaseModel): 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 @@ -258,7 +400,7 @@ async def login(req: LoginRequest, response: Response, request: Request, db: Ses 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} + return {"username": user.username, "role": user.role, "email_verified": user.email_verified} @app.post("/api/auth/register") @@ -277,14 +419,48 @@ async def register(req: RegisterRequest, response: Response, request: Request, d 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} + 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") @@ -295,7 +471,10 @@ async def logout(response: Response): @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} + return { + "id": user.id, "username": user.username, "email": user.email, + "role": user.role, "email_verified": user.email_verified, + } # ── ADMIN ROUTES ───────────────────────────────────────────────────── @@ -422,12 +601,29 @@ async def save_progress(doc_id: str, req: ProgressRequest, 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 = get_setting(db, "nextcloud_url") - nc_user = get_setting(db, "nextcloud_username") - nc_pass = get_setting(db, "nextcloud_password") + nc_url, nc_user, nc_pass = resolve_nextcloud(db, user) if not nc_url: raise HTTPException(status_code=400, detail="Nextcloud not configured") @@ -480,9 +676,7 @@ async def list_nextcloud_files(path: str = "/", user: User = Depends(get_current @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") + nc_url, nc_user, nc_pass = resolve_nextcloud(db, user) if not nc_url: raise HTTPException(status_code=400, detail="Nextcloud not configured") @@ -582,13 +776,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static") 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): - # Let the frontend handle auth redirect via /api/auth/me return FileResponse("static/index.html") - @app.get("/admin") async def admin_page(): return FileResponse("static/admin.html") diff --git a/models.py b/models.py index 390c616..e5a9df9 100644 --- a/models.py +++ b/models.py @@ -18,8 +18,17 @@ class User(Base): 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" + role = Column(String(10), default="user") is_active = Column(Boolean, default=True) + email_verified = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + + +class VerificationToken(Base): + __tablename__ = "verification_tokens" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + token = Column(String(64), unique=True, nullable=False, index=True) created_at = Column(DateTime, default=datetime.utcnow) @@ -29,7 +38,7 @@ class Document(Base): 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 + source_type = Column(String(20)) created_at = Column(DateTime, default=datetime.utcnow) @@ -44,11 +53,22 @@ class Progress(Base): class Setting(Base): + """Global admin settings (key/value)""" __tablename__ = "settings" key = Column(String(100), primary_key=True) value = Column(Text) +class UserSetting(Base): + """Per-user settings (key/value)""" + __tablename__ = "user_settings" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + key = Column(String(100), nullable=False) + value = Column(Text) + __table_args__ = (UniqueConstraint("user_id", "key", name="uq_user_setting"),) + + def get_db(): db = SessionLocal() try: diff --git a/speechify.db b/speechify.db new file mode 100644 index 0000000000000000000000000000000000000000..e6cd1a65de8d376ad96d6e39e2b41b1136743dec GIT binary patch literal 61440 zcmeI*Z*SW~9KdnA*^(w}nh6PvK+~)-)JEIdI9<9w0`R;_YqDt*NG$qVP3+uV?|h%%`SRz~ z^!l|m+q2X=PODM%)CK9dB+JqjRh1+u>EBZRE!-0R!IAKRznA-tM;#`mxt;Hl(?3bm zQBR6`sb8nRn0_beB>$Y!k{={~n3Ce($K}|MlfR6rfOG^9KmdX95jfD~n4&21H+ipm zyKc2ztL2Kji9%V=S9G`N~X+M_rmHft9Ma=}i zc(uH`o-f~0Kh3`k4?oCeL>zoBMRHQ-C)(Kp6xUZ&$(+g-OjEP#es}uSG(Jf)#9># zQ+-|?YN@CWsYjg^4W;)V9*Zdp3-Y7)MVN<_9FjCO5b_}fUNP|ElzM?*JU@6{3?1&n z2lLB>@}M4%DJM?I-<}Zy+bw6aWw~y+PYm=mOnt=;noh0Vu$o>l^214FVR5jVuAgr; zYnCB~M>r7$L%qFi9=_g(0}QzW{I(B|{=O4>$!j1up?>E6n) zqpqCL*U-YFV+WJ5m~!%@{KydT>DAXem>h^uFS#3{QOw;dJoFuy$35HYn}T!xJRbD6 z<^+N9dNYsfv|E0dyuIzG({;96blA*1JW~CJFPe3<$*7{A9A($*xuNwAjlmG>0+RyZvN8m@n(i2c#-nf4~U zFgKXr#IYD(pGTs~ydpmwtRu(|p14K1Bi)*wok2SlhDH0T4U^KTzvVZ^=S9)K(UbH< zRQXUDH6S~p;_1(g_{~iQO$KlB<>pL)`KsBlo8s<@yKPyuE&I;iT-~Wv>!-7~PixuJrHhrNl@+Hpw^?d@wr(xWXKT5V zbJQ2PVM71`1Q0*~0R#|0009ILKmdW)Tj1@;h4|2q7CP_$A4{pnuXhJI zdjt?b009ILKmY**5I_I{1Q0kRFd0!gp9gf_|38sZPY#tK0RaRMKmY**5I_I{1Q0*~ z0R+ZCAQFkkgTMcuNc}9Oe)I3x5I_I{1Q0*~0R#|0009ILK;VBU@a9BBOE#@tuU2>3 zrud@U;ENU0a%ASCzT{SY%k#F~%k%T1+?-i$?$|YVuI4m?_5X>~?^5b7|Bej-1Q0*~ z0R#|0009ILKmY**UMqpOBAVRy2|)KhE=(Mc%*6YWTlHZ5-~Xq7YzQEL00IagfB*sr zAbSz}@Bha*z;q1(1Q0*~0R#|0009ILKmY+>fc1Zd0RjjhfB*srAbwd400IagfB*srAb Speechify — Admin + + + +
+ +
+ ← Back to App + +
+
+ +
+

Account Settings

+

Manage your personal preferences.

+ + +
+ ⚠️ Your email address is not verified. Check your inbox or resend the link. + +
+ + +
+

Account

+
+ + +
+
+ + +
+
+
+
+ + +
+

Nextcloud Integration

+

+ Connect your personal Nextcloud to browse and read PDFs from the Cloud tab. + Leave blank to use the server-wide default configured by the admin. +

+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + + + diff --git a/static/style.css b/static/style.css index 0ef632b..56c6902 100644 --- a/static/style.css +++ b/static/style.css @@ -66,6 +66,20 @@ body { .dd-item:hover { background: var(--bg3); color: var(--text); } .dd-item.hidden { display: none; } +/* ── VERIFICATION BANNER ─────────────────── */ +.verify-banner { + display: none; align-items: center; gap: 12px; flex-shrink: 0; + background: rgba(245,158,11,.1); border-bottom: 1px solid rgba(245,158,11,.2); + padding: 10px 20px; font-size: 13px; color: #fbbf24; +} +.verify-banner.show { display: flex; } +.verify-banner button { + background: rgba(245,158,11,.2); border: 1px solid rgba(245,158,11,.3); color: #fbbf24; + border-radius: 6px; padding: 4px 10px; font-size: 12px; font-family: inherit; cursor: pointer; white-space: nowrap; +} +.verify-banner .close-btn { margin-left: auto; background: none; border: none; opacity: .6; } +.verify-banner .close-btn:hover { opacity: 1; } + /* ── MAIN LAYOUT ──────────────────────────── */ .app { display: flex; flex-direction: column; height: 100vh; } .main-layout { diff --git a/static/verify-fail.html b/static/verify-fail.html new file mode 100644 index 0000000..71f9cb8 --- /dev/null +++ b/static/verify-fail.html @@ -0,0 +1,35 @@ + + + + + + Invalid Link — Speechify + + + + +
+
+ + + +
+

Invalid link

+

This verification link is invalid or has already been used. Sign in and request a new one from your settings.

+ Sign In +
+ + diff --git a/static/verify-ok.html b/static/verify-ok.html new file mode 100644 index 0000000..fb5cfb4 --- /dev/null +++ b/static/verify-ok.html @@ -0,0 +1,35 @@ + + + + + + Email Verified — Speechify + + + + +
+
+ + + +
+

Email verified!

+

Your email address has been confirmed. You can now use all features of Speechify.

+ Go to App +
+ +