v3: added onboarding email on account creation

This commit is contained in:
khannurien
2026-03-30 14:55:30 +00:00
parent cbb3505139
commit 378b3ffa46
27 changed files with 404 additions and 59 deletions

View File

@@ -2,6 +2,17 @@ export const PROTOCOL = Deno.env.get("GERBEUR_PROTOCOL") || "http";
export const HOSTNAME = Deno.env.get("GERBEUR_HOSTNAME") || "localhost";
export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
export const SMTPS_URL = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || "";
export const FROM_EMAIL = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || "";
const DEFAULT_WELCOME_EMAIL_BODY = `# Welcome to {{site_name}}!
Hi **{{username}}**,
Your account has been created successfully. Welcome aboard!`;
export const WELCOME_EMAIL_BODY =
Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() ||
DEFAULT_WELCOME_EMAIL_BODY;
export const JWT_SECRET = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || "";
// GERBEUR_LISTEN_HOST controls the network interface Oak binds to.
// Defaults to 0.0.0.0 so Docker port-forwarding works out of the box.
@@ -26,7 +37,12 @@ export const ALLOWED_IMAGE_MIMES = new Set([
]);
export const DUMP_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
export const DUMP_ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"];
export const DUMP_ALLOWED_MIME_PREFIXES = [
"text/",
"image/",
"video/",
"audio/",
];
export const DUMP_ALLOWED_MIME_TYPES = new Set([
"application/pdf",
"application/json",
@@ -67,11 +83,13 @@ export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ??
"http://localhost:3000";
export const ALLOWED_ORIGINS: string[] = Array.from(new Set([
BASE_URL,
...(
rawOrigins
? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean)
: []
),
]));
export const ALLOWED_ORIGINS: string[] = Array.from(
new Set([
BASE_URL,
...(
rawOrigins
? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean)
: []
),
]),
);

View File

