From b6d927a3d6854f56c272bddfb6da54e37f552490 Mon Sep 17 00:00:00 2001 From: fgbona Date: Fri, 27 Mar 2026 23:29:37 -0300 Subject: [PATCH 1/2] feat/mfa: Added multifactor authentication. --- client/src/api/client.ts | 4 + client/src/i18n/translations/de.ts | 23 ++ client/src/i18n/translations/en.ts | 23 ++ client/src/pages/LoginPage.tsx | 78 +++++- client/src/pages/SettingsPage.tsx | 150 +++++++++- client/src/store/authStore.ts | 34 ++- client/src/types.ts | 2 + scripts/install-server-deps.sh | 134 +++++++++ server/.npmrc | 1 + server/.nvmrc | 1 + server/package-lock.json | 421 +++++++++++++++++++++++++++-- server/package.json | 3 + server/src/db/migrations.ts | 4 + server/src/db/schema.ts | 2 + server/src/routes/auth.ts | 171 +++++++++++- server/src/services/mfaCrypto.ts | 25 ++ server/src/types.ts | 2 + 17 files changed, 1036 insertions(+), 42 deletions(-) create mode 100755 scripts/install-server-deps.sh create mode 100644 server/.npmrc create mode 100644 server/.nvmrc create mode 100644 server/src/services/mfaCrypto.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 534fca0..9824b14 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -41,6 +41,10 @@ apiClient.interceptors.response.use( export const authApi = { register: (data: { username: string; email: string; password: string }) => apiClient.post('/auth/register', data).then(r => r.data), login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data), + verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data), + mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data), + mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data), + mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data), me: () => apiClient.get('/auth/me').then(r => r.data), updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data), updateApiKeys: (data: Record) => apiClient.put('/auth/me/api-keys', data).then(r => r.data), diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 2fc0d5b..428cbbd 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -164,6 +164,22 @@ const de: Record = { 'settings.avatarUploaded': 'Profilbild aktualisiert', 'settings.avatarRemoved': 'Profilbild entfernt', 'settings.avatarError': 'Fehler beim Hochladen', + 'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)', + 'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).', + 'settings.mfa.enabled': '2FA ist für dein Konto aktiv.', + 'settings.mfa.disabled': '2FA ist nicht aktiviert.', + 'settings.mfa.setup': 'Authenticator einrichten', + 'settings.mfa.scanQr': 'QR-Code mit der App scannen oder den Schlüssel manuell eingeben.', + 'settings.mfa.secretLabel': 'Geheimer Schlüssel (manuell)', + 'settings.mfa.codePlaceholder': '6-stelliger Code', + 'settings.mfa.enable': '2FA aktivieren', + 'settings.mfa.cancelSetup': 'Abbrechen', + 'settings.mfa.disableTitle': '2FA deaktivieren', + 'settings.mfa.disableHint': 'Passwort und einen aktuellen Code aus der Authenticator-App eingeben.', + 'settings.mfa.disable': '2FA deaktivieren', + 'settings.mfa.toastEnabled': 'Zwei-Faktor-Authentifizierung aktiviert', + 'settings.mfa.toastDisabled': 'Zwei-Faktor-Authentifizierung deaktiviert', + 'settings.mfa.demoBlocked': 'In der Demo nicht verfügbar', // Login 'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.', @@ -207,6 +223,13 @@ const de: Record = { 'login.demoFailed': 'Demo-Login fehlgeschlagen', 'login.oidcSignIn': 'Anmelden mit {name}', 'login.demoHint': 'Demo ausprobieren — ohne Registrierung', + 'login.mfaTitle': 'Zwei-Faktor-Authentifizierung', + 'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.', + 'login.mfaCodeLabel': 'Bestätigungscode', + 'login.mfaCodeRequired': 'Bitte den Code aus der Authenticator-App eingeben.', + 'login.mfaHint': 'Google Authenticator, Authy oder eine andere TOTP-App öffnen.', + 'login.mfaBack': '← Zurück zur Anmeldung', + 'login.mfaVerify': 'Bestätigen', // Register 'register.passwordMismatch': 'Passwörter stimmen nicht überein', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 4e0a76e..6bf8718 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -164,6 +164,22 @@ const en: Record = { 'settings.avatarUploaded': 'Profile picture updated', 'settings.avatarRemoved': 'Profile picture removed', 'settings.avatarError': 'Upload failed', + 'settings.mfa.title': 'Two-factor authentication (2FA)', + 'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).', + 'settings.mfa.enabled': '2FA is enabled on your account.', + 'settings.mfa.disabled': '2FA is not enabled.', + 'settings.mfa.setup': 'Set up authenticator', + 'settings.mfa.scanQr': 'Scan this QR code with your app, or enter the secret manually.', + 'settings.mfa.secretLabel': 'Secret key (manual entry)', + 'settings.mfa.codePlaceholder': '6-digit code', + 'settings.mfa.enable': 'Enable 2FA', + 'settings.mfa.cancelSetup': 'Cancel', + 'settings.mfa.disableTitle': 'Disable 2FA', + 'settings.mfa.disableHint': 'Enter your account password and a current code from your authenticator.', + 'settings.mfa.disable': 'Disable 2FA', + 'settings.mfa.toastEnabled': 'Two-factor authentication enabled', + 'settings.mfa.toastDisabled': 'Two-factor authentication disabled', + 'settings.mfa.demoBlocked': 'Not available in demo mode', // Login 'login.error': 'Login failed. Please check your credentials.', @@ -207,6 +223,13 @@ const en: Record = { 'login.demoFailed': 'Demo login failed', 'login.oidcSignIn': 'Sign in with {name}', 'login.demoHint': 'Try the demo — no registration needed', + 'login.mfaTitle': 'Two-factor authentication', + 'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.', + 'login.mfaCodeLabel': 'Verification code', + 'login.mfaCodeRequired': 'Enter the code from your authenticator app.', + 'login.mfaHint': 'Open Google Authenticator, Authy, or another TOTP app.', + 'login.mfaBack': '← Back to sign in', + 'login.mfaVerify': 'Verify', // Register 'register.passwordMismatch': 'Passwords do not match', diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 84e9d9c..df4c887 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -4,7 +4,7 @@ import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' import { useTranslation } from '../i18n' import { authApi } from '../api/client' -import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield } from 'lucide-react' +import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react' interface AppConfig { has_users: boolean @@ -25,7 +25,7 @@ export default function LoginPage(): React.ReactElement { const [error, setError] = useState('') const [appConfig, setAppConfig] = useState(null) - const { login, register, demoLogin } = useAuthStore() + const { login, register, demoLogin, completeMfaLogin } = useAuthStore() const { setLanguageLocal } = useSettingsStore() const navigate = useNavigate() @@ -83,18 +83,39 @@ export default function LoginPage(): React.ReactElement { } const [showTakeoff, setShowTakeoff] = useState(false) + const [mfaStep, setMfaStep] = useState(false) + const [mfaToken, setMfaToken] = useState('') + const [mfaCode, setMfaCode] = useState('') const handleSubmit = async (e: React.FormEvent): Promise => { e.preventDefault() setError('') setIsLoading(true) try { + if (mode === 'login' && mfaStep) { + if (!mfaCode.trim()) { + setError(t('login.mfaCodeRequired')) + setIsLoading(false) + return + } + await completeMfaLogin(mfaToken, mfaCode) + setShowTakeoff(true) + setTimeout(() => navigate('/dashboard'), 2600) + return + } if (mode === 'register') { if (!username.trim()) { setError('Username is required'); setIsLoading(false); return } if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return } await register(username, email, password) } else { - await login(email, password) + const result = await login(email, password) + if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) { + setMfaToken(result.mfa_token) + setMfaStep(true) + setMfaCode('') + setIsLoading(false) + return + } } setShowTakeoff(true) setTimeout(() => navigate('/dashboard'), 2600) @@ -435,10 +456,18 @@ export default function LoginPage(): React.ReactElement {

- {mode === 'register' ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) : t('login.title')} + {mode === 'login' && mfaStep + ? t('login.mfaTitle') + : mode === 'register' + ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) + : t('login.title')}

