mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
feat(syncServer): add SMTP email verification for user registration
This commit is contained in:
parent
93c76d5cdb
commit
6308e33a56
10 changed files with 732 additions and 2 deletions
|
|
@ -23,3 +23,11 @@ CORS_ENABLED=true
|
|||
# Allowed CORS origins (comma-separated, default: *)
|
||||
# Use "*" to allow all origins, or specify domains like "https://example.com"
|
||||
CORS_ORIGINS=*
|
||||
|
||||
# Email
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
SMTP_FROM="SuperSync <your-email@gmail.com>"
|
||||
|
|
|
|||
1
packages/super-sync-server/.gitignore
vendored
1
packages/super-sync-server/.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
data
|
||||
./.env
|
||||
.env
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"dotenv": "^17.2.3",
|
||||
"fastify": "^5.6.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.11",
|
||||
"webdav-server": "^2.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -22,6 +24,7 @@
|
|||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^18.0.0",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
|
|
|
|||
136
packages/super-sync-server/public/app.js
Normal file
136
packages/super-sync-server/public/app.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const registerForm = document.getElementById('register-form');
|
||||
const messageArea = document.getElementById('message-area');
|
||||
|
||||
// Tab Switching
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.forEach((t) => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
|
||||
const target = tab.dataset.tab;
|
||||
if (target === 'login') {
|
||||
loginForm.classList.remove('hidden');
|
||||
registerForm.classList.add('hidden');
|
||||
} else {
|
||||
loginForm.classList.add('hidden');
|
||||
registerForm.classList.remove('hidden');
|
||||
}
|
||||
clearMessage();
|
||||
});
|
||||
});
|
||||
|
||||
// Login Handler
|
||||
document.getElementById('login').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(e.target, true);
|
||||
clearMessage();
|
||||
|
||||
const email = document.getElementById('login-email').value;
|
||||
const password = document.getElementById('login-password').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showMessage(
|
||||
'Login successful! You can now use these credentials in Super Productivity.',
|
||||
'success',
|
||||
);
|
||||
// Optional: Store token if needed for future admin features
|
||||
// localStorage.setItem('token', data.token);
|
||||
} else {
|
||||
showMessage(data.error || 'Login failed', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage('Network error occurred', 'error');
|
||||
} finally {
|
||||
setLoading(e.target, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Register Handler
|
||||
document.getElementById('register').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(e.target, true);
|
||||
clearMessage();
|
||||
|
||||
const email = document.getElementById('register-email').value;
|
||||
const password = document.getElementById('register-password').value;
|
||||
const confirmPassword = document.getElementById('register-confirm-password').value;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showMessage('Passwords do not match', 'error');
|
||||
setLoading(e.target, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
showMessage('Password must be at least 6 characters', 'error');
|
||||
setLoading(e.target, false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showMessage(
|
||||
'Registration successful! Please check your server logs for the verification link (dev mode) or verify if email configured.',
|
||||
'success',
|
||||
);
|
||||
// Switch to login tab after brief delay
|
||||
setTimeout(() => {
|
||||
tabs[0].click();
|
||||
}, 2000);
|
||||
} else {
|
||||
showMessage(data.error || 'Registration failed', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage('Network error occurred', 'error');
|
||||
} finally {
|
||||
setLoading(e.target, false);
|
||||
}
|
||||
});
|
||||
|
||||
function showMessage(text, type) {
|
||||
messageArea.textContent = text;
|
||||
messageArea.className = `message ${type}`;
|
||||
messageArea.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearMessage() {
|
||||
messageArea.classList.add('hidden');
|
||||
messageArea.textContent = '';
|
||||
}
|
||||
|
||||
function setLoading(form, isLoading) {
|
||||
const btn = form.querySelector('button');
|
||||
const text = btn.querySelector('.btn-text');
|
||||
const loader = btn.querySelector('.loader');
|
||||
|
||||
if (isLoading) {
|
||||
text.classList.add('hidden');
|
||||
loader.classList.remove('hidden');
|
||||
btn.disabled = true;
|
||||
} else {
|
||||
text.classList.remove('hidden');
|
||||
loader.classList.add('hidden');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
176
packages/super-sync-server/public/index.html
Normal file
176
packages/super-sync-server/public/index.html
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<title>SuperSync Server</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="style.css"
|
||||
/>
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.googleapis.com"
|
||||
/>
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="background-gradient"></div>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="logo">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 4L12 20M12 4L8 8M12 4L16 8"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 12L20 12M4 12L8 8M4 12L8 16"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>SuperSync Server</span>
|
||||
</div>
|
||||
<div class="status-indicator">
|
||||
<span class="dot"></span>
|
||||
<span class="text">Server Online</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<h1>Your Data.<br />Synchronized.</h1>
|
||||
<p>Secure, private, and fast synchronization for Super Productivity.</p>
|
||||
</section>
|
||||
|
||||
<section class="auth-card">
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab active"
|
||||
data-tab="login"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
data-tab="register"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="form-container"
|
||||
id="login-form"
|
||||
>
|
||||
<form id="login">
|
||||
<div class="input-group">
|
||||
<label for="login-email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="login-email"
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="login-password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="login-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
>
|
||||
<span class="btn-text">Log In</span>
|
||||
<div class="loader hidden"></div>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="form-container hidden"
|
||||
id="register-form"
|
||||
>
|
||||
<form id="register">
|
||||
<div class="input-group">
|
||||
<label for="register-email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="register-email"
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="register-password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="register-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<small>Minimum 6 characters</small>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="register-confirm-password">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="register-confirm-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
>
|
||||
<span class="btn-text">Create Account</span>
|
||||
<div class="loader hidden"></div>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="message-area"
|
||||
class="message hidden"
|
||||
></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 Super Productivity. Open Source.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
281
packages/super-sync-server/public/style.css
Normal file
281
packages/super-sync-server/public/style.css
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
:root {
|
||||
--bg-color: #0f172a;
|
||||
--text-color: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--primary-color: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--card-bg: rgba(30, 41, 59, 0.7);
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--success-color: #10b981;
|
||||
--error-color: #ef4444;
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.background-gradient {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at 15% 50%, rgba(59, 130, 246, 0.15), transparent 25%),
|
||||
radial-gradient(circle at 85% 30%, rgba(16, 185, 129, 0.15), transparent 25%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--success-color);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--success-color);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px var(--success-color);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 4rem;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(to right, #fff, #94a3b8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 1.5rem;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
margin-bottom: 2rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
padding: 0.75rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-color);
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.input-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--primary-color);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
/* Loader */
|
||||
.loader {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #ffffff;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import * as bcrypt from 'bcryptjs';
|
|||
import * as jwt from 'jsonwebtoken';
|
||||
import { Logger } from './logger';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { sendVerificationEmail } from './email';
|
||||
|
||||
const getJwtSecret = (): string => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
|
|
@ -55,12 +56,16 @@ export const registerUser = (email: string, password: string) => {
|
|||
.run(email, passwordHash, verificationToken);
|
||||
|
||||
Logger.info(`User registered: ${email} (ID: ${info.lastInsertRowid})`);
|
||||
Logger.info(`Verification token for ${email}: ${verificationToken}`); // In real app, send email
|
||||
|
||||
// Send verification email asynchronously
|
||||
sendVerificationEmail(email, verificationToken).catch((err) => {
|
||||
Logger.error(`Failed to send verification email to ${email}:`, err);
|
||||
});
|
||||
|
||||
return {
|
||||
id: info.lastInsertRowid,
|
||||
email,
|
||||
verificationToken, // Return it for now since we don't have email sending
|
||||
// verificationToken, // Don't return token in response for security, user must check email
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ export interface ServerConfig {
|
|||
enabled: boolean;
|
||||
allowedOrigins?: string[];
|
||||
};
|
||||
smtp?: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
from: string;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ServerConfig = {
|
||||
|
|
@ -67,6 +75,18 @@ export const loadConfigFromEnv = (
|
|||
}
|
||||
}
|
||||
|
||||
// SMTP Configuration
|
||||
if (process.env.SMTP_HOST) {
|
||||
config.smtp = {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
from: process.env.SMTP_FROM || '"SuperSync" <noreply@example.com>',
|
||||
};
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!Number.isInteger(config.port) || config.port <= 0) {
|
||||
throw new Error(`Invalid port configuration: ${config.port}`);
|
||||
|
|
|
|||
84
packages/super-sync-server/src/email.ts
Normal file
84
packages/super-sync-server/src/email.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import * as nodemailer from 'nodemailer';
|
||||
import { Logger } from './logger';
|
||||
import { loadConfigFromEnv } from './config';
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
const getTransporter = async () => {
|
||||
if (transporter) return transporter;
|
||||
|
||||
const config = loadConfigFromEnv();
|
||||
|
||||
if (config.smtp) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.smtp.host,
|
||||
port: config.smtp.port,
|
||||
secure: config.smtp.secure,
|
||||
auth: config.smtp.user
|
||||
? {
|
||||
user: config.smtp.user,
|
||||
pass: config.smtp.pass,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
Logger.info(`SMTP configured: ${config.smtp.host}:${config.smtp.port}`);
|
||||
} else {
|
||||
// Fallback to Ethereal for development if no SMTP config
|
||||
Logger.warn('No SMTP configuration found. Using Ethereal Email for testing.');
|
||||
const testAccount = await nodemailer.createTestAccount();
|
||||
transporter = nodemailer.createTransport({
|
||||
host: 'smtp.ethereal.email',
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: testAccount.user,
|
||||
pass: testAccount.pass,
|
||||
},
|
||||
});
|
||||
Logger.info(`Ethereal Email configured: ${testAccount.user}`);
|
||||
}
|
||||
|
||||
return transporter;
|
||||
};
|
||||
|
||||
export const sendVerificationEmail = async (to: string, token: string) => {
|
||||
try {
|
||||
const transporter = await getTransporter();
|
||||
const config = loadConfigFromEnv();
|
||||
const from = config.smtp?.from || '"SuperSync" <noreply@example.com>';
|
||||
|
||||
// In a real app, this URL should be configurable
|
||||
// For now, we assume the server is running on the same host
|
||||
// or we can use a configured PUBLIC_URL
|
||||
const verificationLink = `http://localhost:${config.port}/verify-email?token=${token}`;
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: 'Verify your SuperSync account',
|
||||
text: `Please verify your account by clicking the following link: ${verificationLink}\n\nToken: ${token}`,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2>Welcome to SuperSync!</h2>
|
||||
<p>Please verify your account by clicking the button below:</p>
|
||||
<a href="${verificationLink}" style="display: inline-block; padding: 10px 20px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 5px;">Verify Email</a>
|
||||
<p style="margin-top: 20px; font-size: 12px; color: #666;">If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="font-size: 12px; color: #666;">${verificationLink}</p>
|
||||
<p style="font-size: 12px; color: #666;">Token: ${token}</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
Logger.info(`Verification email sent to ${to}: ${info.messageId}`);
|
||||
|
||||
// If using Ethereal, log the preview URL
|
||||
if (nodemailer.getTestMessageUrl(info)) {
|
||||
Logger.info(`Preview URL: ${nodemailer.getTestMessageUrl(info)}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
Logger.error('Failed to send verification email:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -2,6 +2,8 @@ import { v2 as webdav } from 'webdav-server';
|
|||
import * as fs from 'fs';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import * as path from 'path';
|
||||
import { loadConfigFromEnv, ServerConfig } from './config';
|
||||
import { Logger } from './logger';
|
||||
import { initDb } from './db';
|
||||
|
|
@ -151,6 +153,12 @@ export const createServer = (
|
|||
});
|
||||
}
|
||||
|
||||
// Serve static files
|
||||
await fastifyServer.register(fastifyStatic, {
|
||||
root: path.join(__dirname, '../public'),
|
||||
prefix: '/',
|
||||
});
|
||||
|
||||
// API Routes
|
||||
await fastifyServer.register(apiRoutes, { prefix: '/api' });
|
||||
|
||||
|
|
@ -162,6 +170,14 @@ export const createServer = (
|
|||
return;
|
||||
}
|
||||
|
||||
// Allow static files to be handled by Fastify
|
||||
const staticFiles = ['/', '/index.html', '/style.css', '/app.js', '/favicon.ico'];
|
||||
const urlPath = req.url.split('?')[0];
|
||||
if (req.method === 'GET' && staticFiles.includes(urlPath)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass Fastify and let WebDAV server handle the raw request
|
||||
reply.hijack();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue