README.md
This commit is contained in:
@@ -3,6 +3,10 @@ import base64
|
||||
import secrets
|
||||
import re
|
||||
import bleach
|
||||
import secrets
|
||||
import string
|
||||
from mailer import mailer, send_mail
|
||||
from migrate import run_migrations
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import quote_plus
|
||||
from bleach import Linker
|
||||
@@ -27,6 +31,7 @@ load_dotenv()
|
||||
|
||||
|
||||
class Config:
|
||||
APP_URL = os.environ.get('APP_URL', 'http://localhost:5000')
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', secrets.token_hex(32))
|
||||
REDIS_HOST = os.environ.get('REDIS_HOST', 'localhost')
|
||||
REDIS_PORT = int(os.environ.get('REDIS_PORT', 6379))
|
||||
@@ -115,7 +120,7 @@ def create_admin():
|
||||
print(f" ВАЖНО: Очистите куки браузера перед входом!")
|
||||
print(f"{'=' * 60}\n")
|
||||
else:
|
||||
print(f"❌ Ошибка при создании администратора")
|
||||
print(f"Ошибка при создании администратора")
|
||||
else:
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Пользователи уже существуют")
|
||||
@@ -123,6 +128,17 @@ def create_admin():
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
|
||||
def get_base_url() -> str:
|
||||
return app.config.get(
|
||||
'APP_URL') or f"{app.config.get('APP_SCHEME', 'http')}://{app.config.get('APP_HOST', 'localhost')}:{app.config.get('APP_PORT', 5000)}"
|
||||
|
||||
|
||||
def absolute_url(endpoint: str, **kwargs) -> str:
|
||||
base_url = get_base_url()
|
||||
relative_url = url_for(endpoint, **kwargs)
|
||||
return f"{base_url}{relative_url}"
|
||||
|
||||
|
||||
def custom_linkify(text):
|
||||
linker = Linker(
|
||||
callbacks=[
|
||||
@@ -138,10 +154,6 @@ def custom_linkify(text):
|
||||
|
||||
database.db.init_app(app)
|
||||
|
||||
'''with app.app_context():
|
||||
database.init_db()
|
||||
create_admin()'''
|
||||
|
||||
csrf = CSRFProtect(app)
|
||||
limiter = Limiter(
|
||||
app=app,
|
||||
@@ -151,6 +163,29 @@ limiter = Limiter(
|
||||
)
|
||||
|
||||
|
||||
def generate_secure_password(min_len: int = 12, max_len: int = 16) -> str:
|
||||
lowercase = string.ascii_lowercase
|
||||
uppercase = string.ascii_uppercase
|
||||
digits = string.digits
|
||||
special = "!@#$%^&*()-_=+"
|
||||
|
||||
password = [
|
||||
secrets.choice(lowercase),
|
||||
secrets.choice(uppercase),
|
||||
secrets.choice(digits),
|
||||
secrets.choice(special)
|
||||
]
|
||||
|
||||
all_chars = lowercase + uppercase + digits + special
|
||||
remaining_length = secrets.randbelow(max_len - min_len + 1) + (min_len - 4)
|
||||
|
||||
for _ in range(remaining_length):
|
||||
password.append(secrets.choice(all_chars))
|
||||
|
||||
secrets.SystemRandom().shuffle(password)
|
||||
return ''.join(password)
|
||||
|
||||
|
||||
@app.template_filter('format_datetime')
|
||||
def format_datetime(value, format='%Y-%m-%d %H:%M'):
|
||||
if value is None:
|
||||
@@ -231,7 +266,6 @@ def is_ip_allowed(ip: str) -> bool:
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -336,13 +370,17 @@ def add_security_headers(response):
|
||||
"form-action 'self'"
|
||||
)
|
||||
response.headers['Content-Security-Policy'] = csp
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def utility_processor():
|
||||
return {'now': datetime.now(), 'config': app.config}
|
||||
return {
|
||||
'now': datetime.now(),
|
||||
'config': app.config,
|
||||
'base_url': get_base_url,
|
||||
'absolute_url': absolute_url
|
||||
}
|
||||
|
||||
|
||||
redis_client = Redis(
|
||||
@@ -379,7 +417,8 @@ def generate_secret_id() -> str:
|
||||
return secrets.token_urlsafe(app.config['SECRET_ID_LENGTH'])
|
||||
|
||||
|
||||
def store_secret(plaintext: str, client_info: dict, user_id: int = None, request_note: str = None) -> str:
|
||||
def store_secret(plaintext: str, client_info: dict, user_id: int = None, request_note: str = None,
|
||||
user_note: str = None) -> str:
|
||||
secret_id = generate_secret_id()
|
||||
|
||||
encrypted = cipher.encrypt(plaintext.encode())
|
||||
@@ -391,13 +430,19 @@ def store_secret(plaintext: str, client_info: dict, user_id: int = None, request
|
||||
if not user_exists:
|
||||
user_id = None
|
||||
|
||||
clean_note = None
|
||||
clean_request_note = None
|
||||
if request_note:
|
||||
clean_note = sanitize_input(request_note)
|
||||
if clean_note and len(clean_note) > 500:
|
||||
clean_note = clean_note[:500]
|
||||
clean_request_note = sanitize_input(request_note)
|
||||
if clean_request_note and len(clean_request_note) > 500:
|
||||
clean_request_note = clean_request_note[:500]
|
||||
|
||||
database.log_creation(secret_id, client_info, user_id, clean_note)
|
||||
clean_user_note = None
|
||||
if user_note:
|
||||
clean_user_note = sanitize_input(user_note)
|
||||
if clean_user_note and len(clean_user_note) > 500:
|
||||
clean_user_note = clean_user_note[:500]
|
||||
|
||||
database.log_creation(secret_id, client_info, user_id, clean_request_note, clean_user_note)
|
||||
return secret_id
|
||||
|
||||
|
||||
@@ -519,7 +564,6 @@ def login():
|
||||
f'Неудачная попытка входа: {username}'
|
||||
)
|
||||
flash('Неверный логин или пароль', 'danger')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
|
||||
@@ -538,11 +582,98 @@ def logout():
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
@app.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
user_id = session.get('user_id')
|
||||
user = database.get_user_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
session.clear()
|
||||
flash('Ваша сессия устарела. Пожалуйста, войдите заново.', 'warning')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
email = request.form.get('email', '').strip()
|
||||
jabber = request.form.get('jabber', '').strip()
|
||||
|
||||
if email and not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
|
||||
flash('Неверный формат email', 'danger')
|
||||
return render_template('profile.html', user=user)
|
||||
|
||||
if jabber and not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', jabber):
|
||||
flash('Неверный формат Jabber ID', 'danger')
|
||||
return render_template('profile.html', user=user)
|
||||
|
||||
user.email = email if email else None
|
||||
user.jabber = jabber if jabber else None
|
||||
user.updated_at = datetime.now()
|
||||
|
||||
user.notify_email = 'notify_email' in request.form
|
||||
user.notify_jabber = 'notify_jabber' in request.form
|
||||
|
||||
database.db.session.commit()
|
||||
|
||||
database.log_security_event(
|
||||
request.remote_addr,
|
||||
'profile_updated',
|
||||
f'Обновлен профиль пользователя {user.username}',
|
||||
user_id=user.id
|
||||
)
|
||||
|
||||
flash('Профиль успешно обновлен!', 'success')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
return render_template('profile.html', user=user)
|
||||
|
||||
|
||||
@app.route('/profile/change-password', methods=['POST'])
|
||||
@login_required
|
||||
def profile_change_password():
|
||||
user_id = session.get('user_id')
|
||||
user = database.get_user_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
session.clear()
|
||||
flash('Ваша сессия устарела.', 'warning')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
old_password = request.form.get('old_password', '')
|
||||
new_password = request.form.get('new_password', '')
|
||||
confirm_password = request.form.get('confirm_password', '')
|
||||
|
||||
if not user.check_password(old_password):
|
||||
flash('Неверный текущий пароль', 'danger')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
if len(new_password) < 6:
|
||||
flash('Новый пароль должен содержать минимум 6 символов', 'danger')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
if new_password != confirm_password:
|
||||
flash('Пароли не совпадают', 'danger')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
user.set_password(new_password)
|
||||
database.db.session.commit()
|
||||
|
||||
database.log_security_event(
|
||||
request.remote_addr,
|
||||
'password_change',
|
||||
f'Смена пароля пользователем {user.username}',
|
||||
user_id=user.id
|
||||
)
|
||||
|
||||
flash('Пароль успешно изменен!', 'success')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
|
||||
@app.route('/create', methods=['POST'])
|
||||
@login_required
|
||||
@limiter.limit("30 per minute")
|
||||
def create_secret():
|
||||
secret_text = request.form.get('secret', '').strip()
|
||||
user_note = request.form.get('user_note', '').strip()
|
||||
|
||||
if not secret_text:
|
||||
flash('Пожалуйста, введите секрет', 'danger')
|
||||
@@ -566,7 +697,7 @@ def create_secret():
|
||||
flash('Ваша сессия устарела. Пожалуйста, войдите заново.', 'warning')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
secret_id = store_secret(secret_text, client_info, user_id)
|
||||
secret_id = store_secret(secret_text, client_info, user_id, request_note=user_note)
|
||||
view_url = url_for('view_secret', secret_id=secret_id, _external=True)
|
||||
|
||||
database.log_security_event(
|
||||
@@ -750,7 +881,7 @@ def request_secret_submit(token):
|
||||
if clean_note and len(clean_note) > 500:
|
||||
clean_note = clean_note[:500]
|
||||
|
||||
secret_id = store_secret(secret_text, client_info, int(user_id), clean_note)
|
||||
secret_id = store_secret(secret_text, client_info, int(user_id), request_note=clean_note)
|
||||
view_url = url_for('view_secret', secret_id=secret_id, _external=True)
|
||||
|
||||
redis_client.delete(request_key)
|
||||
@@ -764,6 +895,30 @@ def request_secret_submit(token):
|
||||
user_id=int(user_id)
|
||||
)
|
||||
|
||||
if user and user.email and mailer.is_enabled() and user.notify_email:
|
||||
try:
|
||||
from mailer import send_secret_received_email
|
||||
send_secret_received_email(
|
||||
to_email=user.email,
|
||||
username=user.username,
|
||||
note=clean_note
|
||||
)
|
||||
print(f"Уведомление о секрете отправлено на {user.email}")
|
||||
except Exception as e:
|
||||
print(f"Ошибка отправки уведомления: {e}")
|
||||
else:
|
||||
# Логируем причину, почему письмо не отправлено
|
||||
reasons = []
|
||||
if not user:
|
||||
reasons.append("пользователь не найден")
|
||||
elif not user.email:
|
||||
reasons.append("email не указан")
|
||||
elif not mailer.is_enabled():
|
||||
reasons.append("почта отключена")
|
||||
elif not user.notify_email:
|
||||
reasons.append("отключены уведомления в профиле")
|
||||
print(f"ℹ️ Письмо не отправлено: {', '.join(reasons)}")
|
||||
|
||||
flash('Секрет успешно отправлен! Получатель сможет его просмотреть.', 'success')
|
||||
|
||||
return render_template('request_secret_submit_done.html',
|
||||
@@ -831,7 +986,6 @@ def my_requests():
|
||||
return render_template('my_requests.html', requests=requests)
|
||||
|
||||
|
||||
|
||||
@app.route('/request/<token>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def request_secret_delete(token):
|
||||
@@ -979,8 +1133,7 @@ def admin_panel():
|
||||
def admin_create_user():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
confirm_password = request.form.get('confirm_password', '')
|
||||
email = request.form.get('email', '').strip()
|
||||
role = request.form.get('role', 'user')
|
||||
|
||||
if not username or len(username) < 3:
|
||||
@@ -991,12 +1144,16 @@ def admin_create_user():
|
||||
flash('Логин может содержать только буквы, цифры и знак подчеркивания', 'danger')
|
||||
return render_template('admin_user_create.html')
|
||||
|
||||
if not password or len(password) < 6:
|
||||
flash('Пароль должен содержать минимум 6 символов', 'danger')
|
||||
if not email:
|
||||
flash('Email обязателен для заполнения', 'danger')
|
||||
return render_template('admin_user_create.html')
|
||||
|
||||
if password != confirm_password:
|
||||
flash('Пароли не совпадают', 'danger')
|
||||
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
|
||||
flash('Неверный формат email', 'danger')
|
||||
return render_template('admin_user_create.html')
|
||||
|
||||
if role not in ['user', 'admin']:
|
||||
flash('Неверная роль', 'danger')
|
||||
return render_template('admin_user_create.html')
|
||||
|
||||
existing = database.get_user(username)
|
||||
@@ -1004,17 +1161,69 @@ def admin_create_user():
|
||||
flash(f'Пользователь "{username}" уже существует', 'danger')
|
||||
return render_template('admin_user_create.html')
|
||||
|
||||
if database.create_user(username, password, role):
|
||||
database.log_security_event(
|
||||
request.remote_addr,
|
||||
'user_created',
|
||||
f'Создан пользователь: {username} (role: {role}) администратором {session.get("username")}',
|
||||
user_id=session.get('user_id')
|
||||
)
|
||||
flash(f'Пользователь "{username}" успешно создан с ролью {role}', 'success')
|
||||
return redirect(url_for('admin_panel'))
|
||||
else:
|
||||
password = generate_secure_password()
|
||||
print(f"Сгенерирован пароль для {username}: {password}")
|
||||
|
||||
if not database.create_user(username, password, role):
|
||||
flash('Ошибка при создании пользователя', 'danger')
|
||||
return render_template('admin_user_create.html')
|
||||
|
||||
new_user = database.get_user(username)
|
||||
new_user_id = new_user.id if new_user else None
|
||||
print(f"✅ Пользователь {username} создан с ID: {new_user_id}")
|
||||
|
||||
admin_id = session.get('user_id')
|
||||
admin_username = session.get('username', 'admin')
|
||||
|
||||
secret_text = f"""Пароль для {username}:
|
||||
|
||||
{password}
|
||||
|
||||
Логин: {username}
|
||||
|
||||
Отправлено на: {email}
|
||||
|
||||
ВАЖНО:
|
||||
• Пароль был отправлен пользователю в виде одноразовой ссылки
|
||||
• После прочтения ссылка станет недействительной
|
||||
|
||||
Создано: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
Администратор: {admin_username}
|
||||
"""
|
||||
|
||||
client_info = {
|
||||
'ip': request.remote_addr,
|
||||
'user_agent': request.headers.get('User-Agent', 'Unknown'),
|
||||
'session_id': session.get('session_id', '')
|
||||
}
|
||||
|
||||
secret_id = store_secret(
|
||||
plaintext=secret_text,
|
||||
client_info=client_info,
|
||||
user_id=admin_id, # ← ID администратора!
|
||||
request_note=f"Пароль для {username} (отправлен на {email})"
|
||||
)
|
||||
|
||||
view_url = url_for('view_secret', secret_id=secret_id, _external=True)
|
||||
|
||||
if mailer.is_enabled():
|
||||
try:
|
||||
mailer.send_user_created_email(email, username, view_url)
|
||||
flash(f'Письмо с паролем отправлено на {email}', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Ошибка отправки письма: {str(e)}', 'danger')
|
||||
print(f"Пароль для {username}: {password}")
|
||||
else:
|
||||
flash(f'Почта отключена. Пароль для {username}: {password} (сохраните его!)', 'warning')
|
||||
|
||||
database.log_security_event(
|
||||
request.remote_addr,
|
||||
'user_created_with_email',
|
||||
f'Создан пользователь: {username} (role: {role}) администратором {admin_username}. Пароль отправлен на {email}',
|
||||
user_id=admin_id
|
||||
)
|
||||
flash(f'Пользователь "{username}" успешно создан! Пароль отправлен на email.', 'success')
|
||||
return redirect(url_for('admin_panel'))
|
||||
|
||||
return render_template('admin_user_create.html')
|
||||
|
||||
@@ -1250,6 +1459,10 @@ def health_check():
|
||||
}), 500
|
||||
|
||||
|
||||
with app.app_context():
|
||||
run_migrations()
|
||||
create_admin()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(
|
||||
debug=app.config['DEBUG'],
|
||||
|
||||
Reference in New Issue
Block a user