7b79015ac2
continuous-integration/drone/push Build is passing
feat(gamification): add streak bonuses, tooltip, and improved history details fix(ep): resolve double counting ep bug ui: update app icon and translations
203 lines
7.2 KiB
TypeScript
203 lines
7.2 KiB
TypeScript
import passport from "passport";
|
|
import { Strategy as LocalStrategy } from "passport-local";
|
|
import { Express } from "express";
|
|
import session from "express-session";
|
|
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
|
|
import { promisify } from "util";
|
|
import { storage } from "./storage";
|
|
import { User } from "../shared/schema";
|
|
|
|
const scryptAsync = promisify(scrypt);
|
|
|
|
export async function hashPassword(password: string) {
|
|
const salt = randomBytes(16).toString("hex");
|
|
const buf = (await scryptAsync(password, salt, 64)) as Buffer;
|
|
return `${buf.toString("hex")}.${salt}`;
|
|
}
|
|
|
|
export async function comparePassword(supplied: string, stored: string) {
|
|
const [hashed, salt] = stored.split(".");
|
|
const hashedBuf = Buffer.from(hashed, "hex");
|
|
const suppliedBuf = (await scryptAsync(supplied, salt, 64)) as Buffer;
|
|
return timingSafeEqual(hashedBuf, suppliedBuf);
|
|
}
|
|
|
|
export function setupAuth(app: Express) {
|
|
const sessionSettings: session.SessionOptions = {
|
|
secret: process.env.SESSION_SECRET || "s3cr3t_m3ss4g3",
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
store: storage.sessionStore,
|
|
cookie: {
|
|
secure: process.env.NODE_ENV === "production" && process.env.SECURE_COOKIES === "true",
|
|
sameSite: "lax",
|
|
// maxAge not set by default (session cookie)
|
|
},
|
|
};
|
|
|
|
|
|
|
|
app.use(session(sessionSettings));
|
|
app.use(passport.initialize());
|
|
app.use(passport.session());
|
|
|
|
passport.use(
|
|
new LocalStrategy(async (username, password, done) => {
|
|
try {
|
|
let user;
|
|
// Check if input looks like an email
|
|
if (username.includes('@')) {
|
|
user = await storage.getUserByEmail(username);
|
|
}
|
|
|
|
// Fallback to username lookup if not found by email, or if input wasn't an email
|
|
if (!user) {
|
|
user = await storage.getUserByUsername(username);
|
|
}
|
|
|
|
if (!user) {
|
|
return done(null, false, { message: "Incorrect username or password." });
|
|
}
|
|
|
|
if (!user.isActive) {
|
|
return done(null, false, { message: "Account is deactivated." });
|
|
}
|
|
|
|
const isValid = await comparePassword(password, user.password);
|
|
if (!isValid) {
|
|
return done(null, false, { message: "Incorrect username or password." });
|
|
}
|
|
|
|
return done(null, user);
|
|
} catch (err) {
|
|
return done(err);
|
|
}
|
|
})
|
|
);
|
|
|
|
// ... serialize/deserialize ...
|
|
|
|
passport.serializeUser((user, done) => {
|
|
done(null, (user as User).id);
|
|
});
|
|
|
|
passport.deserializeUser(async (id: string, done) => {
|
|
try {
|
|
const user = await storage.getUser(id);
|
|
if (!user) {
|
|
return done(null, false);
|
|
}
|
|
done(null, user);
|
|
} catch (err) {
|
|
done(err);
|
|
}
|
|
});
|
|
|
|
app.post("/api/register", async (req, res, next) => {
|
|
try {
|
|
// Check if registration is allowed
|
|
const regEnabled = await storage.getSystemSettings("registration_enabled");
|
|
if (regEnabled === "false") {
|
|
// But wait, if it's the FIRST user (Setup), this route isn't used. Setup uses /api/setup.
|
|
// So we can enforce this check here for public registration.
|
|
return res.status(403).send("Registration is currently disabled.");
|
|
}
|
|
|
|
const existingUser = await storage.getUserByUsername(req.body.username);
|
|
if (existingUser) {
|
|
return res.status(400).send("Username already exists");
|
|
}
|
|
|
|
const existingEmail = await storage.getUserByEmail(req.body.email);
|
|
if (existingEmail) {
|
|
return res.status(400).send("Email already exists");
|
|
}
|
|
|
|
const hashedPassword = await hashPassword(req.body.password);
|
|
const user = await storage.createUser({
|
|
...req.body,
|
|
password: hashedPassword,
|
|
role: 'user', // Default role for public registration
|
|
isActive: true
|
|
});
|
|
|
|
req.login(user, (err) => {
|
|
if (err) return next(err);
|
|
res.status(201).json(user);
|
|
});
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
app.post("/api/login", passport.authenticate("local"), (req, res) => {
|
|
if (req.body.rememberMe) {
|
|
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
}
|
|
res.status(200).json(req.user);
|
|
});
|
|
|
|
app.post("/api/logout", (req, res, next) => {
|
|
req.logout((err) => {
|
|
if (err) return next(err);
|
|
res.redirect("/");
|
|
});
|
|
});
|
|
|
|
app.get("/api/logout", (req, res, next) => {
|
|
req.logout((err) => {
|
|
if (err) return next(err);
|
|
res.redirect("/");
|
|
});
|
|
});
|
|
|
|
app.get("/api/user", async (req, res) => {
|
|
if (!req.isAuthenticated()) return res.sendStatus(401);
|
|
|
|
// Check for Daily Streak
|
|
const user = req.user as User;
|
|
const now = new Date();
|
|
const lastActive = user.lastActive ? new Date(user.lastActive) : new Date(0);
|
|
|
|
// Normalize to dates (ignore time)
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const lastDate = new Date(lastActive.getFullYear(), lastActive.getMonth(), lastActive.getDate());
|
|
const yesterday = new Date(today);
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
|
|
// If last active was yesterday, increment streak
|
|
// If last active was today, do nothing
|
|
// If last active was before yesterday, reset streak (unless we decide to be lenient)
|
|
|
|
// We need GamificationService here
|
|
const { GamificationService } = await import("./gamification");
|
|
const gamificationService = new GamificationService(storage);
|
|
|
|
if (lastDate.getTime() < today.getTime()) {
|
|
if (lastDate.getTime() === yesterday.getTime()) {
|
|
// Perfect streak
|
|
await gamificationService.awardXP(user.id, 'daily_streak');
|
|
// Check bonuses
|
|
const updatedUser = await storage.getUser(user.id);
|
|
if (updatedUser) {
|
|
await gamificationService.checkStreakBonuses(user.id, updatedUser.currentStreak);
|
|
}
|
|
} else if (lastDate.getTime() < yesterday.getTime()) {
|
|
// Streak broken
|
|
// Reset streak to 1 (today is day 1)
|
|
await storage.updateUser(user.id, { currentStreak: 1 });
|
|
// Still award daily XP for today? Yes.
|
|
await gamificationService.awardXP(user.id, 'daily_streak');
|
|
} else {
|
|
// Should not happen if < today
|
|
}
|
|
// Update lastActive
|
|
await storage.updateUser(user.id, { lastActive: now });
|
|
}
|
|
|
|
// Re-fetch user to get latest XP and Streak
|
|
const freshUser = await storage.getUser(user.id);
|
|
res.json(freshUser);
|
|
});
|
|
}
|