feat: enhance visitor mode

This commit is contained in:
Peifan Li
2026-01-03 21:47:54 -05:00
parent 44b24543d0
commit 76d4269164
40 changed files with 941 additions and 398 deletions

View File

@@ -20,6 +20,7 @@
"drizzle-orm": "^0.44.7",
"express": "^4.22.0",
"fs-extra": "^11.2.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"node-cron": "^4.2.1",
"puppeteer": "^24.31.0",
@@ -31,6 +32,7 @@
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/fs-extra": "^11.0.4",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11",
@@ -1536,6 +1538,17 @@
"@types/node": "*"
}
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -1550,6 +1563,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
@@ -2367,6 +2387,12 @@
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -3139,6 +3165,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -4455,12 +4490,103 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",

View File

@@ -27,6 +27,7 @@
"drizzle-orm": "^0.44.7",
"express": "^4.22.0",
"fs-extra": "^11.2.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"node-cron": "^4.2.1",
"puppeteer": "^24.31.0",
@@ -38,6 +39,7 @@
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/fs-extra": "^11.0.4",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11",

View File

@@ -60,4 +60,34 @@ describe('visitorModeMiddleware', () => {
expect(next).toHaveBeenCalled();
});
it('should allow passkey authenticate endpoint', () => {
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
mockReq = {
method: 'POST',
body: {},
path: '/settings/passkeys/authenticate',
url: '/settings/passkeys/authenticate'
};
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
expect(next).toHaveBeenCalled();
});
it('should allow passkey verify endpoint', () => {
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
mockReq = {
method: 'POST',
body: {},
path: '/settings/passkeys/authenticate/verify',
url: '/settings/passkeys/authenticate/verify'
};
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
expect(next).toHaveBeenCalled();
});
});

View File

@@ -37,11 +37,47 @@ describe('visitorModeSettingsMiddleware', () => {
it('should block other updates', () => {
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
mockReq.body = { websiteName: 'Hacked' };
mockReq = {
method: 'POST',
body: { websiteName: 'Hacked' },
path: '/api/settings',
url: '/api/settings'
};
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
expect(next).not.toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(403);
});
it('should allow passkey authenticate endpoint', () => {
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
mockReq = {
method: 'POST',
body: {},
path: '/passkeys/authenticate',
url: '/passkeys/authenticate'
};
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
expect(next).toHaveBeenCalled();
});
it('should allow passkey verify endpoint', () => {
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
mockReq = {
method: 'POST',
body: {},
path: '/passkeys/authenticate/verify',
url: '/passkeys/authenticate/verify'
};
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
expect(next).toHaveBeenCalled();
});
});

View File

@@ -164,7 +164,7 @@ export const verifyAuthentication = async (
);
if (result.verified) {
res.json({ success: true });
res.json({ success: true, token: result.token, role: result.role });
} else {
res.status(401).json({ success: false, error: "Authentication failed" });
}

View File

