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 0000000..e6cd1a6 Binary files /dev/null and b/speechify.db differ diff --git a/static/admin.html b/static/admin.html index b5e5297..c61fb45 100644 --- a/static/admin.html +++ b/static/admin.html @@ -5,6 +5,7 @@ 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 +
+ +