@@ -1,7 +1,4 @@
import {
PAGINATION_DEFAULT_LIMIT,
PAGINATION_MAX_LIMIT,
} from "../config.ts";
import { PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT } from "../config.ts";
/**
* Parses page/limit query parameters with sensible defaults and bounds.

View File

@@ -46,7 +46,7 @@ if (userCount.count === 0) {
const hash = scryptSync("admin", salt, 64).toString("hex");
const passwordHash = `${hash}.${salt}`;
db.prepare(
`INSERT INTO users (id, username, password_hash, is_admin, created_at) VALUES (?, 'admin', ?, 1, datetime('now'))`,
`INSERT INTO users (id, username, password_hash, is_admin, created_at, email) VALUES (?, 'admin', ?, 1, datetime('now'), 'admin@localhost')`,
).run(crypto.randomUUID(), passwordHash);
console.log("Created default admin user (username: admin, password: admin)");
}
@@ -87,6 +87,7 @@ export interface UserRow {
invited_by: string | null;
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
invited_by_username: string | null;
email: string;
[key: string]: SQLOutputValue; // Index signature
}
@@ -136,7 +137,8 @@ export function isUserRow(obj: unknown): obj is UserRow {
"description" in obj &&
(typeof obj.description === "string" || obj.description === null) &&
"invited_by" in obj &&
(typeof obj.invited_by === "string" || obj.invited_by === null);
(typeof obj.invited_by === "string" || obj.invited_by === null) &&
"email" in obj && typeof obj.email === "string";
}
/**
@@ -200,6 +202,7 @@ export function userRowToApi(row: UserRow): User {
invitedByUsername: typeof row.invited_by_username === "string"
? row.invited_by_username
: undefined,
email: row.email,
};
}
@@ -215,6 +218,7 @@ export function userApiToRow(user: User): UserRow {
description: user.description ?? null,
invited_by: null,
invited_by_username: null,
email: user.email,
};
}

View File

@@ -48,6 +48,7 @@ export interface User {
avatarMime?: string;
description?: string;
invitedByUsername?: string;
email: string;
}
export interface LoginUserRequest {
@@ -59,6 +60,7 @@ export interface RegisterUserRequest {
username: string;
password: string;
inviteToken: string;
email: string;
}
export interface UpdateUserRequest {
@@ -66,6 +68,7 @@ export interface UpdateUserRequest {
password?: string;
isAdmin?: boolean;
description?: string | null;
email?: string;
}
export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
@@ -86,9 +89,13 @@ export function validateRegisterUserRequest(obj: unknown): string | null {
!obj || typeof obj !== "object" ||
!("username" in obj) || typeof obj.username !== "string" ||
!("password" in obj) || typeof obj.password !== "string" ||
!("inviteToken" in obj) || typeof obj.inviteToken !== "string"
!("inviteToken" in obj) || typeof obj.inviteToken !== "string" ||
!("email" in obj) || typeof obj.email !== "string"
) return "Invalid request";
const { username, password } = obj as RegisterUserRequest;
const { username, password, email } = obj as RegisterUserRequest;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return "Invalid email address";
}
if (
!new RegExp(
`^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
@@ -125,6 +132,10 @@ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
"description" in o && typeof o.description !== "string" &&
o.description !== null
) return false;
if ("email" in o) {
if (typeof o.email !== "string") return false;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(o.email as string)) return false;
}
if (
typeof o.description === "string" &&
(o.description as string).length > VALIDATION.USER_DESCRIPTION_MAX
@@ -517,7 +528,7 @@ export interface PlaylistDumpsUpdatedMessage {
export interface UserUpdatedMessage {
type: "user_updated";
user: Omit<User, "passwordHash">;
user: Omit<User, "passwordHash" | "email">;
}
export interface CommentCreatedMessage {

View File

@@ -37,7 +37,11 @@ router.get("/", async (ctx) => {
ctx.response.body = {
success: true,
data: {
dumps: { items: dumpItems, total: dumpTotal, hasMore: page * limit < dumpTotal },
dumps: {
items: dumpItems,
total: dumpTotal,
hasMore: page * limit < dumpTotal,
},
users,
playlists,
},

View File

@@ -21,6 +21,9 @@ import {
updateUser,
} from "../services/user-service.ts";
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
import { isEmailEnabled, sendEmail } from "../services/email-service.ts";
import { FROM_EMAIL, OG_SITE_NAME, WELCOME_EMAIL_BODY } from "../config.ts";
import { marked } from "marked";
import { broadcastUserUpdated } from "../services/ws-service.ts";
import {
getDumpsByUser,
@@ -53,6 +56,20 @@ router.post("/register", async (ctx) => {
console.error("[register] redeemInvite failed (user created):", err);
}
// Send welcome email (fire-and-forget)
if (isEmailEnabled()) {
const emailMarkdown = WELCOME_EMAIL_BODY
.replaceAll("{{username}}", user.username)
.replaceAll("{{site_name}}", OG_SITE_NAME);
sendEmail({
from: FROM_EMAIL,
to: user.email,
subject: `Welcome to ${OG_SITE_NAME}`,
text: emailMarkdown,
html: await marked(emailMarkdown),
}).catch((err) => console.error("[register] welcome email failed:", err));
}
const authToken = await createJWT({
userId: user.id,
username: user.username,
@@ -153,7 +170,7 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
);
}
const updated = await updateUser(ctx.state.user.userId, body);
const { passwordHash: _, ...publicUser } = updated;
const { passwordHash: _, email: _email, ...publicUser } = updated;
broadcastUserUpdated(publicUser);
ctx.response.body = { success: true, data: publicUser };
});
@@ -168,7 +185,7 @@ router.get("/search", (ctx) => {
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
router.get("/by-id/:userId", (ctx) => {
const user = getUserById(ctx.params.userId);
const { passwordHash: _, ...publicUser } = user;
const { passwordHash: _, email: _email, ...publicUser } = user;
ctx.response.body = { success: true, data: publicUser };
});
@@ -211,7 +228,7 @@ router.get("/:username/playlists", async (ctx) => {
// Public user profile by username (no passwordHash)
router.get("/:username", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const { passwordHash: _, ...publicUser } = user;
const { passwordHash: _, email: _email, ...publicUser } = user;
ctx.response.body = { success: true, data: publicUser };
});

View File

@@ -226,7 +226,10 @@ export function searchDumps(
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
).get(requestingUserId, ...searchParams) as { count: number } | undefined;
return { items: rows.filter(isDumpRow).map(dumpRowToApi), total: totalRow?.count ?? 0 };
return {
items: rows.filter(isDumpRow).map(dumpRowToApi),
total: totalRow?.count ?? 0,
};
} else {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
@@ -234,7 +237,10 @@ export function searchDumps(
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
).get(...searchParams) as { count: number } | undefined;
return { items: rows.filter(isDumpRow).map(dumpRowToApi), total: totalRow?.count ?? 0 };
return {
items: rows.filter(isDumpRow).map(dumpRowToApi),
total: totalRow?.count ?? 0,
};
}
}

View File

@@ -59,7 +59,9 @@ export async function verifyEmailTransport(): Promise<boolean> {
return true;
}
export async function sendEmail(message: EmailMessage): Promise<EmailSendResult> {
export async function sendEmail(
message: EmailMessage,
): Promise<EmailSendResult> {
if (normalizeRecipients(message.to).length === 0) {
throw new Error("Email recipient is required.");
}

View File

@@ -56,8 +56,9 @@ async function fetchOEmbed(
url: string,
): Promise<{ title?: string; thumbnailUrl?: string }> {
try {
const oembedUrl =
`https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
const oembedUrl = `https://www.youtube.com/oembed?url=${
encodeURIComponent(url)
}&format=json`;
const res = await fetchWithTimeout(oembedUrl);
if (res.ok) {
const data = await res.json() as {
@@ -122,7 +123,8 @@ export const youtubeProvider: RichContentProvider = {
url,
title,
thumbnailUrl,
embedUrl: `https://www.youtube.com/embed/videoseries?list=${listId}&rel=0`,
embedUrl:
`https://www.youtube.com/embed/videoseries?list=${listId}&rel=0`,
};
}

View File

@@ -12,7 +12,7 @@ import { hashPassword } from "../lib/jwt.ts";
import { linkAttachments } from "./attachment-service.ts";
const USER_SELECT =
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by,
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email,
i.username as invited_by_username
FROM users u
LEFT JOIN users i ON i.id = u.invited_by`;
@@ -39,8 +39,8 @@ export async function createUser(
const passwordHash = await hashPassword(request.password);
db.prepare(
`INSERT INTO users (id, username, password_hash, is_admin, created_at, invited_by)
VALUES (?, ?, ?, ?, ?, ?);`,
`INSERT INTO users (id, username, password_hash, is_admin, created_at, invited_by, email)
VALUES (?, ?, ?, ?, ?, ?, ?);`,
).run(
userId,
request.username,
@@ -48,6 +48,7 @@ export async function createUser(
0,
createdAt.toISOString(),
inviterId,
request.email,
);
return {
@@ -56,6 +57,7 @@ export async function createUser(
passwordHash,
isAdmin: false,
createdAt,
email: request.email,
};
}
@@ -129,7 +131,7 @@ export async function updateUser(
): Promise<User> {
const user = getUserById(userId);
const { password, description, ...requestFields } = request;
const { password, description, email, ...requestFields } = request;
const now = new Date();
const updatedUser: User = {
@@ -137,18 +139,20 @@ export async function updateUser(
passwordHash: password ? await hashPassword(password) : user.passwordHash,
...requestFields,
description: description ?? user.description,
email: email ?? user.email,
updatedAt: now,
};
const updatedUserRow = userApiToRow(updatedUser);
const userResult = db.prepare(
`UPDATE users SET username = ?, password_hash = ?, is_admin = ?, description = ?, updated_at = ? WHERE id = ?`,
`UPDATE users SET username = ?, password_hash = ?, is_admin = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`,
).run(
updatedUserRow.username,
updatedUserRow.password_hash,
updatedUserRow.is_admin,
updatedUserRow.description,
updatedUserRow.email,
now.toISOString(),
updatedUserRow.id,
);

View File

@@ -163,7 +163,9 @@ export function broadcastPlaylistDumpsUpdated(
});
}
export function broadcastUserUpdated(user: Omit<User, "passwordHash">): void {
export function broadcastUserUpdated(
user: Omit<User, "passwordHash" | "email">,
): void {
for (const client of clients) {
send(client.socket, { type: "user_updated", user });
}

View File

@@ -26,7 +26,8 @@ CREATE TABLE users (
updated_at TEXT,
avatar_mime TEXT,
description TEXT,
invited_by TEXT REFERENCES users(id)
invited_by TEXT REFERENCES users(id),
email TEXT NOT NULL
);
CREATE TABLE votes (