v3: added onboarding email on account creation
This commit is contained in:
@@ -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)
|
||||
: []
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user