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"""
+
+
+
+
+
+
+ |
+
+ |
+
+ |
+ Verify your email
+
+ Hi {username}, thanks for signing up!
+ Click the button below to verify your email address and activate your account.
+
+
+
+ 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
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+