first commit
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
const dbPath = path.join(dataDir, 'support.sqlite3');
|
||||
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
function initDb(callback) {
|
||||
db.serialize(() => {
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.run(
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT,
|
||||
google_id TEXT UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
verify_token TEXT,
|
||||
verify_token_expires TEXT,
|
||||
banned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT
|
||||
)`
|
||||
);
|
||||
|
||||
db.all(`PRAGMA table_info(users)`, (tiErr, cols) => {
|
||||
if (!tiErr) {
|
||||
const hasBanned = Array.isArray(cols) && cols.some(c => c.name === 'banned');
|
||||
const hasEmailVerified = Array.isArray(cols) && cols.some(c => c.name === 'email_verified');
|
||||
const hasVerifyToken = Array.isArray(cols) && cols.some(c => c.name === 'verify_token');
|
||||
const hasVerifyTokenExpires = Array.isArray(cols) && cols.some(c => c.name === 'verify_token_expires');
|
||||
if (!hasBanned) {
|
||||
db.run(`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`);
|
||||
}
|
||||
if (!hasEmailVerified) {
|
||||
db.run(`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
|
||||
}
|
||||
if (!hasVerifyToken) {
|
||||
db.run(`ALTER TABLE users ADD COLUMN verify_token TEXT`);
|
||||
}
|
||||
if (!hasVerifyTokenExpires) {
|
||||
db.run(`ALTER TABLE users ADD COLUMN verify_token_expires TEXT`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helpful index for verification lookups
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_users_verify_token ON users(verify_token)');
|
||||
|
||||
db.run(
|
||||
`CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`
|
||||
);
|
||||
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_tickets_user_id ON tickets(user_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status)');
|
||||
|
||||
db.run(
|
||||
`CREATE TABLE IF NOT EXISTS ticket_responses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
is_admin_response INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`
|
||||
);
|
||||
|
||||
if (typeof callback === 'function') callback();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { db, initDb };
|
||||
@@ -0,0 +1,118 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const https = require('https');
|
||||
|
||||
const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || '';
|
||||
|
||||
function buildTransport() {
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : undefined;
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
if (host && user && pass) {
|
||||
return nodemailer.createTransport({
|
||||
host,
|
||||
port: port || 587,
|
||||
secure: Boolean(process.env.SMTP_SECURE === '1' || process.env.SMTP_SECURE === 'true'),
|
||||
auth: { user, pass },
|
||||
});
|
||||
}
|
||||
return null; // fallback to console logging
|
||||
}
|
||||
|
||||
const transporter = buildTransport();
|
||||
|
||||
async function sendVerificationEmail(toEmail, token, name) {
|
||||
const verifyUrl = `${APP_BASE_URL}/verify/${token}`;
|
||||
const from = process.env.SMTP_FROM || 'no-reply@ticketsupport.local';
|
||||
const subject = 'Verify your email address';
|
||||
const text = `Hi${name ? ' ' + name : ''},\n\nPlease verify your email to activate your account.\n\nVerify link: ${verifyUrl}\n\nIf you did not create an account, you can ignore this email.`;
|
||||
const html = `
|
||||
<p>Hi${name ? ' ' + escapeHtml(name) : ''},</p>
|
||||
<p>Please verify your email to activate your account.</p>
|
||||
<p><a href="${verifyUrl}">Verify your email</a></p>
|
||||
<p>If the button above does not work, copy and paste this URL into your browser:</p>
|
||||
<p><code>${verifyUrl}</code></p>
|
||||
<hr/>
|
||||
<p>If you did not create an account, you can ignore this email.</p>
|
||||
`;
|
||||
|
||||
if (transporter) {
|
||||
await transporter.sendMail({ from, to: toEmail, subject, text, html });
|
||||
return; // Prefer SMTP when configured
|
||||
}
|
||||
if (DISCORD_WEBHOOK_URL) {
|
||||
try {
|
||||
await sendToDiscordWebhook(DISCORD_WEBHOOK_URL, {
|
||||
verifyUrl,
|
||||
toEmail,
|
||||
name,
|
||||
});
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error('Failed to send verification to Discord webhook:', e.message);
|
||||
}
|
||||
}
|
||||
// Final fallback: log the link to the server console
|
||||
console.log('[Email Verification] No SMTP/Discord configured. Share this link with the user:', verifyUrl);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function sendToDiscordWebhook(webhookUrl, details) {
|
||||
const { verifyUrl, toEmail, name } = details || {};
|
||||
const payload = {
|
||||
username: 'TicketSupport',
|
||||
avatar_url: 'https://raw.githubusercontent.com/twitter/twemoji/master/assets/72x72/2709.png',
|
||||
content: `Email verification${toEmail ? ` for ${toEmail}` : ''}`,
|
||||
embeds: [
|
||||
{
|
||||
title: 'Verify your email',
|
||||
description: `[Click here to verify](${verifyUrl})`,
|
||||
color: 0x5865f2,
|
||||
fields: [
|
||||
toEmail ? { name: 'Email', value: String(toEmail), inline: true } : undefined,
|
||||
name ? { name: 'Name', value: String(name), inline: true } : undefined,
|
||||
{ name: 'Link', value: verifyUrl },
|
||||
].filter(Boolean),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const data = JSON.stringify(payload);
|
||||
const u = new URL(webhookUrl);
|
||||
const options = {
|
||||
method: 'POST',
|
||||
hostname: u.hostname,
|
||||
path: `${u.pathname}${u.search || ''}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
},
|
||||
};
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = https.request(options, (res) => {
|
||||
// Drain response to free memory
|
||||
let body = '';
|
||||
res.on('data', (chunk) => (body += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) return resolve();
|
||||
return reject(new Error(`HTTP ${res.statusCode}: ${body}`));
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { sendVerificationEmail, APP_BASE_URL };
|
||||
Reference in New Issue
Block a user