@@ -27,15 +27,22 @@ export const verifyPassword = async (
const result = await passwordService.verifyPassword(password);
if (result.success) {
// Return format expected by frontend: { success: boolean }
res.json({ success: true });
// Return format expected by frontend: { success: boolean, role?, token? }
res.json({
success: true,
role: result.role,
token: result.token
});
} else {
// Return wait time information
res.status(result.waitTime ? 429 : 401).json({
// Return 200 OK to suppress browser console errors, but include status code and success: false
const statusCode = result.waitTime ? 429 : 401;
res.json({
success: false,
waitTime: result.waitTime,
failedAttempts: result.failedAttempts,
message: result.message,
statusCode
});
}
};

View File

@@ -2,9 +2,9 @@ import { Request, Response } from "express";
import fs from "fs-extra";
import path from "path";
import {
COLLECTIONS_DATA_PATH,
STATUS_DATA_PATH,
VIDEOS_DATA_PATH,
COLLECTIONS_DATA_PATH,
STATUS_DATA_PATH,
VIDEOS_DATA_PATH,
} from "../config/paths";
import { cloudflaredService } from "../services/cloudflaredService";
import downloadManager from "../services/downloadManager";
@@ -37,9 +37,9 @@ export const getSettings = async (
const mergedSettings = { ...defaultSettings, ...settings };
// Do not send the hashed password to the frontend
const { password, ...safeSettings } = mergedSettings;
const { password, visitorPassword, ...safeSettings } = mergedSettings;
// Return data directly for backward compatibility
res.json({ ...safeSettings, isPasswordSet: !!password });
res.json({ ...safeSettings, isPasswordSet: !!password, isVisitorPasswordSet: !!visitorPassword });
};
/**
@@ -124,35 +124,43 @@ export const updateSettings = async (
{}
);
// Check visitor mode restrictions
const visitorModeCheck =
settingsValidationService.checkVisitorModeRestrictions(
mergedSettings,
newSettings
);
// Check visitor mode restrictions (if not admin)
// If user is admin (jwt authenticated), they bypass visitor mode restrictions
const isAdmin = req.user?.role === "admin";
if (!isAdmin) {
const visitorModeCheck =
settingsValidationService.checkVisitorModeRestrictions(
mergedSettings,
newSettings
);
if (!visitorModeCheck.allowed) {
res.status(403).json({
success: false,
error: visitorModeCheck.error,
});
return;
if (!visitorModeCheck.allowed) {
res.status(403).json({
success: false,
error: visitorModeCheck.error,
});
return;
}
// Handle special case: visitorMode being set to true (already enabled)
// Only applies if NOT admin (admins can update settings while in visitor mode)
if (mergedSettings.visitorMode === true && newSettings.visitorMode === true) {
// Only update visitorMode, ignore other changes
const allowedSettings: Settings = {
...mergedSettings,
visitorMode: true,
};
storageService.saveSettings(allowedSettings);
res.json({
success: true,
settings: { ...allowedSettings, password: undefined, visitorPassword: undefined },
});
return;
}
}
// Handle special case: visitorMode being set to true (already enabled)
if (mergedSettings.visitorMode === true && newSettings.visitorMode === true) {
// Only update visitorMode, ignore other changes
const allowedSettings: Settings = {
...mergedSettings,
visitorMode: true,
};
storageService.saveSettings(allowedSettings);
res.json({
success: true,
settings: { ...allowedSettings, password: undefined },
});
return;
}
// Validate settings
settingsValidationService.validateSettings(newSettings);
@@ -253,7 +261,7 @@ export const updateSettings = async (
// Return format expected by frontend: { success: true, settings: {...} }
res.json({
success: true,
settings: { ...finalSettings, password: undefined },
settings: { ...finalSettings, password: undefined, visitorPassword: undefined },
});
};

View File

@@ -0,0 +1,35 @@
import { NextFunction, Request, Response } from "express";
import { UserPayload, verifyToken } from "../services/authService";
// Extend Express Request type to include user property
declare global {
namespace Express {
interface Request {
user?: UserPayload;
}
}
}
/**
* Middleware to verify JWT token and attach user to request
* Does NOT block requests if token is missing/invalid, just leaves req.user undefined
* Blocking logic should be handled by specific route guards or visitorModeMiddleware
*/
export const authMiddleware = (
req: Request,
_res: Response,
next: NextFunction
): void => {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.split(" ")[1];
const decoded = verifyToken(token);
if (decoded) {
req.user = decoded;
}
}
next();
};

View File

@@ -19,6 +19,12 @@ export const visitorModeMiddleware = (
return;
}
// If user is Admin, allow all requests
if (req.user?.role === "admin") {
next();
return;
}
// Visitor mode is enabled
// Allow GET requests (read-only)
if (req.method === "GET") {
@@ -37,6 +43,12 @@ export const visitorModeMiddleware = (
return;
}
// Allow passkey authentication
if (req.path.includes("/settings/passkeys/authenticate") || req.url.includes("/settings/passkeys/authenticate")) {
next();
return;
}
// Check if the request is trying to disable visitor mode
if (body.visitorMode === false) {
// Allow disabling visitor mode

View File

@@ -19,6 +19,12 @@ export const visitorModeSettingsMiddleware = (
return;
}
// If user is Admin, allow all requests
if (req.user?.role === "admin") {
next();
return;
}
// Visitor mode is enabled
// Allow GET requests (read-only)
if (req.method === "GET") {
@@ -37,6 +43,15 @@ export const visitorModeSettingsMiddleware = (
return;
}
// Allow passkey authentication
if (
req.path.includes("/passkeys/authenticate") ||
req.url.includes("/passkeys/authenticate")
) {
next();
return;
}
const body = req.body || {};
// Check if the request is trying to disable visitor mode
if (body.visitorMode === false) {

View File

@@ -12,6 +12,7 @@ import {
VIDEOS_DIR,
} from "./config/paths";
import { runMigrations } from "./db/migrate";
import { authMiddleware } from "./middleware/authMiddleware";
import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware";
import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware";
import apiRoutes from "./routes/api";
@@ -243,6 +244,8 @@ const startServer = async () => {
);
// API Routes
// Apply auth middleware to all API routes
app.use("/api", authMiddleware);
// Apply visitor mode middleware to all API routes
app.use("/api", visitorModeMiddleware, apiRoutes);
// Use separate middleware for settings that allows disabling visitor mode

View File

@@ -35,6 +35,10 @@ vi.mock("../../utils/logger", () => ({
},
}));
vi.mock("../authService", () => ({
generateToken: vi.fn(() => "mock-token"),
}));
describe("passkeyService", () => {
const mockPasskey = {
credentialID: "mock-credential-id",
@@ -251,6 +255,18 @@ describe("passkeyService", () => {
])
})
);
expect(storageService.saveSettings).toHaveBeenCalledWith(
expect.objectContaining({
passkeys: expect.arrayContaining([
expect.objectContaining({
credentialID: "mock-credential-id",
counter: 1
})
])
})
);
expect(result.token).toBe("mock-token");
expect(result.role).toBe("admin");
});
it("should fail if passkey not found", async () => {

View File

@@ -0,0 +1,31 @@
import jwt from "jsonwebtoken";
import { v4 as uuidv4 } from "uuid";
const JWT_SECRET = process.env.JWT_SECRET || "default_development_secret_do_not_use_in_production";
const JWT_EXPIRES_IN = "24h";
export interface UserPayload {
role: "admin" | "visitor";
id?: string;
}
/**
* Generate a JWT token for a user
*/
export const generateToken = (payload: UserPayload): string => {
return jwt.sign({ ...payload, id: payload.id || uuidv4() }, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
};
/**
* Verify a JWT token
*/
export const verifyToken = (token: string): UserPayload | null => {
try {
const decoded = jwt.verify(token, JWT_SECRET) as UserPayload;
return decoded;
} catch (error) {
return null;
}
};

View File

@@ -11,6 +11,7 @@ import {
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { logger } from "../utils/logger";
import { generateToken } from "./authService";
import * as storageService from "./storageService";
// RP (Relying Party) configuration
@@ -255,6 +256,10 @@ export async function generatePasskeyAuthenticationOptions(
};
}
/**
* Verify passkey authentication
*/
/**
* Verify passkey authentication
*/
@@ -263,7 +268,7 @@ export async function verifyPasskeyAuthentication(
challenge: string,
originOverride?: string,
rpIDOverride?: string
): Promise<{ verified: boolean }> {
): Promise<{ verified: boolean; token?: string; role?: "admin" | "visitor" }> {
try {
const passkeys = getPasskeys();
// Find passkey by matching the credential ID
@@ -304,7 +309,11 @@ export async function verifyPasskeyAuthentication(
savePasskeys(updatedPasskeys);
logger.info("Passkey authentication successful");
return { verified: true };
// Generate admin token (Passkeys are currently only for admins)
const token = generateToken({ role: "admin" });
return { verified: true, token, role: "admin" };
}
return { verified: false };

View File

@@ -1,9 +1,9 @@
import bcrypt from "bcryptjs";
import crypto from "crypto";
import { defaultSettings } from "../types/settings";
import { logger } from "../utils/logger";
import * as loginAttemptService from "./loginAttemptService";
import * as storageService from "./storageService";
import { logger } from "../utils/logger";
import { Settings, defaultSettings } from "../types/settings";
/**
* Check if login is required (loginEnabled is true)
@@ -44,10 +44,17 @@ export function isPasswordEnabled(): {
/**
* Verify password for authentication
*/
/**
* Verify password for authentication
*/
import { generateToken } from "./authService";
export async function verifyPassword(
password: string
): Promise<{
success: boolean;
role?: "admin" | "visitor";
token?: string;
waitTime?: number;
failedAttempts?: number;
message?: string;
@@ -58,6 +65,11 @@ export async function verifyPassword(
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
// If password login is explicitly disabled, ONLY allow Admin to login via password (if they have one set)?
// Or just block everyone? The frontend says "When disabled, password login is not available."
// But typically Admin should always be able to login?
// For now, let's respect the flag, but maybe we should allow it if we are matching the Admin password?
// Let's stick to current logic: if blocked, blocked.
if (!passwordLoginAllowed) {
return {
success: false,
@@ -65,11 +77,6 @@ export async function verifyPassword(
};
}
if (!mergedSettings.password) {
// If no password set but login enabled, allow access
return { success: true };
}
// Check if user can attempt login (wait time check)
const remainingWaitTime = loginAttemptService.canAttemptLogin();
if (remainingWaitTime > 0) {
@@ -81,24 +88,49 @@ export async function verifyPassword(
};
}
const isMatch = await bcrypt.compare(password, mergedSettings.password);
if (isMatch) {
// Reset failed attempts on successful login
loginAttemptService.resetFailedAttempts();
return { success: true };
// 1. Check Admin Password
if (mergedSettings.password) {
const isAdminMatch = await bcrypt.compare(password, mergedSettings.password);
if (isAdminMatch) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "admin" });
return { success: true, role: "admin", token };
}
} else {
// Record failed attempt and get wait time
const waitTime = loginAttemptService.recordFailedAttempt();
const failedAttempts = loginAttemptService.getFailedAttempts();
return {
success: false,
waitTime,
failedAttempts,
message: "Incorrect password",
};
// If no admin password set, and login enabled, allow as admin
if (mergedSettings.loginEnabled) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "admin" });
return { success: true, role: "admin", token };
}
}
// 2. Check Visitor Password (only if visitorMode is enabled AND visitorPassword is set)
// Actually, user said: "When visitor user enable... login from this password input user role is Visitor"
// So we check if visitorMode is enabled in settings.
if (mergedSettings.visitorMode && mergedSettings.visitorPassword) {
// NOTE: visitorPassword might not be hashed yet if we just added it?
// Step 6 in Plan said "Update Settings type", but we didn't discuss hashing.
// We should probably hash it when saving too.
// For now, assuming it WILL be hashed. I'll need to update `settingsValidationService` to hash it.
const isVisitorMatch = await bcrypt.compare(password, mergedSettings.visitorPassword);
if (isVisitorMatch) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "visitor" });
return { success: true, role: "visitor", token };
}
}
// No match
const waitTime = loginAttemptService.recordFailedAttempt();
const failedAttempts = loginAttemptService.getFailedAttempts();
return {
success: false,
waitTime,
failedAttempts,
message: "Incorrect password",
};
}
/**

View File

@@ -1,6 +1,6 @@
import * as storageService from "./storageService";
import { Settings, defaultSettings } from "../types/settings";
import { logger } from "../utils/logger";
import * as storageService from "./storageService";
/**
* Validate and normalize settings values
@@ -155,6 +155,13 @@ export async function prepareSettingsForSave(
prepared.password = existingSettings.password;
}
// Handle visitor password hashing
if (prepared.visitorPassword) {
prepared.visitorPassword = await hashPassword(prepared.visitorPassword);
} else {
prepared.visitorPassword = existingSettings.visitorPassword;
}
// Handle tags
const oldTags: string[] = existingSettings.tags || [];
if (prepared.tags === undefined) {

View File

@@ -24,6 +24,7 @@ export interface Settings {
moveSubtitlesToVideoFolder?: boolean;
moveThumbnailsToVideoFolder?: boolean;
visitorMode?: boolean;
visitorPassword?: string;
infiniteScroll?: boolean;
videoColumns?: number;
cloudflaredTunnelEnabled?: boolean;