v18 — безопасность (SHA-256, CSP, XSS-защита, сессии, админ-панель)
This commit is contained in:
parent
b120586a01
commit
56e2cb1cda
197
index.html
197
index.html
@ -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>🔑 Придумай логин и пароль</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()">← Назад</button>
|
||||
<button class="btn" onclick="nextStep()">Дальше →</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('⏰ Сессия истекла. Войди заново.'); 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 => ({'<':'<','>':'>','"':'"',"'":''','&':'&'}[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: '🟡 Слабый пароль', color: '#FFD700' };
|
||||
if(score <= 3) return { ok: true, msg: '🟢 Средний пароль', color: '#4CAF50' };
|
||||
return { ok: true, msg: '🟢🟢🟢 Сильный пароль', 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>🛡 Админ-панель</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||'—'} | ⭐ ${u.stars||0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn danger small" onclick="adminDeleteUser(${u.id})">🗑</button>
|
||||
<button class="btn small outline" onclick="adminResetPass(${u.id})">🔒</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
h += '<button class="btn small outline" onclick="showPage(currentPage)" style="margin-top:8px">← Назад</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 = '🛡 Админ';
|
||||
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('🛡 Режим администратора');
|
||||
}
|
||||
};
|
||||
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>🛡 Администратор</h3><p class="muted">Чаты доступны только пользователям.</p><button class="btn small outline" onclick="showAdminPanel()">← Панель</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">💬</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()">➤</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">⭐ ${u.stars||0}${u.quizScore?` | 🏆 ${u.quizScore}/8`:''}${u.gamesWon?` | 🎮 ${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">⭐ ${u.stars||0}${u.quizScore?` | 🏆 ${u.quizScore}/8`:''}${u.gamesWon?` | 🎮 ${u.gamesWon}`:''}</span></div>`).join('');
|
||||
}
|
||||
function voteRandom(){
|
||||
const users=LS('users')||[];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user