- {mode === 'register' ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) : t('login.subtitle')} + {mode === 'login' && mfaStep + ? t('login.mfaSubtitle') + : mode === 'register' + ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) + : t('login.subtitle')}

@@ -448,6 +477,35 @@ export default function LoginPage(): React.ReactElement {
)} + {mode === 'login' && mfaStep && ( +
+ +
+ + ) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))} + placeholder="000000" + required + style={inputBase} + onFocus={(e: React.FocusEvent) => e.target.style.borderColor = '#111827'} + onBlur={(e: React.FocusEvent) => e.target.style.borderColor = '#e5e7eb'} + /> +
+

{t('login.mfaHint')}

+ +
+ )} + {/* Username (register only) */} {mode === 'register' && (
@@ -465,6 +523,7 @@ export default function LoginPage(): React.ReactElement { )} {/* Email */} + {!(mode === 'login' && mfaStep) && (
@@ -477,8 +536,10 @@ export default function LoginPage(): React.ReactElement { />
+ )} {/* Password */} + {!(mode === 'login' && mfaStep) && (
@@ -497,6 +558,7 @@ export default function LoginPage(): React.ReactElement {
+ )} @@ -518,7 +580,7 @@ export default function LoginPage(): React.ReactElement { {showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (

{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '} - diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index bf19952..2cd985d 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -6,7 +6,7 @@ import { useTranslation } from '../i18n' import Navbar from '../components/Layout/Navbar' import CustomSelect from '../components/shared/CustomSelect' import { useToast } from '../components/shared/Toast' -import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock } from 'lucide-react' +import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react' import { authApi, adminApi } from '../api/client' import type { LucideIcon } from 'lucide-react' import type { UserWithOidc } from '../types' @@ -46,7 +46,7 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem } export default function SettingsPage(): React.ReactElement { - const { user, updateProfile, uploadAvatar, deleteAvatar, logout } = useAuthStore() + const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore() const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const avatarInputRef = React.useRef(null) const { settings, updateSetting, updateSettings } = useSettingsStore() @@ -72,6 +72,13 @@ export default function SettingsPage(): React.ReactElement { const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') + const [mfaQr, setMfaQr] = useState(null) + const [mfaSecret, setMfaSecret] = useState(null) + const [mfaSetupCode, setMfaSetupCode] = useState('') + const [mfaDisablePwd, setMfaDisablePwd] = useState('') + const [mfaDisableCode, setMfaDisableCode] = useState('') + const [mfaLoading, setMfaLoading] = useState(false) + useEffect(() => { setMapTileUrl(settings.map_tile_url || '') setDefaultLat(settings.default_lat || 48.8566) @@ -447,6 +454,145 @@ export default function SettingsPage(): React.ReactElement {

+ {/* MFA */} +
+
+ +

{t('settings.mfa.title')}

+
+
+

{t('settings.mfa.description')}

+ {demoMode ? ( +

{t('settings.mfa.demoBlocked')}

+ ) : ( + <> +

+ {user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')} +

+ + {!user?.mfa_enabled && !mfaQr && ( + + )} + + {!user?.mfa_enabled && mfaQr && ( +
+

{t('settings.mfa.scanQr')}

+ +
+ + {mfaSecret} +
+ setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))} + placeholder={t('settings.mfa.codePlaceholder')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" + /> +
+ + +
+
+ )} + + {user?.mfa_enabled && ( +
+

{t('settings.mfa.disableTitle')}

+

{t('settings.mfa.disableHint')}

+ setMfaDisablePwd(e.target.value)} + placeholder={t('settings.currentPassword')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" + /> + setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))} + placeholder={t('settings.mfa.codePlaceholder')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" + /> + +
+ )} + + )} +
+
+
{user?.avatar_url ? ( diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index ee1f9b0..263f796 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -9,6 +9,8 @@ interface AuthResponse { token: string } +export type LoginResult = AuthResponse | { mfa_required: true; mfa_token: string } + interface AvatarResponse { avatar_url: string } @@ -22,7 +24,8 @@ interface AuthState { demoMode: boolean hasMapsKey: boolean - login: (email: string, password: string) => Promise + login: (email: string, password: string) => Promise + completeMfaLogin: (mfaToken: string, code: string) => Promise register: (username: string, email: string, password: string) => Promise logout: () => void loadUser: () => Promise @@ -48,7 +51,11 @@ export const useAuthStore = create((set, get) => ({ login: async (email: string, password: string) => { set({ isLoading: true, error: null }) try { - const data = await authApi.login({ email, password }) + const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string } + if (data.mfa_required && data.mfa_token) { + set({ isLoading: false, error: null }) + return { mfa_required: true as const, mfa_token: data.mfa_token } + } localStorage.setItem('auth_token', data.token) set({ user: data.user, @@ -58,7 +65,7 @@ export const useAuthStore = create((set, get) => ({ error: null, }) connect(data.token) - return data + return data as AuthResponse } catch (err: unknown) { const error = getApiErrorMessage(err, 'Login failed') set({ isLoading: false, error }) @@ -66,6 +73,27 @@ export const useAuthStore = create((set, get) => ({ } }, + completeMfaLogin: async (mfaToken: string, code: string) => { + set({ isLoading: true, error: null }) + try { + const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') }) + localStorage.setItem('auth_token', data.token) + set({ + user: data.user, + token: data.token, + isAuthenticated: true, + isLoading: false, + error: null, + }) + connect(data.token) + return data as AuthResponse + } catch (err: unknown) { + const error = getApiErrorMessage(err, 'Verification failed') + set({ isLoading: false, error }) + throw new Error(error) + } + }, + register: async (username: string, email: string, password: string) => { set({ isLoading: true, error: null }) try { diff --git a/client/src/types.ts b/client/src/types.ts index c411637..e5bd1f5 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -8,6 +8,8 @@ export interface User { avatar_url: string | null maps_api_key: string | null created_at: string + /** Present after load; true when TOTP MFA is enabled for password login */ + mfa_enabled?: boolean } export interface Trip { diff --git a/scripts/install-server-deps.sh b/scripts/install-server-deps.sh new file mode 100755 index 0000000..964a5fb --- /dev/null +++ b/scripts/install-server-deps.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Install server/node_modules on Linux (e.g. RHEL 8) when: +# - `node` points to NSolid → node-gyp downloads wrong headers (403) +# - GLIBC < 2.29 → better-sqlite3 prebuild fails → must compile from source +# - Python 3.6 (default on EL8) → node-gyp 12+ requires Python 3.8+ for its gyp scripts +# - GCC 8 (default g++ on EL8) → better-sqlite3 needs -std=c++20 (GCC 10+); use gcc-toolset +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT/server" + +# node-gyp: pick Python 3.8+ (required; 3.6 throws SyntaxError on walrus operator in gyp) +pick_python38() { + local py + if [[ -n "${PYTHON:-}" && -x "${PYTHON}" ]]; then + if "${PYTHON}" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' 2>/dev/null; then + echo "$PYTHON" + return 0 + fi + fi + for py in \ + /usr/bin/python3.12 /usr/bin/python3.11 /usr/bin/python3.10 /usr/bin/python3.9 /usr/bin/python3.8 \ + /usr/local/bin/python3.11 /usr/local/bin/python3.10 /usr/local/bin/python3.9 \ + "$(command -v python3 2>/dev/null || true)"; do + [[ -z "$py" || ! -x "$py" ]] && continue + if "$py" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' 2>/dev/null; then + echo "$py" + return 0 + fi + done + return 1 +} + +if ! PY="$(pick_python38)"; then + echo "ERROR: No Python 3.8+ found. node-gyp (used by better-sqlite3) needs Python ≥ 3.8." + echo "On RHEL 8 / AlmaLinux 8:" + echo " sudo dnf install -y python39 gcc-c++ make" + echo " export PYTHON=/usr/bin/python3.9" + echo " $0" + exit 1 +fi +export PYTHON="$PY" +export npm_config_python="$PY" +echo "Using PYTHON=$PYTHON ($("$PYTHON" -c 'import sys; print(sys.version.split()[0])' 2>/dev/null || echo unknown))" + +# Optional: force a real Node.js binary (bypasses NSolid on PATH), e.g. +# NODE_BINARY=/opt/nodejs/bin/node ../scripts/install-server-deps.sh +# nvm stores versions as v22.x.x — do NOT use .../versions/node/22/bin/node (wrong). +if [[ -n "${NODE_BINARY:-}" ]]; then + if [[ ! -x "$NODE_BINARY" ]]; then + echo "ERROR: NODE_BINARY is not executable: $NODE_BINARY" + echo "Hint: nvm path looks like: \$HOME/.nvm/versions/node/v22.21.1/bin/node" + exit 1 + fi + export PATH="$(dirname "$NODE_BINARY"):$PATH" + hash -r 2>/dev/null || true +fi + +# Load nvm if present so Node from nvm wins over /usr/bin/nsolid (must run before `node` check) +if [[ -z "${NODE_BINARY:-}" ]]; then + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + if [[ -s "$NVM_DIR/nvm.sh" ]]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" + if [[ -f "$ROOT/server/.nvmrc" ]]; then + nvm install + nvm use + else + nvm install 22 + nvm use 22 + fi + hash -r 2>/dev/null || true + fi +fi + +NODE_BIN="$(command -v node 2>/dev/null || true)" +if [[ -z "$NODE_BIN" ]]; then + echo "ERROR: \`node\` not found. Install Node.js (nvm recommended) or set NODE_BINARY=/path/to/node" + exit 1 +fi + +BASE="$(basename "$(readlink -f "$NODE_BIN" 2>/dev/null || echo "$NODE_BIN")")" +if [[ "$BASE" == "nsolid" ]] || [[ "$NODE_BIN" == *nsolid* ]] || [[ "$(readlink -f "$NODE_BIN" 2>/dev/null || true)" == *nsolid* ]]; then + echo "ERROR: \`node\` still resolves to NSolid: $NODE_BIN" + echo " node-gyp then requests NSolid header tarballs and often gets HTTP 403." + echo "" + echo "Try one of:" + echo " 1) source ~/.nvm/nvm.sh && cd $ROOT/server && nvm use && $0" + echo " 2) NODE_BINARY=\$(ls -d \"\$HOME/.nvm/versions/node/v22\"*\"/bin/node\" 2>/dev/null | head -1) $0" + exit 1 +fi + +echo "Using node: $NODE_BIN ($("$NODE_BIN" -p 'process.version'))" + +# better-sqlite3 native build uses -std=c++20; GCC 8 only knows -std=c++2a — need gcc-toolset-12+ on RHEL 8 +gpp_supports_cxx20() { + local tmp + tmp="$(mktemp)" + if echo 'int main(){}' | g++ -std=c++20 -x c++ - -o "$tmp" 2>/dev/null; then + rm -f "$tmp" + return 0 + fi + rm -f "$tmp" + return 1 +} + +if ! gpp_supports_cxx20; then + for ts in gcc-toolset-13 gcc-toolset-12 gcc-toolset-11; do + en="/opt/rh/${ts}/enable" + if [[ -f "$en" ]]; then + # shellcheck source=/dev/null + . "$en" + if gpp_supports_cxx20; then + echo "Using newer g++ for C++20: $(command -v g++) ($($(command -v g++) -dumpversion))" + break + fi + fi + done +fi + +if ! gpp_supports_cxx20; then + echo "ERROR: \`g++\` does not support -std=c++20 (better-sqlite3 needs GCC 10+)." + echo "On RHEL 8 / AlmaLinux 8 install a toolchain and re-run this script:" + echo " sudo dnf install -y gcc-toolset-12-gcc-c++ make" + echo " source /opt/rh/gcc-toolset-12/enable" + echo " $0" + exit 1 +fi + +echo "Also required on EL8: sudo dnf install -y gcc-c++ make (if not already)" +echo "" + +# Prefer compiling native addons from source when prebuilds do not load (older GLIBC) +npm install --build-from-source "$@" diff --git a/server/.npmrc b/server/.npmrc new file mode 100644 index 0000000..a69081d --- /dev/null +++ b/server/.npmrc @@ -0,0 +1 @@ +# node-gyp reads PYTHON from the environment; do not set deprecated keys here (npm 10+ warns on unknown keys). diff --git a/server/.nvmrc b/server/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/server/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/server/package-lock.json b/server/package-lock.json index 6be8c5f..30612cc 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "nomad-server", - "version": "2.6.0", + "version": "2.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nomad-server", - "version": "2.6.0", + "version": "2.6.1", "dependencies": { "archiver": "^6.0.1", "bcryptjs": "^2.4.3", @@ -19,6 +19,8 @@ "multer": "^1.4.5-lts.1", "node-cron": "^4.2.1", "node-fetch": "^2.7.0", + "otplib": "^12.0.1", + "qrcode": "^1.5.4", "tsx": "^4.21.0", "typescript": "^6.0.2", "unzipper": "^0.12.3", @@ -35,6 +37,7 @@ "@types/multer": "^2.1.0", "@types/node": "^25.5.0", "@types/node-cron": "^3.0.11", + "@types/qrcode": "^1.5.5", "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", @@ -457,6 +460,56 @@ "node": ">=18" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@types/archiver": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", @@ -592,6 +645,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -677,6 +740,30 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -959,9 +1046,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1078,6 +1165,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1109,6 +1205,35 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/compress-commons": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz", @@ -1262,6 +1387,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1314,6 +1448,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1394,6 +1534,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1605,6 +1751,19 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1672,6 +1831,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1767,9 +1935,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1962,6 +2130,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2094,6 +2271,18 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", @@ -2458,6 +2647,53 @@ "wrappy": "1" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2467,16 +2703,25 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -2486,6 +2731,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2549,6 +2803,23 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -2633,9 +2904,9 @@ "license": "MIT" }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -2666,6 +2937,21 @@ "node": ">=8.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2758,6 +3044,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2931,6 +3223,32 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -3011,6 +3329,14 @@ "b4a": "^1.6.4" } }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3210,6 +3536,26 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3246,6 +3592,47 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zip-stream": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz", diff --git a/server/package.json b/server/package.json index a8f78aa..6994726 100644 --- a/server/package.json +++ b/server/package.json @@ -17,6 +17,8 @@ "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "node-cron": "^4.2.1", + "otplib": "^12.0.1", + "qrcode": "^1.5.4", "node-fetch": "^2.7.0", "tsx": "^4.21.0", "typescript": "^6.0.2", @@ -34,6 +36,7 @@ "@types/multer": "^2.1.0", "@types/node": "^25.5.0", "@types/node-cron": "^3.0.11", + "@types/qrcode": "^1.5.5", "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index dfcdf9e..0a30fed 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -193,6 +193,10 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {} }, + () => { + try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch {} + try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 40f3d44..60b1f79 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -15,6 +15,8 @@ function createTables(db: Database.Database): void { oidc_sub TEXT, oidc_issuer TEXT, last_login DATETIME, + mfa_enabled INTEGER DEFAULT 0, + mfa_secret TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index e64eb8e..b14f61a 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -6,11 +6,43 @@ import path from 'path'; import fs from 'fs'; import { v4 as uuid } from 'uuid'; import fetch from 'node-fetch'; +import { authenticator } from 'otplib'; +import QRCode from 'qrcode'; import { db } from '../db/database'; import { authenticate, demoUploadBlock } from '../middleware/auth'; import { JWT_SECRET } from '../config'; +import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto'; import { AuthRequest, User } from '../types'; +authenticator.options = { window: 1 }; + +const MFA_SETUP_TTL_MS = 15 * 60 * 1000; +const mfaSetupPending = new Map(); + +function getPendingMfaSecret(userId: number): string | null { + const row = mfaSetupPending.get(userId); + if (!row || Date.now() > row.exp) { + mfaSetupPending.delete(userId); + return null; + } + return row.secret; +} + +function stripUserForClient(user: User): Record { + const { + password_hash: _p, + maps_api_key: _m, + openweather_api_key: _o, + unsplash_api_key: _u, + mfa_secret: _mf, + ...rest + } = user; + return { + ...rest, + mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true), + }; +} + const router = express.Router(); const avatarDir = path.join(__dirname, '../../uploads/avatars'); @@ -110,7 +142,7 @@ router.post('/demo-login', (_req: Request, res: Response) => { const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@nomad.app') as User | undefined; if (!user) return res.status(500).json({ error: 'Demo user not found' }); const token = generateToken(user); - const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...safe } = user; + const safe = stripUserForClient(user) as Record; res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } }); }); @@ -157,7 +189,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { 'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)' ).run(username, email, password_hash, role); - const user = { id: result.lastInsertRowid, username, email, role, avatar: null }; + const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false }; const token = generateToken(user); res.status(201).json({ token, user: { ...user, avatar_url: null } }); @@ -183,24 +215,34 @@ router.post('/login', authLimiter, (req: Request, res: Response) => { return res.status(401).json({ error: 'Invalid email or password' }); } + if (user.mfa_enabled === 1 || user.mfa_enabled === true) { + const mfa_token = jwt.sign( + { id: Number(user.id), purpose: 'mfa_login' }, + JWT_SECRET, + { expiresIn: '5m' } + ); + return res.json({ mfa_required: true, mfa_token }); + } + db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); const token = generateToken(user); - const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = user; + const userSafe = stripUserForClient(user) as Record; - res.json({ token, user: { ...userWithoutSensitive, avatar_url: avatarUrl(user) } }); + res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } }); }); router.get('/me', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const user = db.prepare( - 'SELECT id, username, email, role, avatar, oidc_issuer, created_at FROM users WHERE id = ?' + 'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled FROM users WHERE id = ?' ).get(authReq.user.id) as User | undefined; if (!user) { return res.status(404).json({ error: 'User not found' }); } - res.json({ user: { ...user, avatar_url: avatarUrl(user) } }); + const base = stripUserForClient(user as User) as Record; + res.json({ user: { ...base, avatar_url: avatarUrl(user) } }); }); router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { @@ -267,10 +309,11 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => { ); const updated = db.prepare( - 'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?' - ).get(authReq.user.id) as Pick | undefined; + 'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?' + ).get(authReq.user.id) as Pick | undefined; - res.json({ success: true, user: { ...updated, maps_api_key: maskKey(updated?.maps_api_key), openweather_api_key: maskKey(updated?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); + const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; + res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); }); router.put('/me/settings', authenticate, (req: Request, res: Response) => { @@ -314,10 +357,11 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => { } const updated = db.prepare( - 'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?' - ).get(authReq.user.id) as Pick | undefined; + 'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?' + ).get(authReq.user.id) as Pick | undefined; - res.json({ success: true, user: { ...updated, maps_api_key: maskKey(updated?.maps_api_key), openweather_api_key: maskKey(updated?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); + const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; + res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); }); router.get('/me/settings', authenticate, (req: Request, res: Response) => { @@ -497,4 +541,107 @@ router.get('/travel-stats', authenticate, (req: Request, res: Response) => { }); }); +router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => { + const { mfa_token, code } = req.body as { mfa_token?: string; code?: string }; + if (!mfa_token || !code) { + return res.status(400).json({ error: 'Verification token and code are required' }); + } + try { + const decoded = jwt.verify(mfa_token, JWT_SECRET) as { id: number; purpose?: string }; + if (decoded.purpose !== 'mfa_login') { + return res.status(401).json({ error: 'Invalid verification token' }); + } + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id) as User | undefined; + if (!user || !(user.mfa_enabled === 1 || user.mfa_enabled === true) || !user.mfa_secret) { + return res.status(401).json({ error: 'Invalid session' }); + } + const secret = decryptMfaSecret(user.mfa_secret); + const tokenStr = String(code).replace(/\s/g, ''); + const ok = authenticator.verify({ token: tokenStr, secret }); + if (!ok) { + return res.status(401).json({ error: 'Invalid verification code' }); + } + db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); + const sessionToken = generateToken(user); + const userSafe = stripUserForClient(user) as Record; + res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } }); + } catch { + return res.status(401).json({ error: 'Invalid or expired verification token' }); + } +}); + +router.post('/mfa/setup', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') { + return res.status(403).json({ error: 'MFA is not available in demo mode.' }); + } + const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined; + if (row?.mfa_enabled) { + return res.status(400).json({ error: 'MFA is already enabled' }); + } + const secret = authenticator.generateSecret(); + mfaSetupPending.set(authReq.user.id, { secret, exp: Date.now() + MFA_SETUP_TTL_MS }); + const otpauth_url = authenticator.keyuri(authReq.user.email, 'NOMAD', secret); + QRCode.toDataURL(otpauth_url) + .then((qr_data_url: string) => { + res.json({ secret, otpauth_url, qr_data_url }); + }) + .catch(() => { + res.status(500).json({ error: 'Could not generate QR code' }); + }); +}); + +router.post('/mfa/enable', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { code } = req.body as { code?: string }; + if (!code) { + return res.status(400).json({ error: 'Verification code is required' }); + } + const pending = getPendingMfaSecret(authReq.user.id); + if (!pending) { + return res.status(400).json({ error: 'No MFA setup in progress. Start the setup again.' }); + } + const tokenStr = String(code).replace(/\s/g, ''); + const ok = authenticator.verify({ token: tokenStr, secret: pending }); + if (!ok) { + return res.status(401).json({ error: 'Invalid verification code' }); + } + const enc = encryptMfaSecret(pending); + db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( + enc, + authReq.user.id + ); + mfaSetupPending.delete(authReq.user.id); + res.json({ success: true, mfa_enabled: true }); +}); + +router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') { + return res.status(403).json({ error: 'MFA cannot be changed in demo mode.' }); + } + const { password, code } = req.body as { password?: string; code?: string }; + if (!password || !code) { + return res.status(400).json({ error: 'Password and authenticator code are required' }); + } + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(authReq.user.id) as User | undefined; + if (!user?.mfa_enabled || !user.mfa_secret) { + return res.status(400).json({ error: 'MFA is not enabled' }); + } + if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) { + return res.status(401).json({ error: 'Incorrect password' }); + } + const secret = decryptMfaSecret(user.mfa_secret); + const tokenStr = String(code).replace(/\s/g, ''); + const ok = authenticator.verify({ token: tokenStr, secret }); + if (!ok) { + return res.status(401).json({ error: 'Invalid verification code' }); + } + db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( + authReq.user.id + ); + mfaSetupPending.delete(authReq.user.id); + res.json({ success: true, mfa_enabled: false }); +}); + export default router; diff --git a/server/src/services/mfaCrypto.ts b/server/src/services/mfaCrypto.ts new file mode 100644 index 0000000..748f9bd --- /dev/null +++ b/server/src/services/mfaCrypto.ts @@ -0,0 +1,25 @@ +import crypto from 'crypto'; +import { JWT_SECRET } from '../config'; + +function getKey(): Buffer { + return crypto.createHash('sha256').update(`${JWT_SECRET}:mfa:v1`).digest(); +} + +/** Encrypt TOTP secret for storage in SQLite. */ +export function encryptMfaSecret(plain: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString('base64'); +} + +export function decryptMfaSecret(blob: string): string { + const buf = Buffer.from(blob, 'base64'); + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const enc = buf.subarray(28); + const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8'); +} diff --git a/server/src/types.ts b/server/src/types.ts index cce6dbd..68b12cf 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -13,6 +13,8 @@ export interface User { oidc_sub?: string | null; oidc_issuer?: string | null; last_login?: string | null; + mfa_enabled?: number | boolean; + mfa_secret?: string | null; created_at?: string; updated_at?: string; } From a091051387f1f0e259e28556f0b7d0dd0235a340 Mon Sep 17 00:00:00 2001 From: fgbona Date: Sat, 28 Mar 2026 22:09:38 -0300 Subject: [PATCH 2/2] feat/mfa: Removed install-server-deps.sh, .npmrc and .nvmrc --- scripts/install-server-deps.sh | 134 --------------------------------- server/.npmrc | 1 - server/.nvmrc | 1 - 3 files changed, 136 deletions(-) delete mode 100755 scripts/install-server-deps.sh delete mode 100644 server/.npmrc delete mode 100644 server/.nvmrc diff --git a/scripts/install-server-deps.sh b/scripts/install-server-deps.sh deleted file mode 100755 index 964a5fb..0000000 --- a/scripts/install-server-deps.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env bash -# Install server/node_modules on Linux (e.g. RHEL 8) when: -# - `node` points to NSolid → node-gyp downloads wrong headers (403) -# - GLIBC < 2.29 → better-sqlite3 prebuild fails → must compile from source -# - Python 3.6 (default on EL8) → node-gyp 12+ requires Python 3.8+ for its gyp scripts -# - GCC 8 (default g++ on EL8) → better-sqlite3 needs -std=c++20 (GCC 10+); use gcc-toolset -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT/server" - -# node-gyp: pick Python 3.8+ (required; 3.6 throws SyntaxError on walrus operator in gyp) -pick_python38() { - local py - if [[ -n "${PYTHON:-}" && -x "${PYTHON}" ]]; then - if "${PYTHON}" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' 2>/dev/null; then - echo "$PYTHON" - return 0 - fi - fi - for py in \ - /usr/bin/python3.12 /usr/bin/python3.11 /usr/bin/python3.10 /usr/bin/python3.9 /usr/bin/python3.8 \ - /usr/local/bin/python3.11 /usr/local/bin/python3.10 /usr/local/bin/python3.9 \ - "$(command -v python3 2>/dev/null || true)"; do - [[ -z "$py" || ! -x "$py" ]] && continue - if "$py" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' 2>/dev/null; then - echo "$py" - return 0 - fi - done - return 1 -} - -if ! PY="$(pick_python38)"; then - echo "ERROR: No Python 3.8+ found. node-gyp (used by better-sqlite3) needs Python ≥ 3.8." - echo "On RHEL 8 / AlmaLinux 8:" - echo " sudo dnf install -y python39 gcc-c++ make" - echo " export PYTHON=/usr/bin/python3.9" - echo " $0" - exit 1 -fi -export PYTHON="$PY" -export npm_config_python="$PY" -echo "Using PYTHON=$PYTHON ($("$PYTHON" -c 'import sys; print(sys.version.split()[0])' 2>/dev/null || echo unknown))" - -# Optional: force a real Node.js binary (bypasses NSolid on PATH), e.g. -# NODE_BINARY=/opt/nodejs/bin/node ../scripts/install-server-deps.sh -# nvm stores versions as v22.x.x — do NOT use .../versions/node/22/bin/node (wrong). -if [[ -n "${NODE_BINARY:-}" ]]; then - if [[ ! -x "$NODE_BINARY" ]]; then - echo "ERROR: NODE_BINARY is not executable: $NODE_BINARY" - echo "Hint: nvm path looks like: \$HOME/.nvm/versions/node/v22.21.1/bin/node" - exit 1 - fi - export PATH="$(dirname "$NODE_BINARY"):$PATH" - hash -r 2>/dev/null || true -fi - -# Load nvm if present so Node from nvm wins over /usr/bin/nsolid (must run before `node` check) -if [[ -z "${NODE_BINARY:-}" ]]; then - export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" - if [[ -s "$NVM_DIR/nvm.sh" ]]; then - # shellcheck source=/dev/null - . "$NVM_DIR/nvm.sh" - if [[ -f "$ROOT/server/.nvmrc" ]]; then - nvm install - nvm use - else - nvm install 22 - nvm use 22 - fi - hash -r 2>/dev/null || true - fi -fi - -NODE_BIN="$(command -v node 2>/dev/null || true)" -if [[ -z "$NODE_BIN" ]]; then - echo "ERROR: \`node\` not found. Install Node.js (nvm recommended) or set NODE_BINARY=/path/to/node" - exit 1 -fi - -BASE="$(basename "$(readlink -f "$NODE_BIN" 2>/dev/null || echo "$NODE_BIN")")" -if [[ "$BASE" == "nsolid" ]] || [[ "$NODE_BIN" == *nsolid* ]] || [[ "$(readlink -f "$NODE_BIN" 2>/dev/null || true)" == *nsolid* ]]; then - echo "ERROR: \`node\` still resolves to NSolid: $NODE_BIN" - echo " node-gyp then requests NSolid header tarballs and often gets HTTP 403." - echo "" - echo "Try one of:" - echo " 1) source ~/.nvm/nvm.sh && cd $ROOT/server && nvm use && $0" - echo " 2) NODE_BINARY=\$(ls -d \"\$HOME/.nvm/versions/node/v22\"*\"/bin/node\" 2>/dev/null | head -1) $0" - exit 1 -fi - -echo "Using node: $NODE_BIN ($("$NODE_BIN" -p 'process.version'))" - -# better-sqlite3 native build uses -std=c++20; GCC 8 only knows -std=c++2a — need gcc-toolset-12+ on RHEL 8 -gpp_supports_cxx20() { - local tmp - tmp="$(mktemp)" - if echo 'int main(){}' | g++ -std=c++20 -x c++ - -o "$tmp" 2>/dev/null; then - rm -f "$tmp" - return 0 - fi - rm -f "$tmp" - return 1 -} - -if ! gpp_supports_cxx20; then - for ts in gcc-toolset-13 gcc-toolset-12 gcc-toolset-11; do - en="/opt/rh/${ts}/enable" - if [[ -f "$en" ]]; then - # shellcheck source=/dev/null - . "$en" - if gpp_supports_cxx20; then - echo "Using newer g++ for C++20: $(command -v g++) ($($(command -v g++) -dumpversion))" - break - fi - fi - done -fi - -if ! gpp_supports_cxx20; then - echo "ERROR: \`g++\` does not support -std=c++20 (better-sqlite3 needs GCC 10+)." - echo "On RHEL 8 / AlmaLinux 8 install a toolchain and re-run this script:" - echo " sudo dnf install -y gcc-toolset-12-gcc-c++ make" - echo " source /opt/rh/gcc-toolset-12/enable" - echo " $0" - exit 1 -fi - -echo "Also required on EL8: sudo dnf install -y gcc-c++ make (if not already)" -echo "" - -# Prefer compiling native addons from source when prebuilds do not load (older GLIBC) -npm install --build-from-source "$@" diff --git a/server/.npmrc b/server/.npmrc deleted file mode 100644 index a69081d..0000000 --- a/server/.npmrc +++ /dev/null @@ -1 +0,0 @@ -# node-gyp reads PYTHON from the environment; do not set deprecated keys here (npm 10+ warns on unknown keys). diff --git a/server/.nvmrc b/server/.nvmrc deleted file mode 100644 index 2bd5a0a..0000000 --- a/server/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -22