fix email verification

This commit is contained in:
Kohaku-Blueleaf
2025-10-05 03:29:32 +08:00
parent 84ecb94474
commit 75eb9725ac
4 changed files with 199 additions and 23 deletions

View File

@@ -13,51 +13,63 @@ server {
# API PROXY RULES
#
# These specific locations are evaluated BEFORE the general SPA rule.
# The order of these proxy rules matters.
# The order of these proxy rules matters - most specific first.
# =================================================================
# 1. Standard API prefixes (highest priority for matching)
# 1. API endpoints (all routes under /api/)
# Covers: /api/auth/*, /api/repos/*, /api/models/*, /api/datasets/*,
# /api/spaces/*, /api/users/*, /api/organizations/*, /api/whoami-v2, etc.
location /api/ {
proxy_pass http://hub-api:48888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 2. Organization API endpoints (mounted at /org/)
# Covers: /org/create, /org/{name}, /org/{name}/members, /org/users/{username}/orgs
location /org/ {
proxy_pass http://hub-api:48888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 2. Git LFS routes (critical for large file uploads/downloads)
# Matches URLs like /kohaku/test-2.git/info/lfs/objects/batch
# 3. Git LFS endpoints
# Covers: /{type}s/{namespace}/{name}.git/info/lfs/* and /{namespace}/{name}.git/info/lfs/*
location ~ \.git/info/lfs/ {
proxy_pass http://hub-api:48888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Important for large file uploads
proxy_request_buffering off;
client_body_buffer_size 128k;
}
# 3. File resolution routes (for downloads)
# This is the most specific pattern and should be checked first.
# Matches /models/user/repo/resolve/...
# 4. Public file resolution routes (no /api prefix)
# These are public-facing download endpoints
# Pattern: /{type}s/{namespace}/{name}/resolve/{revision}/{path}
location ~ ^/(models|datasets|spaces)/[^/]+/[^/]+/resolve/ {
proxy_pass http://hub-api:48888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# This is the legacy pattern.
# Matches /user/repo/resolve/...
# By placing it after the more specific rule above, we avoid conflicts.
# 5. Legacy public file resolution route (no /api prefix, no type prefix)
# Pattern: /{namespace}/{name}/resolve/{revision}/{path}
# Must come AFTER the specific routes to avoid catching frontend routes
location ~ ^/[^/]+/[^/]+/resolve/ {
proxy_pass http://hub-api:48888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
@@ -70,4 +82,4 @@ server {
location / {
try_files $uri $uri/ /index.html;
}
}
}

View File

@@ -208,12 +208,14 @@
<script setup>
import { repoAPI } from "@/utils/api";
import { useAuthStore } from "@/stores/auth";
import { ElMessage } from "element-plus";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const { isAuthenticated } = storeToRefs(authStore);
@@ -269,6 +271,21 @@ async function loadStats() {
}
onMounted(() => {
// Check for verification error messages in query params
if (route.query.error) {
const errorType = route.query.error;
const message = route.query.message || "An error occurred";
if (errorType === "invalid_token") {
ElMessage.error(decodeURIComponent(message));
// Clean up URL
router.replace("/");
} else if (errorType === "user_not_found") {
ElMessage.error("User account not found");
router.replace("/");
}
}
loadStats();
});
</script>

View File

@@ -8,17 +8,27 @@ from ..config import cfg
def send_verification_email(to_email: str, username: str, token: str) -> bool:
"""Send email verification email."""
"""Send email verification email.
Note: HTML emails have limitations:
- Must use inline CSS (no <style> tags or external stylesheets)
- Table-based layouts are most reliable (flexbox/grid not supported)
- Limited CSS properties (many modern CSS features don't work)
- Different email clients render differently (Outlook, Gmail, Apple Mail, etc.)
- Must use absolute URLs for images
- Background images often blocked
"""
if not cfg.smtp.enabled:
print(
f"[EMAIL] SMTP disabled. Verification link: {cfg.app.base_url}/auth/verify?token={token}"
f"[EMAIL] SMTP disabled. Verification link: {cfg.app.base_url}/api/auth/verify-email?token={token}"
)
return True
subject = "Verify your Kohaku Hub account"
verify_link = f"{cfg.app.base_url}/auth/verify?token={token}"
verify_link = f"{cfg.app.base_url}/api/auth/verify-email?token={token}"
body = f"""
# Plain text version (fallback)
text_body = f"""
Hello {username},
Please verify your email address by clicking the link below:
@@ -30,15 +40,121 @@ This link will expire in 24 hours.
If you didn't create this account, please ignore this email.
Best regards,
Kohaku Hub
Kohaku Hub Team
"""
# HTML version (styled with table-based layout and inline CSS)
html_body = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<!-- Main container table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f5f5f5;">
<tr>
<td style="padding: 40px 20px;">
<!-- Content card -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<tr>
<td style="padding: 40px 40px 20px 40px; text-align: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px 8px 0 0;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">
🎉 Welcome to Kohaku Hub!
</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 1.6; color: #333333;">
Hello <strong>{username}</strong>,
</p>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 1.6; color: #555555;">
Thank you for signing up! Please verify your email address to activate your account and start using Kohaku Hub.
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 20px 0;">
<a href="{verify_link}" style="display: inline-block; padding: 16px 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 6px rgba(102, 126, 234, 0.4);">
✉️ Verify Email Address
</a>
</td>
</tr>
</table>
<p style="margin: 30px 0 20px 0; font-size: 14px; line-height: 1.6; color: #666666;">
Or copy and paste this link into your browser:
</p>
<p style="margin: 0 0 30px 0; padding: 12px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; font-size: 13px; word-break: break-all; color: #555555;">
{verify_link}
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="border-top: 1px solid #e5e7eb; margin-top: 30px; padding-top: 20px;">
<tr>
<td style="padding: 15px; background-color: #fef3c7; border-radius: 6px; border-left: 4px solid #f59e0b;">
<p style="margin: 0; font-size: 14px; color: #92400e; line-height: 1.5;">
⏰ <strong>Important:</strong> This link will expire in 24 hours.
</p>
</td>
</tr>
</table>
<p style="margin: 30px 0 0 0; font-size: 14px; line-height: 1.6; color: #999999;">
If you didn't create this account, you can safely ignore this email.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 30px 40px; background-color: #f8f9fa; border-radius: 0 0 8px 8px; text-align: center;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
Best regards,<br>
<strong>The Kohaku Hub Team</strong>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
This is an automated email, please do not reply.
</p>
</td>
</tr>
</table>
<!-- Disclaimer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 20px auto 0 auto;">
<tr>
<td style="padding: 20px; text-align: center; font-size: 12px; color: #999999; line-height: 1.5;">
© 2025 Kohaku Hub. All rights reserved.<br>
You received this email because you registered an account.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
try:
msg = MIMEMultipart()
msg = MIMEMultipart("alternative")
msg["From"] = cfg.smtp.from_email
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
# Attach both plain text and HTML versions
# Email clients will choose which to display (preferring HTML if supported)
part1 = MIMEText(text_body, "plain")
part2 = MIMEText(html_body, "html")
msg.attach(part1)
msg.attach(part2)
with smtplib.SMTP(cfg.smtp.host, cfg.smtp.port) as server:
if cfg.smtp.use_tls:

View File

@@ -84,8 +84,9 @@ def register(req: RegisterRequest):
@router.get("/verify-email")
def verify_email(token: str):
"""Verify email with token."""
def verify_email(token: str, response: Response):
"""Verify email with token and automatically log in user."""
from fastapi.responses import RedirectResponse
verification = EmailVerification.get_or_none(
(EmailVerification.token == token)
@@ -93,15 +94,45 @@ def verify_email(token: str):
)
if not verification:
raise HTTPException(400, detail="Invalid or expired verification token")
# Redirect to login with error message
return RedirectResponse(
url=f"/?error=invalid_token&message=Invalid+or+expired+verification+token",
status_code=302,
)
# Update user
# Get user
user = User.get_or_none(User.id == verification.user)
if not user:
return RedirectResponse(url="/?error=user_not_found", status_code=302)
# Update user email verification status
User.update(email_verified=True).where(User.id == verification.user).execute()
# Delete verification token
EmailVerification.delete().where(EmailVerification.id == verification.id).execute()
return {"success": True, "message": "Email verified successfully"}
# Create session for auto-login
session_id = generate_token()
session_secret = generate_session_secret()
Session.create(
session_id=session_id,
user_id=user.id,
secret=session_secret,
expires_at=get_expiry_time(cfg.auth.session_expire_hours),
)
# Set session cookie
response = RedirectResponse(url=f"/{user.username}", status_code=302)
response.set_cookie(
key="session_id",
value=session_id,
httponly=True,
max_age=cfg.auth.session_expire_hours * 3600,
samesite="lax",
)
return response
@router.post("/login")