v18 — безопасность (SHA-256, CSP, XSS-защита, сессии, админ-панель)

This commit is contained in:
Dauren777 2026-06-01 11:41:08 +00:00
parent b120586a01
commit 56e2cb1cda

View File

@ -9,6 +9,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Галикон">
<meta name="theme-color" content="#0F1218">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: https:; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; connect-src 'self'">
<style>
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7;--red:#FF6B6B;--green:#4CAF50;--gold:#FFD700}
*{box-sizing:border-box;margin:0;padding:0}
@ -207,7 +208,7 @@ input[type=file]{display:none}
<h2>&#x1F511; Придумай логин и пароль</h2>
<p class="hint">Логин — твоё имя в приложении. Пароль — секрет.</p>
<input type="text" id="rLogin" placeholder="Логин (латиница, без пробелов)">
<input type="password" id="rPass" placeholder="Пароль (минимум 3 символа)">
<input type="password" id="rPass" placeholder="Пароль (минимум 3 символа)" oninput="showPassStrength()"><div id="passStrength" style="font-size:12px;margin-bottom:4px"></div>
<div class="reg-nav">
<button class="btn outline" onclick="prevStep()">&#x2190; Назад</button>
<button class="btn" onclick="nextStep()">Дальше &#x2192;</button>
@ -312,6 +313,158 @@ input[type=file]{display:none}
<div class="lightbox" id="lightbox" style="display:none" onclick="this.style.display='none'"></div>
<script>
// === SECURITY ===
async function hashPass(pass) {
const encoder = new TextEncoder();
const data = encoder.encode(pass + 'galikon_salt_2026');
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2,'0')).join('');
}
// Session timeout (30 min inactivity)
let sessionTimer;
function resetSession() {
if(!currentUser) return;
clearTimeout(sessionTimer);
sessionTimer = setTimeout(() => {
if(currentUser) { toast('&#x23F0; Сессия истекла. Войди заново.'); doLogout(); }
}, 30 * 60 * 1000);
}
document.addEventListener('click', resetSession);
document.addEventListener('keydown', resetSession);
document.addEventListener('touchstart', resetSession);
// Login attempt rate limiting
let loginAttempts = 0, loginBlocked = false;
function checkLoginRate() {
if(loginBlocked) { showErr('Слишком много попыток. Подожди 1 минуту.'); return false; }
loginAttempts++;
if(loginAttempts >= 5) {
loginBlocked = true;
showErr('5 неудачных попыток. Подожди 1 минуту.');
setTimeout(() => { loginBlocked = false; loginAttempts = 0; hideErr(); }, 60000);
return false;
}
return true;
}
// Sanitize text to prevent XSS
function sanitize(str) {
if(!str) return '';
return String(str).replace(/[<>"'&]/g, c => ({'<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;','&':'&amp;'}[c]));
}
// Encrypt sensitive data in localStorage
async function encryptData(data) {
const json = JSON.stringify(data);
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey('raw', encoder.encode('galikon_key_2026'), {name:'AES-GCM'}, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt({name:'AES-GCM', iv}, key, encoder.encode(json));
return { iv: Array.from(iv), data: Array.from(new Uint8Array(encrypted)) };
}
// Check password strength
function checkPassStrength(pass) {
if(!pass || pass.length < 3) return { ok: false, msg: 'Минимум 3 символа' };
let score = 0;
if(pass.length >= 6) score++;
if(pass.length >= 10) score++;
if(/[A-Z]/.test(pass)) score++;
if(/[0-9]/.test(pass)) score++;
if(/[!@#$%^&*]/.test(pass)) score++;
if(score <= 1) return { ok: true, msg: '&#x1F7E1; Слабый пароль', color: '#FFD700' };
if(score <= 3) return { ok: true, msg: '&#x1F7E2; Средний пароль', color: '#4CAF50' };
return { ok: true, msg: '&#x1F7E2;&#x1F7E2;&#x1F7E2; Сильный пароль', color: '#4CAF50' };
}
// === ADMIN ===
const ADMIN_HASH = "1d7623489c0b8ae06d079d7d64b5e414e7132d0041ff08e1272c5b8a2112685c";
function isAdmin() { return currentUser && currentUser.login === 'admin'; }
async function adminCheck() {
const pass = prompt('Введи пароль администратора:');
if(!pass) return false;
const encoder = new TextEncoder();
const data = encoder.encode(pass + 'galikon_salt_2026');
const hash = await crypto.subtle.digest('SHA-256', data);
const hashStr = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2,'0')).join('');
return hashStr === ADMIN_HASH;
}
async function showAdminPanel() {
if(!await adminCheck()) { toast('Неверный пароль!'); return; }
const users = LS('users') || [];
const c = document.getElementById('mainContent');
let h = `<div class="card"><h3>&#x1F6E1; Админ-панель</h3>
<p class="muted">Пользователей: <strong>${users.length}</strong></p></div>`;
users.forEach(u => {
h += `<div class="card" style="padding:14px">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<strong>${sanitize(u.name)}</strong> <span class="badge blue">${u.role||'спортсмен'}</span>
<div class="muted">Логин: ${sanitize(u.login)} | ${u.sport||'—'} | ${u.club||'—'} | &#x2B50; ${u.stars||0}</div>
</div>
<div>
<button class="btn danger small" onclick="adminDeleteUser(${u.id})">&#x1F5D1;</button>
<button class="btn small outline" onclick="adminResetPass(${u.id})">&#x1F512;</button>
</div>
</div>
</div>`;
});
h += '<button class="btn small outline" onclick="showPage(currentPage)" style="margin-top:8px">&#x2190; Назад</button>';
c.innerHTML = h;
}
async function adminDeleteUser(id) {
if(!await adminCheck()) return;
const users = LS('users') || [];
const updated = users.filter(u => u.id !== id);
SS('users', updated);
if(currentUser && currentUser.id === id) doLogout();
showAdminPanel();
toast('Пользователь удалён');
}
async function adminResetPass(id) {
if(!await adminCheck()) return;
const newPass = prompt('Новый пароль:');
if(!newPass||newPass.length<3) { toast('Минимум 3 символа!'); return; }
const users = LS('users') || [];
const idx = users.findIndex(u => u.id === id);
if(idx < 0) return;
const encoder = new TextEncoder();
const data = encoder.encode(newPass + 'galikon_salt_2026');
const hash = await crypto.subtle.digest('SHA-256', data);
users[idx].pass = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2,'0')).join('');
SS('users', users);
toast('Пароль сброшен! Новый: ' + newPass);
}
// Add admin button to login screen
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
const loginBox = document.querySelector('#loginScreen .login-box');
if(!loginBox) return;
const adminBtn = document.createElement('button');
adminBtn.className = 'btn small outline';
adminBtn.textContent = '&#x1F6E1; Админ';
adminBtn.style.cssText = 'margin-top:8px;width:100%;font-size:11px;opacity:0.5';
adminBtn.onclick = async () => {
if(await adminCheck()) {
hideErr();
currentUser = { id: '_admin_', login: 'admin', name: 'Администратор', role: 'admin' };
document.getElementById('loginScreen').classList.remove('active');
document.getElementById('appScreen').classList.add('active');
renderAll();
toast('&#x1F6E1; Режим администратора');
}
};
loginBox.appendChild(adminBtn);
}, 100);
});
// === DATA ===
const LS=(k)=>{try{return JSON.parse(localStorage.getItem('g_'+k))}catch{return null}};
const SS=(k,v)=>{try{localStorage.setItem('g_'+k,JSON.stringify(v))}catch{toast('Память полна!')}};
@ -320,15 +473,18 @@ let currentUser=null, currentPage='profile', currentChat=null, chatFilter='all';
function toast(m){const t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),2000)}
// === LOGIN ===
function doLogin(){
const u=document.getElementById('lUser').value.trim().toLowerCase();
async function doLogin(){
if(!checkLoginRate()) return;
const u=sanitize(document.getElementById('lUser').value.trim().toLowerCase());
const p=document.getElementById('lPass').value;
if(!u||!p){showErr('Введи логин и пароль');return}
const users=LS('users')||[];
const user=users.find(x=>x.login===u&&x.pass===p);
const hash=await hashPass(p);
const user=users.find(x=>x.login===u&&(x.pass===hash||x.pass===p));
if(!user){showErr('Неверный логин или пароль');return}
if(!user.role) user.role='athlete';
currentUser=user;
resetSession();
currentPage='profile';
document.getElementById('loginScreen').classList.remove('active');
document.getElementById('appScreen').classList.add('active');
@ -386,14 +542,23 @@ function nextStep(){
if(regStep<8){buildStepDots();showRegStep(regStep+1)}else finishReg()
}
function prevStep(){if(regStep>1)showRegStep(regStep-1);buildStepDots()}
function showPassStrength(){
const s = checkPassStrength(document.getElementById('rPass').value);
const el = document.getElementById('passStrength');
if(s.msg) el.innerHTML = s.msg;
else el.innerHTML = '';
}
function autoAge(){const b=document.getElementById('rBirth').value;if(!b)return;const bd=new Date(b),td=new Date();let a=td.getFullYear()-bd.getFullYear();const m=td.getMonth()-bd.getMonth();if(m<0||(m===0&&td.getDate()<bd.getDate()))a--;document.getElementById('rAge').value=a}
function previewRegPhoto(){const f=document.getElementById('rPhotoFile').files[0];if(!f)return;const r=new FileReader();r.onload=e=>{regPhoto=e.target.result;const p=document.getElementById('rPhotoPreview');p.src=e.target.result;p.style.display='block'};r.readAsDataURL(f)}
document.querySelectorAll('.avatar-opt').forEach(a=>{a.addEventListener('click',()=>{document.querySelectorAll('.avatar-opt').forEach(x=>x.classList.remove('selected'));a.classList.add('selected');regAvatar=a.dataset.emoji;regPhoto=null;document.getElementById('rPhotoPreview').style.display='none'})});
function finishReg(){
async function finishReg(){
const name=document.getElementById('rName').value.trim();
const login=document.getElementById('rLogin').value.trim().toLowerCase();
const pass=document.getElementById('rPass').value;
const strength=checkPassStrength(pass);
if(!strength.ok){toast(strength.msg);return}
const sport=document.getElementById('rSport').value;
const birth=document.getElementById('rBirth').value;
autoAge();
@ -412,7 +577,7 @@ function finishReg(){
if(!name||!login||!pass||!sport||!regRole){toast('Заполни обязательные поля!');return}
const user={
id:Date.now(),name,login,pass,sport,birth,age,country,city,club,coach,rank,goal,phone,email,
id:Date.now(),name,login,pass: await hashPass(pass),sport,birth,age,country,city,club,coach,rank,goal,phone,email,
role:regRole,childName:childName||'',
avatar:regPhoto||regAvatar,photo:regPhoto||null,
created:new Date().toISOString(),
@ -423,6 +588,7 @@ function finishReg(){
users.push(user);
SS('users',users);
currentUser=user;
resetSession();
document.getElementById('regScreen').classList.remove('active');
document.getElementById('appScreen').classList.add('active');
// reset form
@ -484,6 +650,17 @@ function showPage(page){
renderPage();
}
function renderPage(){
if(isAdmin()){
const c=document.getElementById('mainContent');
switch(currentPage){
case 'profile': showAdminPanel(); return;
case 'diary': showAdminPanel(); return;
case 'chat': c.innerHTML='<div class="card"><h3>&#x1F6E1; Администратор</h3><p class="muted">Чаты доступны только пользователям.</p><button class="btn small outline" onclick="showAdminPanel()">&#x2190; Панель</button></div>'; return;
default: showAdminPanel(); return;
}
}
const c=document.getElementById('mainContent');
switch(currentPage){
case 'profile':c.innerHTML=renderProfile();break;
@ -523,7 +700,7 @@ function renderProfile(){
const h=`
<div class="profile-header">
<div class="avatar">${u.photo?`<img src="${u.photo}">`:av}</div>
<h2>${u.name}</h2>
<h2>${sanitize(u.name)}</h2>
<div class="tag">${u.sport}</div>
<div style="margin-top:4px;font-size:13px;color:var(--cyan)">${roleLabel}</div>
${u.rank?`<div style="margin-top:4px;font-size:13px;color:var(--gray-500)">${u.rank}</div>`:''}
@ -789,7 +966,7 @@ function renderChatList(){
const unread=chatMsgs.filter(m=>m.to===uid()&&!m.read).length;
h+=`<div class="chat-list-item" onclick="openChat(${u.id})">
<div class="av">${u.photo?`<img src="${u.photo}" style="width:100%;height:100%;border-radius:50%;object-fit:cover">`:u.avatar||u.name.charAt(0)}</div>
<div class="info"><div class="name">${u.name}${u.role?` <span class="badge blue" style="font-size:9px">${u.role==='coach'?'Тренер':u.role==='parent'?'Родитель':'Спортсмен'}</span>`:''}</div><div class="last">${last?last.text:'Начни общение'}</div></div>
<div class="info"><div class="name">${sanitize(u.name)}${u.role?` <span class="badge blue" style="font-size:9px">${u.role==='coach'?'Тренер':u.role==='parent'?'Родитель':'Спортсмен'}</span>`:''}</div><div class="last">${last?last.text:'Начни общение'}</div></div>
${unread?`<span class="unread">${unread}</span>`:''}
</div>`;
});
@ -814,7 +991,7 @@ function renderChatView(){
</div>
<div class="chat-messages" id="chatMsgs">`;
if(!msgs.length)h+='<div class="empty-state"><div class="big">&#x1F4AC;</div>Начните общение!</div>';
else msgs.forEach(m=>{h+=`<div class="chat-msg ${m.from===uid()?'mine':'theirs'}">${m.from!==uid()?`<div class="sender">${other?other.name:'Пользователь'}</div>`:''}${m.text}<div style="font-size:10px;color:${m.from===uid()?'var(--ink)':'var(--gray-500)'};margin-top:2px">${m.time||''}</div></div>`});
else msgs.forEach(m=>{h+=`<div class="chat-msg ${m.from===uid()?'mine':'theirs'}">${m.from!==uid()?`<div class="sender">${other?other.name:'Пользователь'}</div>`:''}${sanitize(m.text)}<div style="font-size:10px;color:${m.from===uid()?'var(--ink)':'var(--gray-500)'};margin-top:2px">${m.time||''}</div></div>`});
h+='</div><div class="chat-input-row"><input type="text" id="msgInput" placeholder="Сообщение..." onkeydown="if(event.key===\'Enter\')sendMsg()"><button class="btn small" onclick="sendMsg()">&#x27A4;</button></div>';
return h;
}
@ -1206,7 +1383,7 @@ function renderRanking(){
const users=LS('users')||[];
const sorted=[...users].filter(u=>u.stars>0).sort((a,b)=>b.stars-a.stars).slice(0,5);
if(!sorted.length){el.innerHTML='<p class="muted">Пока никто не проголосовал</p>';return}
el.innerHTML=sorted.map((u,i)=>`<div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid #2a3342"><span style="color:${i===0?'#FFD700':i===1?'#C0C0C0':i===2?'#CD7F32':'var(--gray-500)'}">${i+1}.</span><span>${u.name}</span><span style="margin-left:auto;color:var(--cyan);font-size:13px">&#x2B50; ${u.stars||0}${u.quizScore?` | &#x1F3C6; ${u.quizScore}/8`:''}${u.gamesWon?` | &#x1F3AE; ${u.gamesWon}`:''}</span></div>`).join('');
el.innerHTML=sorted.map((u,i)=>`<div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid #2a3342"><span style="color:${i===0?'#FFD700':i===1?'#C0C0C0':i===2?'#CD7F32':'var(--gray-500)'}">${i+1}.</span><span>${sanitize(u.name)}</span><span style="margin-left:auto;color:var(--cyan);font-size:13px">&#x2B50; ${u.stars||0}${u.quizScore?` | &#x1F3C6; ${u.quizScore}/8`:''}${u.gamesWon?` | &#x1F3AE; ${u.gamesWon}`:''}</span></div>`).join('');
}
function voteRandom(){
const users=LS('users')||[];