Files
ai-qpid-pvz/game/js/systems/auth.js
T

625 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Auth & Profile Management System — Server-backed (SQLite via /api/auth/)
// Falls back to localStorage if server is unavailable.
window.PvZAuth = (function () {
const CURRENT_USER_KEY = 'pvz_current_user';
const TOKEN_KEY = 'pvz_token';
const LOCAL_DATA_KEY = 'pvz_local_data'; // local fallback cache
const API_BASE = '/api/auth';
/* ─── Session Token Helpers ─── */
function getToken() {
try { return localStorage.getItem(TOKEN_KEY) || null; } catch (_) { return null; }
}
function setToken(token) {
try {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
} catch (_) {}
}
function clearToken() {
try { localStorage.removeItem(TOKEN_KEY); } catch (_) {}
}
/* ─── Password hashing (djb2 for lightweight fast client hashing) ─── */
function hashPassword(str) {
if (!str) return '';
let h = 0;
for (let i = 0; i < str.length; i++) {
h = ((h << 5) - h) + str.charCodeAt(i);
h |= 0;
}
return h.toString(16);
}
/* ─── Validation Helpers ─── */
function validateUsername(username) {
const u = (username || '').trim();
if (!u) return { valid: false, message: 'Введите имя пользователя' };
if (u.length < 3) return { valid: false, message: 'Логин должен содержать минимум 3 символа' };
if (u.length > 20) return { valid: false, message: 'Логин не должен превышать 20 символов' };
if (!/^[a-zA-Zа-яА-ЯёЁ0-9_-]+$/.test(u)) {
return { valid: false, message: 'Логин может содержать только буквы, цифры, "-" и "_"' };
}
const reserved = ['гость', 'guest', 'admin', 'null', 'undefined', 'system', 'anonymous'];
if (reserved.includes(u.toLowerCase())) {
return { valid: false, message: 'Это имя пользователя зарезервировано' };
}
return { valid: true, username: u };
}
function validatePassword(password) {
const p = password || '';
if (!p) return { valid: false, message: 'Введите пароль' };
if (p.length < 4) return { valid: false, message: 'Пароль должен содержать не менее 4 символов' };
return { valid: true, password: p };
}
/* ─── Notification Toast Helper ─── */
function showNotification(title, subtitle = '', icon = '💾') {
try {
let toast = document.getElementById('achievement-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'achievement-toast';
toast.className = 'achievement-toast';
document.body.appendChild(toast);
}
toast.innerHTML = `
<div class="ach-toast-icon">${icon}</div>
<div class="ach-toast-text">
<div class="ach-toast-subtitle">${subtitle || 'УВЕДОМЛЕНИЕ'}</div>
<div class="ach-toast-title">${title}</div>
</div>
`;
toast.classList.add('visible');
setTimeout(() => {
if (toast) toast.classList.remove('visible');
}, 3500);
} catch (_) {}
}
/* ─── Default blank profile ─── */
function getDefaultData() {
return {
coins: 150,
level: 1,
maxUnlockedLevel: 1,
highScore: 0,
theme: 'classic',
stats: {
zombiesKilled: 0,
sunProduced: 0,
plantsPlanted: 0,
levelsCompleted: 0,
coinsEarned: 0
},
upgrades: { extraSlot: false, hasRake: false },
unlockedPlants: ['sunflower', 'peashooter', 'wallnut'],
selectedDeck: ['sunflower', 'peashooter', 'wallnut'],
materials: { bioHumus: 10, scrapMetal: 5, sunEssence: 20, crystals: 0 },
plantLevels: { peashooter: 1, sunflower: 1, wallnut: 1, potatomine: 1,
repeater: 1, snowpea: 1, chomper: 1 },
perks: { magnetism: 0, startSun: 0, fastCool: 0, hardyRoots: 0, luckyDrop: 0 },
zenGarden: {
pots: [
{ id: 0, plantType: 'sunflower', stage: 2, thirstTimer: 0, needsFertilizer: false, happyTimer: 60, coinTimer: 5 },
{ id: 1, plantType: 'peashooter', stage: 1, thirstTimer: 0, needsFertilizer: false, happyTimer: 45, coinTimer: 8 },
{ id: 2, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 },
{ id: 3, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 },
{ id: 4, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 },
{ id: 5, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 }
],
fertilizerCount: 5,
chocolateCount: 2,
snail: { x: 100, targetX: 300, dir: 1, isAwake: true, sleepTimer: 120, boostTimer: 0 },
lastVisit: Date.now()
},
treeOfWisdom: { height: 0, cheats: {} },
achievements: {}
};
}
/* ─── In-memory current user data ─── */
let _cachedData = null;
function localSave(data) {
try { localStorage.setItem(LOCAL_DATA_KEY, JSON.stringify(data)); } catch (_) {}
}
function localLoad() {
try { return JSON.parse(localStorage.getItem(LOCAL_DATA_KEY) || 'null'); } catch (_) { return null; }
}
/* ─── Profile change event listeners ─── */
const _profileListeners = new Set();
function onProfileChange(fn) {
if (typeof fn === 'function') _profileListeners.add(fn);
}
function offProfileChange(fn) {
_profileListeners.delete(fn);
}
function notifyProfileChange(data) {
_profileListeners.forEach(fn => {
try { fn(data); } catch (e) { console.error('Profile listener error:', e); }
});
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('pvz:profile-updated', { detail: data }));
}
} catch (_) {}
}
/* ─── Non-blocking GET server call with Bearer Token Authorization ─── */
async function apiGet(endpoint) {
try {
const headers = {};
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const resp = await fetch(`${API_BASE}/${endpoint}`, {
method: 'GET',
headers
});
if (resp.status === 401) {
clearToken();
const json = await resp.json().catch(() => ({}));
return { success: false, status: 401, message: json.message || 'Сессия истекла или недействительна' };
}
const json = await resp.json();
return json;
} catch (e) {
return { success: false, message: 'Сервер авторизации недоступен' };
}
}
/* ─── Non-blocking server call with Bearer Token Authorization ─── */
async function apiPost(endpoint, body = {}, keepalive = false) {
try {
const headers = { 'Content-Type': 'application/json' };
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Attach token in JSON body as well for maximum compatibility
const payload = (typeof body === 'object' && body !== null && token)
? { token, ...body }
: body;
const fetchOptions = {
method: 'POST',
headers,
body: JSON.stringify(payload)
};
if (keepalive && typeof fetch === 'function') {
fetchOptions.keepalive = true;
}
const resp = await fetch(`${API_BASE}/${endpoint}`, fetchOptions);
if (resp.status === 401) {
// Clear invalid or expired session token
clearToken();
const json = await resp.json().catch(() => ({}));
return { success: false, status: 401, message: json.message || 'Сессия истекла или недействительна' };
}
const json = await resp.json();
return json;
} catch (e) {
return { success: false, message: 'Сервер авторизации недоступен' };
}
}
/* ─── Debounced save (prevents save storms on rapid updates) ─── */
let _saveTimer = null;
let _hasSyncedFromServer = false;
function scheduleSave() {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(async () => {
const user = localStorage.getItem(CURRENT_USER_KEY);
if (!user || user === 'Гость' || !_cachedData) return;
if (!_hasSyncedFromServer && getToken()) {
await authInstance.fetchProfile();
}
apiPost('save', { username: user, data: _cachedData });
}, 1500);
}
function mergeUserData(serverData, localData = null) {
const def = getDefaultData();
if (!serverData && !localData) return def;
const base = serverData || localData || def;
const loc = localData || {};
// Combine unlocked plants uniquely
const unlocked = Array.from(new Set([
...def.unlockedPlants,
...(loc.unlockedPlants || []),
...(base.unlockedPlants || [])
]));
// Combine selected deck
const selDeck = (base.selectedDeck && base.selectedDeck.length > 0)
? base.selectedDeck
: ((loc.selectedDeck && loc.selectedDeck.length > 0) ? loc.selectedDeck : def.selectedDeck);
return {
...def,
...loc,
...base,
coins: base.coins !== undefined ? base.coins : (loc.coins !== undefined ? loc.coins : def.coins),
level: Math.max(base.level || 1, loc.level || 1),
maxUnlockedLevel: Math.max(base.maxUnlockedLevel || 1, loc.maxUnlockedLevel || 1, base.level || 1, loc.level || 1),
highScore: Math.max(base.highScore || 0, loc.highScore || 0),
theme: base.theme || loc.theme || def.theme,
stats: {
...def.stats,
...(loc.stats || {}),
...(base.stats || {})
},
upgrades: {
...def.upgrades,
...(loc.upgrades || {}),
...(base.upgrades || {}),
unlockedPlants: unlocked,
selectedDeck: selDeck
},
unlockedPlants: unlocked,
selectedDeck: selDeck,
materials: {
...def.materials,
...(loc.materials || {}),
...(base.materials || {})
},
plantLevels: {
...def.plantLevels,
...(loc.plantLevels || {}),
...(base.plantLevels || {})
},
perks: {
...def.perks,
...(loc.perks || {}),
...(base.perks || {})
},
zenGarden: base.zenGarden
? {
...def.zenGarden,
...(loc.zenGarden || {}),
...base.zenGarden,
pots: base.zenGarden.pots || (loc.zenGarden && loc.zenGarden.pots) || def.zenGarden.pots,
snail: { ...def.zenGarden.snail, ...((loc.zenGarden && loc.zenGarden.snail) || {}), ...(base.zenGarden.snail || {}) }
}
: (loc.zenGarden || def.zenGarden),
treeOfWisdom: base.treeOfWisdom || loc.treeOfWisdom || def.treeOfWisdom,
achievements: {
...(loc.achievements || {}),
...(base.achievements || {})
}
};
}
const authInstance = {
/* ── Public API ── */
getCurrentUser() {
return localStorage.getItem(CURRENT_USER_KEY) || 'Гость';
},
isGuest() {
const u = localStorage.getItem(CURRENT_USER_KEY);
return !u || u === 'Гость';
},
getToken,
setToken,
clearToken,
getDefaultData,
validateUsername,
validatePassword,
apiGet,
apiPost,
scheduleSave,
showNotification,
onProfileChange,
offProfileChange,
notifyProfileChange,
/**
* User Login
*/
async login(username, password, callback = null) {
const userVal = validateUsername(username);
if (!userVal.valid) {
if (typeof callback === 'function') callback(false, null, userVal.message);
return { success: false, message: userVal.message };
}
const passVal = validatePassword(password);
if (!passVal.valid) {
if (typeof callback === 'function') callback(false, null, passVal.message);
return { success: false, message: passVal.message };
}
const cleanUser = userVal.username;
const pwdHash = hashPassword(passVal.password);
const res = await apiPost('login', { username: cleanUser, passwordHash: pwdHash });
if (res && res.success && res.data) {
localStorage.setItem(CURRENT_USER_KEY, cleanUser);
if (res.token) {
setToken(res.token);
}
_cachedData = mergeUserData(res.data);
localSave(_cachedData);
if (window.GameEngine && window._pvzEngine) {
window._pvzEngine.loadUserData();
if (typeof window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI();
if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay();
if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI();
}
notifyProfileChange(_cachedData);
if (typeof callback === 'function') callback(true, _cachedData, null);
return { success: true, username: cleanUser, token: res.token, data: _cachedData };
} else {
const errorMsg = (res && res.message) ? res.message : 'Неверный логин или пароль';
if (typeof callback === 'function') callback(false, null, errorMsg);
return { success: false, message: errorMsg };
}
},
/**
* User Registration
*/
async register(username, password, callback = null) {
const userVal = validateUsername(username);
if (!userVal.valid) {
if (typeof callback === 'function') callback(false, null, userVal.message);
return { success: false, message: userVal.message };
}
const passVal = validatePassword(password);
if (!passVal.valid) {
if (typeof callback === 'function') callback(false, null, passVal.message);
return { success: false, message: passVal.message };
}
const cleanUser = userVal.username;
const pwdHash = hashPassword(passVal.password);
const initData = _cachedData || getDefaultData();
const res = await apiPost('register', { username: cleanUser, passwordHash: pwdHash, initialData: initData });
if (res && res.success) {
localStorage.setItem(CURRENT_USER_KEY, cleanUser);
if (res.token) {
setToken(res.token);
}
_cachedData = mergeUserData(res.data || initData);
localSave(_cachedData);
if (window.GameEngine && window._pvzEngine) {
window._pvzEngine.loadUserData();
if (typeof window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI();
if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay();
if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI();
}
notifyProfileChange(_cachedData);
if (typeof callback === 'function') callback(true, _cachedData, null);
return { success: true, username: cleanUser, token: res.token, data: _cachedData };
} else {
const errorMsg = (res && res.message) ? res.message : 'Ошибка при регистрации';
if (typeof callback === 'function') callback(false, null, errorMsg);
return { success: false, message: errorMsg };
}
},
loginAsGuest() {
clearToken();
localStorage.setItem(CURRENT_USER_KEY, 'Гость');
notifyProfileChange(this.getUserData());
return { success: true, user: 'Гость' };
},
logout() {
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
const user = this.getCurrentUser();
const token = getToken();
if (user !== 'Гость' && _cachedData) {
apiPost('save', { username: user, data: _cachedData });
}
if (token) {
apiPost('logout', { token });
}
this.loginAsGuest();
},
/**
* Strictly Server-Authoritative Profile Sync:
* When user is logged in, fetches /api/auth/profile (with fallback to /api/users)
* and applies it directly to the game state.
*/
async fetchProfile() {
const token = getToken();
const user = this.getCurrentUser();
if (!token || user === 'Гость') return null;
try {
let profileData = null;
// 1. Try dedicated profile endpoint
const res = await apiGet('profile');
if (res && res.success && res.data) {
profileData = res.data;
} else if (res && res.status === 401) {
clearToken();
return null;
}
// 2. Fallback to /api/users which is universally supported across backend versions
if (!profileData) {
try {
const usersResp = await fetch('/api/users');
if (usersResp.ok) {
const usersJson = await usersResp.json();
if (usersJson && usersJson.success && Array.isArray(usersJson.users)) {
const matched = usersJson.users.find(u => u.username && u.username.toLowerCase() === user.toLowerCase());
if (matched) {
profileData = matched;
}
}
}
} catch (_) {}
}
if (profileData) {
_hasSyncedFromServer = true;
_cachedData = mergeUserData(profileData);
localSave(_cachedData);
if (window._pvzEngine) {
window._pvzEngine.loadUserData();
if (typeof window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI();
if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay();
if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI();
}
notifyProfileChange(_cachedData);
return _cachedData;
}
} catch (e) {
console.warn('Profile fetch from server error:', e);
}
return null;
},
async syncFromServer() {
return this.fetchProfile();
},
/**
* Active Device Sessions Management
*/
async fetchSessions() {
const token = getToken();
if (!token || this.isGuest()) return [];
try {
const res = await apiGet('sessions');
if (res && res.success && Array.isArray(res.sessions)) {
return res.sessions.map(s => ({
token: s.token || '',
deviceName: s.deviceName || s.device_name || 'Браузер / Компьютер',
ipAddress: s.ipAddress || s.ip_address || '127.0.0.1',
createdAt: s.createdAt || s.created_at || '',
lastActive: s.lastActive || s.last_active || s.createdAt || '',
isCurrent: Boolean(s.isCurrent || s.is_current || (token && s.token === token))
}));
}
} catch (e) {
console.error('Fetch sessions error:', e);
}
return [];
},
async terminateSession(sessionToken) {
if (!sessionToken) return { success: false, message: 'Не указан токен сессии' };
const res = await apiPost('sessions/terminate', { sessionToken, token: sessionToken });
return res || { success: false, message: 'Ошибка сети' };
},
async terminateOtherSessions() {
const res = await apiPost('sessions/terminate', { allOthers: true, all_others: true });
return res || { success: false, message: 'Ошибка сети' };
},
getUserData() {
if (_cachedData) return _cachedData;
const local = localLoad();
if (local) {
_cachedData = mergeUserData(local);
} else {
_cachedData = getDefaultData();
}
// Background refresh from server if logged in
if (!this.isGuest() && getToken()) {
this.fetchProfile();
}
return _cachedData;
},
saveUserData(data) {
_cachedData = _cachedData
? { ..._cachedData, ...data }
: { ...getDefaultData(), ...data };
localSave(_cachedData); // instant local write for both guests and registered users
if (!this.isGuest()) {
scheduleSave(); // debounced server sync for logged-in accounts
}
},
/** Force immediate server save (call on game-over / victory / visibility hidden / unload) */
flushSave(keepalive = false) {
if (this.isGuest() || !_cachedData) return;
if (!_hasSyncedFromServer && getToken()) return; // Don't flush stale unsynced local cache over server
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
apiPost('save', { username: this.getCurrentUser(), data: _cachedData }, keepalive);
},
/**
* Export save file - disabled in favor of secure cloud saves
*/
exportSaveFile() {
showNotification('Облачное сохранение', 'Все данные сохраняются на сервере. Экспорт в файл отключен.', '☁️');
return { success: false, message: 'Локальный экспорт отключен' };
},
/**
* Import save file - disabled in favor of secure cloud saves
*/
async importSaveFile(file) {
showNotification('Облачный профиль', 'Импорт локальных файлов отключен. Прогресс загружается с сервера.', '🔒');
return { success: false, message: 'Локальный импорт отключен' };
}
};
/* ─── Auto-save on page exit / tab switch ─── */
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
if (window._pvzEngine && typeof window._pvzEngine.saveUserData === 'function') {
window._pvzEngine.saveUserData();
}
authInstance.flushSave();
}
});
window.addEventListener('beforeunload', () => {
if (window._pvzEngine && typeof window._pvzEngine.saveUserData === 'function') {
window._pvzEngine.saveUserData();
}
authInstance.flushSave(true);
});
// Auto-sync user data on startup if logged in
setTimeout(() => {
if (!authInstance.isGuest() && authInstance.getToken()) {
authInstance.syncFromServer();
}
}, 150);
}
return authInstance;
})();