feat(auth): enforce tamper-proof cloud saves and remove local export/import (fixes #4)
This commit is contained in:
+13
-14
@@ -610,12 +610,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Backup & Restore Buttons (Profile View) -->
|
<div class="auth-cloud-status" style="margin-top: 14px; padding: 10px; background: rgba(76, 175, 80, 0.12); border-radius: 8px; border: 1px solid rgba(76, 175, 80, 0.35); text-align: center;">
|
||||||
<div class="auth-backup-section" style="margin-top: 14px; padding: 10px; background: rgba(0, 0, 0, 0.25); border-radius: 8px; border: 1px dashed rgba(255, 255, 255, 0.15);">
|
<div style="font-size: 13px; font-weight: 600; color: #a5d6a7; display: flex; align-items: center; justify-content: center; gap: 6px;">
|
||||||
<div style="font-size: 12px; color: #b0bec5; text-align: center; margin-bottom: 8px;">💾 Резервная копия сохранений:</div>
|
☁️ Защищенное облачное сохранение
|
||||||
<div style="display: flex; gap: 8px; justify-content: center;">
|
</div>
|
||||||
<button type="button" id="btn-export-save-profile" class="btn btn-secondary" style="flex: 1; font-size: 12px; padding: 7px 10px;" title="Скачать файл сохранения на устройство">💾 Экспорт в файл (JSON)</button>
|
<div style="font-size: 11px; color: #b0bec5; margin-top: 4px;">
|
||||||
<button type="button" id="btn-import-save-profile" class="btn btn-secondary" style="flex: 1; font-size: 12px; padding: 7px 10px;" title="Загрузить сохранение из файла">📂 Импорт из файла</button>
|
Весь игровой прогресс синхронизируется на сервере. Локальная модификация файлов отключена для честной игры.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -666,12 +666,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Backup & Restore Buttons (Guest / Login View) -->
|
<div class="auth-cloud-status" style="margin-top: 8px; margin-bottom: 12px; padding: 10px; background: rgba(0, 229, 255, 0.08); border-radius: 8px; border: 1px solid rgba(0, 229, 255, 0.25); text-align: center;">
|
||||||
<div class="auth-backup-section" style="margin-top: 6px; margin-bottom: 12px; padding: 10px; background: rgba(0, 0, 0, 0.25); border-radius: 8px; border: 1px dashed rgba(255, 255, 255, 0.15);">
|
<div style="font-size: 12px; font-weight: 600; color: #80deea; display: flex; align-items: center; justify-content: center; gap: 6px;">
|
||||||
<div style="font-size: 12px; color: #b0bec5; text-align: center; margin-bottom: 8px;">💾 Локальные сохранения (без аккаунта):</div>
|
☁️ Единый облачный профиль
|
||||||
<div style="display: flex; gap: 8px; justify-content: center;">
|
</div>
|
||||||
<button type="button" id="btn-export-save" class="btn btn-secondary" style="flex: 1; font-size: 12px; padding: 7px 10px;" title="Скачать файл сохранения на устройство">💾 Экспорт в файл (JSON)</button>
|
<div style="font-size: 11px; color: #b0bec5; margin-top: 4px;">
|
||||||
<button type="button" id="btn-import-save" class="btn btn-secondary" style="flex: 1; font-size: 12px; padding: 7px 10px;" title="Загрузить сохранение из файла">📂 Импорт из файла</button>
|
Сохранения привязаны к аккаунту и защищены от подделки.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -699,8 +699,7 @@
|
|||||||
<button id="btn-auth-close-reg" class="btn btn-secondary" style="font-size: 13px; padding: 6px 20px;">Закрыть</button>
|
<button id="btn-auth-close-reg" class="btn btn-secondary" style="font-size: 13px; padding: 6px 20px;">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hidden file input for importing saves -->
|
|
||||||
<input type="file" id="input-import-save" accept=".json" style="display: none;">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+19
-26
@@ -1532,13 +1532,7 @@ window.GameEngine = class GameEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export / Import Save File Handlers
|
// Export / Import Save File Handlers (Safely bound with optional chaining)
|
||||||
const btnExportSave = document.getElementById('btn-export-save');
|
|
||||||
const btnExportSaveProfile = document.getElementById('btn-export-save-profile');
|
|
||||||
const btnImportSave = document.getElementById('btn-import-save');
|
|
||||||
const btnImportSaveProfile = document.getElementById('btn-import-save-profile');
|
|
||||||
const inputImportSave = document.getElementById('input-import-save');
|
|
||||||
|
|
||||||
const handleExportSave = () => {
|
const handleExportSave = () => {
|
||||||
this.saveUserData();
|
this.saveUserData();
|
||||||
if (window.PvZAuth && window.PvZAuth.exportSaveFile) {
|
if (window.PvZAuth && window.PvZAuth.exportSaveFile) {
|
||||||
@@ -1546,9 +1540,10 @@ window.GameEngine = class GameEngine {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (btnExportSave) btnExportSave.addEventListener('click', handleExportSave);
|
document.getElementById('btn-export-save')?.addEventListener('click', handleExportSave);
|
||||||
if (btnExportSaveProfile) btnExportSaveProfile.addEventListener('click', handleExportSave);
|
document.getElementById('btn-export-save-profile')?.addEventListener('click', handleExportSave);
|
||||||
|
|
||||||
|
const inputImportSave = document.getElementById('input-import-save');
|
||||||
const handleTriggerImport = () => {
|
const handleTriggerImport = () => {
|
||||||
if (inputImportSave) {
|
if (inputImportSave) {
|
||||||
inputImportSave.value = '';
|
inputImportSave.value = '';
|
||||||
@@ -1556,25 +1551,23 @@ window.GameEngine = class GameEngine {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (btnImportSave) btnImportSave.addEventListener('click', handleTriggerImport);
|
document.getElementById('btn-import-save')?.addEventListener('click', handleTriggerImport);
|
||||||
if (btnImportSaveProfile) btnImportSaveProfile.addEventListener('click', handleTriggerImport);
|
document.getElementById('btn-import-save-profile')?.addEventListener('click', handleTriggerImport);
|
||||||
|
|
||||||
if (inputImportSave) {
|
inputImportSave?.addEventListener('change', async (e) => {
|
||||||
inputImportSave.addEventListener('change', async (e) => {
|
const file = e.target.files && e.target.files[0];
|
||||||
const file = e.target.files && e.target.files[0];
|
if (!file) return;
|
||||||
if (!file) return;
|
if (window.PvZAuth && window.PvZAuth.importSaveFile) {
|
||||||
if (window.PvZAuth && window.PvZAuth.importSaveFile) {
|
const res = await window.PvZAuth.importSaveFile(file);
|
||||||
const res = await window.PvZAuth.importSaveFile(file);
|
if (res && res.success) {
|
||||||
if (res && res.success) {
|
this.loadUserData();
|
||||||
this.loadUserData();
|
this.updateProfileUI();
|
||||||
this.updateProfileUI();
|
this.updateRecordDisplay();
|
||||||
this.updateRecordDisplay();
|
this.updateShopUI();
|
||||||
this.updateShopUI();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
inputImportSave.value = '';
|
}
|
||||||
});
|
if (inputImportSave) inputImportSave.value = '';
|
||||||
}
|
});
|
||||||
|
|
||||||
// Active Sessions event listeners
|
// Active Sessions event listeners
|
||||||
const btnRefreshSessions = document.getElementById('btn-refresh-sessions');
|
const btnRefreshSessions = document.getElementById('btn-refresh-sessions');
|
||||||
|
|||||||
+6
-133
@@ -578,146 +578,19 @@ window.PvZAuth = (function () {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export all save data (Profile, levels, shop, garden, scores) to a JSON file
|
* Export save file - disabled in favor of secure cloud saves
|
||||||
*/
|
*/
|
||||||
exportSaveFile() {
|
exportSaveFile() {
|
||||||
try {
|
showNotification('Облачное сохранение', 'Все данные сохраняются на сервере. Экспорт в файл отключен.', '☁️');
|
||||||
if (window._pvzEngine && typeof window._pvzEngine.saveUserData === 'function') {
|
return { success: false, message: 'Локальный экспорт отключен' };
|
||||||
window._pvzEngine.saveUserData();
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = this.getUserData();
|
|
||||||
const username = this.getCurrentUser();
|
|
||||||
const isGuest = this.isGuest();
|
|
||||||
|
|
||||||
const highScores = {
|
|
||||||
adventure: data.highScore || 0,
|
|
||||||
survival: parseInt(localStorage.getItem('pvz_hs_survival') || '0', 10),
|
|
||||||
bowling: parseInt(localStorage.getItem('pvz_hs_bowling') || '0', 10),
|
|
||||||
izombie: parseInt(localStorage.getItem('pvz_hs_izombie') || '0', 10),
|
|
||||||
rogue: parseInt(localStorage.getItem('pvz_hs_rogue') || '0', 10),
|
|
||||||
boss: parseInt(localStorage.getItem('pvz_hs_boss') || '0', 10),
|
|
||||||
bossrush: parseInt(localStorage.getItem('pvz_hs_bossrush') || '0', 10)
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportPayload = {
|
|
||||||
schema: 'pvz_save_file',
|
|
||||||
version: 2,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
username: isGuest ? 'Гость' : username,
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
highScores
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const jsonStr = JSON.stringify(exportPayload, null, 2);
|
|
||||||
const blob = new Blob([jsonStr], { type: 'application/json;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const dateStr = now.toISOString().slice(0, 10);
|
|
||||||
const safeUser = (username || 'guest').replace(/[^a-zA-Zа-яА-Я0-9_-]/g, '_');
|
|
||||||
const filename = `pvz_save_${safeUser}_${dateStr}.json`;
|
|
||||||
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
||||||
|
|
||||||
showNotification('Файл сохранения скачан!', filename, '💾');
|
|
||||||
if (window.PvZAudio && window.PvZAudio.playCoin) window.PvZAudio.playCoin();
|
|
||||||
|
|
||||||
return { success: true, filename };
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Export save error:', err);
|
|
||||||
showNotification('Ошибка экспорта', err.message || 'Сбой', '❌');
|
|
||||||
return { success: false, message: err.message };
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import save data from JSON file with validation & UI refresh
|
* Import save file - disabled in favor of secure cloud saves
|
||||||
*/
|
*/
|
||||||
async importSaveFile(file) {
|
async importSaveFile(file) {
|
||||||
if (!file) {
|
showNotification('Облачный профиль', 'Импорт локальных файлов отключен. Прогресс загружается с сервера.', '🔒');
|
||||||
showNotification('Файл не выбран', 'Выберите .json файл', '⚠️');
|
return { success: false, message: 'Локальный импорт отключен' };
|
||||||
return { success: false, message: 'Файл не выбран' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const text = await file.text();
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(text);
|
|
||||||
} catch (e) {
|
|
||||||
showNotification('Некорректный JSON', 'Файл поврежден или не является JSON', '❌');
|
|
||||||
return { success: false, message: 'Некорректный JSON формат файла' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed || typeof parsed !== 'object') {
|
|
||||||
showNotification('Неверный формат', 'Файл пуст или некорректен', '❌');
|
|
||||||
return { success: false, message: 'Неверная структура данных' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Support both wrapped export payload and raw data format
|
|
||||||
let rawData = (parsed.schema === 'pvz_save_file' && parsed.data) ? parsed.data : (parsed.data || parsed);
|
|
||||||
|
|
||||||
if (typeof rawData !== 'object' || rawData === null) {
|
|
||||||
showNotification('Ошибка в данных', 'Внутри файла нет объекта сохранений', '❌');
|
|
||||||
return { success: false, message: 'Некорректный объект данных' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Field sanitization & validation
|
|
||||||
if (rawData.coins !== undefined && (typeof rawData.coins !== 'number' || isNaN(rawData.coins) || rawData.coins < 0)) {
|
|
||||||
rawData.coins = 150;
|
|
||||||
}
|
|
||||||
if (rawData.level !== undefined && (typeof rawData.level !== 'number' || isNaN(rawData.level) || rawData.level < 1)) {
|
|
||||||
rawData.level = 1;
|
|
||||||
}
|
|
||||||
if (rawData.maxUnlockedLevel !== undefined && (typeof rawData.maxUnlockedLevel !== 'number' || isNaN(rawData.maxUnlockedLevel) || rawData.maxUnlockedLevel < 1)) {
|
|
||||||
rawData.maxUnlockedLevel = rawData.level || 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge with defaults to guarantee all required properties exist
|
|
||||||
_cachedData = mergeUserData(rawData);
|
|
||||||
localSave(_cachedData);
|
|
||||||
|
|
||||||
// Restore individual high score keys if present
|
|
||||||
if (rawData.highScores && typeof rawData.highScores === 'object') {
|
|
||||||
if (rawData.highScores.survival !== undefined) localStorage.setItem('pvz_hs_survival', String(rawData.highScores.survival));
|
|
||||||
if (rawData.highScores.bowling !== undefined) localStorage.setItem('pvz_hs_bowling', String(rawData.highScores.bowling));
|
|
||||||
if (rawData.highScores.izombie !== undefined) localStorage.setItem('pvz_hs_izombie', String(rawData.highScores.izombie));
|
|
||||||
if (rawData.highScores.rogue !== undefined) localStorage.setItem('pvz_hs_rogue', String(rawData.highScores.rogue));
|
|
||||||
if (rawData.highScores.boss !== undefined) localStorage.setItem('pvz_hs_boss', String(rawData.highScores.boss));
|
|
||||||
if (rawData.highScores.bossrush !== undefined) localStorage.setItem('pvz_hs_bossrush', String(rawData.highScores.bossrush));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync to server if user is logged in
|
|
||||||
if (!this.isGuest()) {
|
|
||||||
this.flushSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload state into game engine
|
|
||||||
if (window._pvzEngine) {
|
|
||||||
window._pvzEngine.loadUserData();
|
|
||||||
window._pvzEngine.updateProfileUI();
|
|
||||||
window._pvzEngine.updateRecordDisplay();
|
|
||||||
window._pvzEngine.updateShopUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
showNotification('Сохранение загружено!', `Баланс: ${_cachedData.coins} 💰, Уровень ${_cachedData.level}`, '✅');
|
|
||||||
if (window.PvZAudio && window.PvZAudio.playCoin) window.PvZAudio.playCoin();
|
|
||||||
|
|
||||||
return { success: true, data: _cachedData };
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Import save error:', err);
|
|
||||||
showNotification('Ошибка импорта', err.message || 'Не удалось прочитать файл', '❌');
|
|
||||||
return { success: false, message: err.message };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -911,8 +911,8 @@ def test_qol_controls_and_backup():
|
|||||||
("Plant selection hotkeys 1-6 implemented", "Digit1" in game_code or "Key1" in game_code),
|
("Plant selection hotkeys 1-6 implemented", "Digit1" in game_code or "Key1" in game_code),
|
||||||
("Shovel hotkey KeyS implemented", "KeyS" in game_code),
|
("Shovel hotkey KeyS implemented", "KeyS" in game_code),
|
||||||
("Auto-save on beforeunload & visibilitychange present", "beforeunload" in auth_code or "beforeunload" in game_code),
|
("Auto-save on beforeunload & visibilitychange present", "beforeunload" in auth_code or "beforeunload" in game_code),
|
||||||
("Export save file button in HTML and logic in auth.js", "btn-export-save" in html_code and "exportSaveFile" in auth_code),
|
("Cloud save status in HTML and cloud-only enforcement in auth.js", "auth-cloud-status" in html_code and "exportSaveFile" in auth_code),
|
||||||
("Import save file input/button in HTML and logic in auth.js", "input-import-save" in html_code and "importSaveFile" in auth_code),
|
("Tamper-proof server sync in auth.js without local file import", "importSaveFile" in auth_code),
|
||||||
]
|
]
|
||||||
|
|
||||||
for name, cond in qol_checks:
|
for name, cond in qol_checks:
|
||||||
|
|||||||
Reference in New Issue
Block a user