Compare commits
9
Commits
98eb22238f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fb52fd1cb | ||
|
|
cccfae3c51 | ||
|
|
7ae77c0775 | ||
|
|
216da22bc2 | ||
|
|
93fdac7de8 | ||
|
|
00e2f39480 | ||
|
|
09f02208af | ||
|
|
a86ffe5741 | ||
|
|
3cffd01168 |
@@ -1,3 +1,4 @@
|
|||||||
|
APP_URL=http://localhost:5000
|
||||||
|
|
||||||
SECRET_KEY=
|
SECRET_KEY=
|
||||||
WTF_CSRF_SECRET_KEY=
|
WTF_CSRF_SECRET_KEY=
|
||||||
@@ -24,3 +25,12 @@ DB_USER=root
|
|||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
DB_NAME=secrettext
|
DB_NAME=secrettext
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
|
|
||||||
|
MAIL_ENABLED=true
|
||||||
|
MAIL_SERVER=smtp.google.ru
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USE_TLS=true
|
||||||
|
MAIL_USE_SSL=false
|
||||||
|
MAIL_USERNAME=noreply@secrettext.ru
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
MAIL_DEFAULT_SENDER=noreply@secrettext.ru
|
||||||
@@ -13,3 +13,7 @@ instance/
|
|||||||
/.deploy_cache.db
|
/.deploy_cache.db
|
||||||
/.env
|
/.env
|
||||||
/sync.py
|
/sync.py
|
||||||
|
/.idea
|
||||||
|
/collect_code.py
|
||||||
|
/project_summary.txt
|
||||||
|
exclude.txt
|
||||||
|
|||||||
@@ -1,6 +1,160 @@
|
|||||||
До первого запуска, для создания таблиц в БД и пользователя admin необходимо раскоментировать строки 141-143
|
# SecretText — Сервис безопасной передачи одноразовых секретов
|
||||||
|
|
||||||
|
**SecretText** — это self-hosted веб-приложение на Flask, предназначенное для конфиденциальной передачи одноразовых паролей, ключей шифрования и защищенных текстовых заметок. Сервис работает по принципу «прочитано — удалено», минимизируя цифровой след.
|
||||||
|
|
||||||
|
## 🚀 Основные возможности
|
||||||
|
|
||||||
|
- **Одноразовые секреты**: Ссылка на секрет автоматически уничтожается в Redis сразу после первого просмотра получателем.
|
||||||
|
- **Двусторонний обмен (Запросы секретов)**: Возможность создать защищенную ссылку-запрос с уникальным токеном, перейдя по которой, сторонний пользователь может безопасно отправить секрет вам в личный кабинет.
|
||||||
|
- **Симметричное шифрование**: Все секреты шифруются «на лету» с помощью библиотеки `cryptography` (алгоритм Fernet/AES) перед отправкой в оперативную память.
|
||||||
|
- **Встроенная защита от атак**:
|
||||||
|
- Защита от перебора ссылок (Anti-Bruteforce) с прогрессивной блокировкой IP-адресов в Redis.
|
||||||
|
- Ограничение частоты запросов (Rate Limiting) для предотвращения DoS-атак и спама.
|
||||||
|
- Строгая санитизация входных данных (`bleach`) и защита от CSRF-атак.
|
||||||
|
- **Панель администратора**: Встроенный аудит событий безопасности, просмотр системных логов, управление пользователями (активация/деактивация) и просмотр статистики.
|
||||||
|
- **Мультиязычные шаблоны**: Готовые двуязычные блоки (RU/EN) с кнопками автоматического копирования в один клик.
|
||||||
|
- **Автономность**: Проект не использует внешние CDN. Все библиотеки (Bootstrap 5, Bootstrap Icons) упакованы локально в папке `static`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Подготовка к первому запуску
|
||||||
|
|
||||||
|
### 1. Системные требования
|
||||||
|
Для работы приложения необходимы:
|
||||||
|
- **Python 3.10** или выше
|
||||||
|
- **Redis Server** (для хранения зашифрованных секретов и сессий)
|
||||||
|
- **MariaDB / MySQL** (для хранения учетных записей пользователей и логов безопасности)
|
||||||
|
|
||||||
|
### 2. Клонирование репозитория и окружение
|
||||||
|
```bash
|
||||||
|
git clone https://git.palchikov.name/PalchikovAleksandr/secrettext.git
|
||||||
|
cd secrettext
|
||||||
|
|
||||||
|
# Создание и активация виртуального окружения
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Для Linux/macOS
|
||||||
|
# .venv\Scripts\activate # Для Windows
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Настройка конфигурации (`.env`)
|
||||||
|
Создайте файл `.env` в корневом каталоге проекта и заполните его учетными данными:
|
||||||
|
```env
|
||||||
|
# Flask конфигурация
|
||||||
|
FLASK_ENV=production
|
||||||
|
DEBUG=False
|
||||||
|
SECRET_KEY=укажите_случайный_длинный_хеш
|
||||||
|
ENCRYPTION_PASSWORD=укажите_стойкий_пароль_для_шифрования_секретов
|
||||||
|
SALT=укажите_случайный_соленый_хеш
|
||||||
|
|
||||||
|
# Настройки MariaDB / MySQL
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=secretuser
|
||||||
|
DB_NAME=secrettext
|
||||||
|
DB_PASSWORD=ваш_пароль_от_базы_данных
|
||||||
|
|
||||||
|
# Настройки Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
REDIS_PASSWORD=пароль_redis_если_есть
|
||||||
|
|
||||||
|
# Безопасность админки
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=придумайте_сложный_пароль_админа
|
||||||
|
ADMIN_ALLOWED_IPS=127.0.0.1,::1
|
||||||
|
ADMIN_ALLOW_ALL=False
|
||||||
|
```
|
||||||
|
⚠️ *Внимание: Обязательно добавьте `.env` в ваш `.gitignore`, чтобы случайно не опубликовать пароли в репозитории.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏁 Запуск приложения
|
||||||
|
|
||||||
|
### Шаг 1. Инициализация базы данных
|
||||||
|
Перед самым первым запуском раскомментируйте блок инициализации в `main.py` (примерно 121-123 строки):
|
||||||
|
```python
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
|
||||||
database.init_db()
|
database.init_db()
|
||||||
create_admin()
|
create_admin()
|
||||||
|
```
|
||||||
|
Запустите приложение один раз, чтобы создались таблицы в MariaDB и сгенерировалась учетная запись администратора, указанная в `.env`. После успешного создания **закомментируйте этот блок обратно**, чтобы сервер не выполнял избыточные проверки при каждом перезапуске.
|
||||||
|
Или ещё проще, запустить Python в интерактивном режиме и выполнить инициализацию вручную, чтобы точно не забыть закомментировать код.
|
||||||
|
|
||||||
|
Сразу после установки сменить пароль администратора через интерфейс, даже если он задан в .env. Это защитит от случайной утечки, если файл с настройками попадёт в чужие руки.
|
||||||
|
### Шаг 2. Запуск в режиме разработки
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
Приложение станет доступно по адресу `http://localhost:5000`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Рекомендации по настройке в продакшене (Production)
|
||||||
|
|
||||||
|
Запуск напрямую через `python main.py` предназначен **только для разработки**. При развертывании в реальной сети строго следуйте правилам ниже:
|
||||||
|
|
||||||
|
### 1. Использование боевого WSGI-сервера
|
||||||
|
Для стабильной и многопоточной работы Flask-приложения в продакшене рекомендуется использовать чистый Python WSGI-сервер, например **Waitress**. Это исключает проблемы с потоками встроенного сервера разработки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установка сервера
|
||||||
|
pip install waitress
|
||||||
|
|
||||||
|
# Запуск приложения через WSGI-интерфейс
|
||||||
|
waitress-serve --host=127.0.0.1 --port=5000 main:app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. При использование HTTPS (Apache)
|
||||||
|
Поскольку сервис обрабатывает пароли, передача данных по незащищенному протоколу HTTP категорически запрещена. Настройте **Apache** в качестве Reverse Proxy (используя модули `mod_proxy` и `mod_proxy_http`) и установите SSL-сертификат (например, бесплатный от Let's Encrypt через `certbot`).
|
||||||
|
|
||||||
|
Пример конфигурации виртуального хоста (VirtualHost) в Apache:
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:443>
|
||||||
|
ServerName yourdomain.com
|
||||||
|
|
||||||
|
SSLEngine on
|
||||||
|
SSLCertificateFile /etc/letsencrypt/live/yourdomain.com/fullchain.pem
|
||||||
|
SSLCertificateKeyFile /etc/letsencrypt/live/yourdomain.com/privkey.pem
|
||||||
|
|
||||||
|
# Запрет доступа к скрытым файлам и папкам в корне (включая .env)
|
||||||
|
<FilesMatch "^\.">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# Настройка Reverse Proxy на локальный боевой WSGI-сервер (Waitress)
|
||||||
|
ProxyRequests Off
|
||||||
|
ProxyPreserveHost On
|
||||||
|
|
||||||
|
# Проксирование всех запросов на приложение, запущенное на порту 5000
|
||||||
|
ProxyPass / http://127.0.0.1:5000/
|
||||||
|
ProxyPassReverse / http://127.0.0.1:5000/
|
||||||
|
|
||||||
|
# Дополнительные заголовки безопасности для прокси
|
||||||
|
ProxySetHeader X-Forwarded-Proto https
|
||||||
|
ProxySetHeader X-Forwarded-Host %{HTTP_HOST}s
|
||||||
|
ProxySetHeader X-Real-IP %{REMOTE_ADDR}s
|
||||||
|
|
||||||
|
# Логирование
|
||||||
|
ErrorLog ${APACHE_LOG_DIR}/secrettext_error.log
|
||||||
|
CustomLog ${APACHE_LOG_DIR}/secrettext_access.log combined
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 3. Настройка заголовков за прокси-сервером
|
||||||
|
При работе за Nginx Proxy обязательно убедитесь, что в `main.py` корректно обрабатывается заголовок `X-Forwarded-For`. Это необходимо, чтобы встроенная система rate-limiting и блокировки брутфорса видела **реальные IP-адреса злоумышленников**, а не локальный адрес самого Nginx (`127.0.0.1`).
|
||||||
|
|
||||||
|
### 4. Ротация логов и очистка Redis
|
||||||
|
В файле `database.py` предусмотрена функция `cleanup_old_data()`. Рекомендуется настроить системный планировщик **Cron** для ежедневного вызова скрипта очистки старых логов безопасности и просроченных записей:
|
||||||
|
```bash
|
||||||
|
0 3 * * * /home/shurik/pass_toket/.venv/bin/python -c "import database; database.cleanup_old_data()"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
## 📄 Лицензия
|
||||||
|
Этот проект является полностью свободным программным обеспечением и передан в общественное достояние. Вы можете копировать, изменять, публиковать, использовать, компилировать, продавать или распространять этот код как в исходном, так и в скомпилированном виде, в любых целях, коммерческих или некоммерческих, любыми способами.
|
||||||
|
|||||||
+14
-2
@@ -21,6 +21,15 @@ class User(db.Model):
|
|||||||
last_login = Column(DateTime, nullable=True)
|
last_login = Column(DateTime, nullable=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# ТОЛЬКО ЭТИ ПОЛЯ ДЛЯ ПРОФИЛЯ
|
||||||
|
email = Column(String(255), nullable=True)
|
||||||
|
jabber = Column(String(255), nullable=True)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Настройки уведомлений
|
||||||
|
notify_email = Column(Boolean, default=False)
|
||||||
|
notify_jabber = Column(Boolean, default=False)
|
||||||
|
|
||||||
secrets = relationship('Secret', back_populates='user', cascade='all, delete-orphan')
|
secrets = relationship('Secret', back_populates='user', cascade='all, delete-orphan')
|
||||||
security_logs = relationship('SecurityLog', back_populates='user', cascade='all, delete-orphan')
|
security_logs = relationship('SecurityLog', back_populates='user', cascade='all, delete-orphan')
|
||||||
|
|
||||||
@@ -40,7 +49,10 @@ class User(db.Model):
|
|||||||
'role': self.role,
|
'role': self.role,
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
'last_login': self.last_login.isoformat() if self.last_login else None,
|
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||||
'is_active': self.is_active
|
'is_active': self.is_active,
|
||||||
|
'email': self.email,
|
||||||
|
'jabber': self.jabber,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -174,7 +186,7 @@ def set_user_role(user_id: int, role: str):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
def log_creation(secret_id: str, client_info: dict, user_id: int = None, request_note: str = None):
|
def log_creation(secret_id: str, client_info: dict, user_id: int = None, request_note: str = None, user_note: str = None):
|
||||||
secret = Secret(
|
secret = Secret(
|
||||||
secret_id=secret_id,
|
secret_id=secret_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
/exclude.txt
|
|
||||||
/sync.py
|
|
||||||
/.idea
|
|
||||||
/.git
|
|
||||||
/.env
|
|
||||||
/.gitignore
|
|
||||||
/.venv
|
|
||||||
/venv
|
|
||||||
/test
|
|
||||||
/.vscode
|
|
||||||
/uploads
|
|
||||||
/__pycache__
|
|
||||||
/test.py
|
|
||||||
/device.db
|
|
||||||
/.deploy_cache.db
|
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.utils import formataddr
|
||||||
|
from threading import Thread
|
||||||
|
from flask import render_template_string, render_template
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class Mailer:
|
||||||
|
def __init__(self):
|
||||||
|
self.enabled = os.environ.get('MAIL_ENABLED', 'false').lower() == 'true'
|
||||||
|
self.smtp_server = os.environ.get('MAIL_SERVER', 'smtp.gmail.com')
|
||||||
|
self.smtp_port = int(os.environ.get('MAIL_PORT', 587))
|
||||||
|
self.use_tls = os.environ.get('MAIL_USE_TLS', 'true').lower() == 'true'
|
||||||
|
self.use_ssl = os.environ.get('MAIL_USE_SSL', 'false').lower() == 'true'
|
||||||
|
self.username = os.environ.get('MAIL_USERNAME', '')
|
||||||
|
self.password = os.environ.get('MAIL_PASSWORD', '')
|
||||||
|
self.default_sender = os.environ.get('MAIL_DEFAULT_SENDER', self.username)
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
if not self.enabled:
|
||||||
|
return False
|
||||||
|
if not self.username or not self.password:
|
||||||
|
print("Mail: Отсутствуют логин или пароль")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_connection(self):
|
||||||
|
if self.use_ssl:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
return smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, context=context)
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(self.smtp_server, self.smtp_port)
|
||||||
|
if self.use_tls:
|
||||||
|
server.starttls()
|
||||||
|
return server
|
||||||
|
|
||||||
|
def _send(self, to_emails, subject, body, html_body=None, from_email=None, from_name='SecretText'):
|
||||||
|
if not self.is_enabled():
|
||||||
|
print(f"Mail: Пропущено письмо (отключено)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not to_emails:
|
||||||
|
print("Mail: Не указаны получатели")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(to_emails, str):
|
||||||
|
to_emails = [to_emails]
|
||||||
|
|
||||||
|
from_email = from_email or self.default_sender
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = formataddr((from_name, from_email))
|
||||||
|
msg['To'] = ', '.join(to_emails)
|
||||||
|
|
||||||
|
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
||||||
|
|
||||||
|
if html_body:
|
||||||
|
msg.attach(MIMEText(html_body, 'html', 'utf-8'))
|
||||||
|
|
||||||
|
with self._get_connection() as server:
|
||||||
|
if self.username and self.password:
|
||||||
|
server.login(self.username, self.password)
|
||||||
|
server.send_message(msg)
|
||||||
|
|
||||||
|
print(f"Mail: Письмо отправлено на {', '.join(to_emails)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Mail: Ошибка отправки: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_email(self, to_emails, subject, body, html_body=None, from_email=None, from_name='SecretText'):
|
||||||
|
if not self.is_enabled():
|
||||||
|
print(f"Mail: Письмо не отправлено (отключено)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
thread = Thread(
|
||||||
|
target=self._send,
|
||||||
|
args=(to_emails, subject, body, html_body, from_email, from_name),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_with_template(self, to_emails, subject, template_name, context=None, from_email=None,
|
||||||
|
from_name='SecretText'):
|
||||||
|
from main import app
|
||||||
|
context = context or {}
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
html_body = render_template(template_name, **context)
|
||||||
|
|
||||||
|
body = f"Письмо от SecretText. Пожалуйста, используйте почтовый клиент с поддержкой HTML."
|
||||||
|
|
||||||
|
return self.send_email(to_emails, subject, body, html_body, from_email, from_name)
|
||||||
|
|
||||||
|
def send_user_created_email(self, to_email, username, view_url):
|
||||||
|
subject = f"Ваш аккаунт в SecretText создан!"
|
||||||
|
context = {
|
||||||
|
'username': username,
|
||||||
|
'view_url': view_url,
|
||||||
|
'base_url': self._get_base_url()
|
||||||
|
}
|
||||||
|
return self.send_email_with_template(
|
||||||
|
to_email,
|
||||||
|
subject,
|
||||||
|
'emails/user_created.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_base_url(self):
|
||||||
|
from main import app
|
||||||
|
return app.config.get('APP_URL', 'http://localhost:5000')
|
||||||
|
|
||||||
|
def send_welcome_email(self, to_email, username):
|
||||||
|
subject = f"Добро пожаловать в SecretText, {username}!"
|
||||||
|
context = {
|
||||||
|
'username': username,
|
||||||
|
'base_url': self._get_base_url()
|
||||||
|
}
|
||||||
|
return self.send_email_with_template(
|
||||||
|
to_email,
|
||||||
|
subject,
|
||||||
|
'emails/welcome.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_secret_notification(self, to_email, username, secret_id=None):
|
||||||
|
subject = f"Новый секрет для вас!"
|
||||||
|
context = {
|
||||||
|
'username': username,
|
||||||
|
'base_url': self._get_base_url()
|
||||||
|
}
|
||||||
|
return self.send_email_with_template(
|
||||||
|
to_email,
|
||||||
|
subject,
|
||||||
|
'emails/secret_notification.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_secret_viewed_notification(self, to_email, username):
|
||||||
|
subject = f"Ваш секрет просмотрен!"
|
||||||
|
context = {
|
||||||
|
'username': username,
|
||||||
|
'base_url': self._get_base_url()
|
||||||
|
}
|
||||||
|
return self.send_email_with_template(
|
||||||
|
to_email,
|
||||||
|
subject,
|
||||||
|
'emails/secret_viewed.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
# mailer.py
|
||||||
|
|
||||||
|
def send_secret_received_email(self, to_email, username, note=None):
|
||||||
|
subject = f"Вам отправили секрет по запросу!"
|
||||||
|
context = {
|
||||||
|
'username': username,
|
||||||
|
'note': note,
|
||||||
|
'base_url': self._get_base_url()
|
||||||
|
}
|
||||||
|
return self.send_email_with_template(
|
||||||
|
to_email,
|
||||||
|
subject,
|
||||||
|
'emails/secret_received.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
mailer = Mailer()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def send_mail(to_emails, subject, body, html_body=None):
|
||||||
|
return mailer.send_email(to_emails, subject, body, html_body)
|
||||||
|
|
||||||
|
|
||||||
|
def send_mail_template(to_emails, subject, template_name, context=None):
|
||||||
|
return mailer.send_email_with_template(to_emails, subject, template_name, context)
|
||||||
|
|
||||||
|
|
||||||
|
def send_user_created_email(to_email, username, view_url):
|
||||||
|
return mailer.send_user_created_email(to_email, username, view_url)
|
||||||
|
|
||||||
|
|
||||||
|
def send_welcome_email(to_email, username):
|
||||||
|
return mailer.send_welcome_email(to_email, username)
|
||||||
|
|
||||||
|
|
||||||
|
def send_secret_notification(to_email, username, secret_id=None):
|
||||||
|
return mailer.send_secret_notification(to_email, username, secret_id)
|
||||||
|
|
||||||
|
|
||||||
|
def send_secret_viewed_notification(to_email, username):
|
||||||
|
return mailer.send_secret_viewed_notification(to_email, username)
|
||||||
|
|
||||||
|
|
||||||
|
def send_secret_received_email(to_email, username, note=None):
|
||||||
|
return mailer.send_secret_received_email(to_email, username, note)
|
||||||
@@ -3,7 +3,11 @@ import base64
|
|||||||
import secrets
|
import secrets
|
||||||
import re
|
import re
|
||||||
import bleach
|
import bleach
|
||||||
from datetime import datetime
|
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 urllib.parse import quote_plus
|
||||||
from bleach import Linker
|
from bleach import Linker
|
||||||
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, abort, session, make_response
|
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, abort, session, make_response
|
||||||
@@ -27,6 +31,7 @@ load_dotenv()
|
|||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
APP_URL = os.environ.get('APP_URL', 'http://localhost:5000')
|
||||||
SECRET_KEY = os.environ.get('SECRET_KEY', secrets.token_hex(32))
|
SECRET_KEY = os.environ.get('SECRET_KEY', secrets.token_hex(32))
|
||||||
REDIS_HOST = os.environ.get('REDIS_HOST', 'localhost')
|
REDIS_HOST = os.environ.get('REDIS_HOST', 'localhost')
|
||||||
REDIS_PORT = int(os.environ.get('REDIS_PORT', 6379))
|
REDIS_PORT = int(os.environ.get('REDIS_PORT', 6379))
|
||||||
@@ -115,7 +120,7 @@ def create_admin():
|
|||||||
print(f" ВАЖНО: Очистите куки браузера перед входом!")
|
print(f" ВАЖНО: Очистите куки браузера перед входом!")
|
||||||
print(f"{'=' * 60}\n")
|
print(f"{'=' * 60}\n")
|
||||||
else:
|
else:
|
||||||
print(f"❌ Ошибка при создании администратора")
|
print(f"Ошибка при создании администратора")
|
||||||
else:
|
else:
|
||||||
print(f"\n{'=' * 60}")
|
print(f"\n{'=' * 60}")
|
||||||
print(f" Пользователи уже существуют")
|
print(f" Пользователи уже существуют")
|
||||||
@@ -123,6 +128,17 @@ def create_admin():
|
|||||||
print(f"{'=' * 60}\n")
|
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):
|
def custom_linkify(text):
|
||||||
linker = Linker(
|
linker = Linker(
|
||||||
callbacks=[
|
callbacks=[
|
||||||
@@ -138,10 +154,6 @@ def custom_linkify(text):
|
|||||||
|
|
||||||
database.db.init_app(app)
|
database.db.init_app(app)
|
||||||
|
|
||||||
'''with app.app_context():
|
|
||||||
database.init_db()
|
|
||||||
create_admin()'''
|
|
||||||
|
|
||||||
csrf = CSRFProtect(app)
|
csrf = CSRFProtect(app)
|
||||||
limiter = Limiter(
|
limiter = Limiter(
|
||||||
app=app,
|
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')
|
@app.template_filter('format_datetime')
|
||||||
def format_datetime(value, format='%Y-%m-%d %H:%M'):
|
def format_datetime(value, format='%Y-%m-%d %H:%M'):
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -231,7 +266,6 @@ def is_ip_allowed(ip: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -336,13 +370,17 @@ def add_security_headers(response):
|
|||||||
"form-action 'self'"
|
"form-action 'self'"
|
||||||
)
|
)
|
||||||
response.headers['Content-Security-Policy'] = csp
|
response.headers['Content-Security-Policy'] = csp
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def utility_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(
|
redis_client = Redis(
|
||||||
@@ -379,7 +417,8 @@ def generate_secret_id() -> str:
|
|||||||
return secrets.token_urlsafe(app.config['SECRET_ID_LENGTH'])
|
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()
|
secret_id = generate_secret_id()
|
||||||
|
|
||||||
encrypted = cipher.encrypt(plaintext.encode())
|
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:
|
if not user_exists:
|
||||||
user_id = None
|
user_id = None
|
||||||
|
|
||||||
clean_note = None
|
clean_request_note = None
|
||||||
if request_note:
|
if request_note:
|
||||||
clean_note = sanitize_input(request_note)
|
clean_request_note = sanitize_input(request_note)
|
||||||
if clean_note and len(clean_note) > 500:
|
if clean_request_note and len(clean_request_note) > 500:
|
||||||
clean_note = clean_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
|
return secret_id
|
||||||
|
|
||||||
|
|
||||||
@@ -519,7 +564,6 @@ def login():
|
|||||||
f'Неудачная попытка входа: {username}'
|
f'Неудачная попытка входа: {username}'
|
||||||
)
|
)
|
||||||
flash('Неверный логин или пароль', 'danger')
|
flash('Неверный логин или пароль', 'danger')
|
||||||
|
|
||||||
return render_template('login.html')
|
return render_template('login.html')
|
||||||
|
|
||||||
|
|
||||||
@@ -538,11 +582,98 @@ def logout():
|
|||||||
return redirect(url_for('index'))
|
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'])
|
@app.route('/create', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@limiter.limit("30 per minute")
|
@limiter.limit("30 per minute")
|
||||||
def create_secret():
|
def create_secret():
|
||||||
secret_text = request.form.get('secret', '').strip()
|
secret_text = request.form.get('secret', '').strip()
|
||||||
|
user_note = request.form.get('user_note', '').strip()
|
||||||
|
|
||||||
if not secret_text:
|
if not secret_text:
|
||||||
flash('Пожалуйста, введите секрет', 'danger')
|
flash('Пожалуйста, введите секрет', 'danger')
|
||||||
@@ -566,7 +697,7 @@ def create_secret():
|
|||||||
flash('Ваша сессия устарела. Пожалуйста, войдите заново.', 'warning')
|
flash('Ваша сессия устарела. Пожалуйста, войдите заново.', 'warning')
|
||||||
return redirect(url_for('login'))
|
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)
|
view_url = url_for('view_secret', secret_id=secret_id, _external=True)
|
||||||
|
|
||||||
database.log_security_event(
|
database.log_security_event(
|
||||||
@@ -750,7 +881,7 @@ def request_secret_submit(token):
|
|||||||
if clean_note and len(clean_note) > 500:
|
if clean_note and len(clean_note) > 500:
|
||||||
clean_note = 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)
|
view_url = url_for('view_secret', secret_id=secret_id, _external=True)
|
||||||
|
|
||||||
redis_client.delete(request_key)
|
redis_client.delete(request_key)
|
||||||
@@ -764,6 +895,30 @@ def request_secret_submit(token):
|
|||||||
user_id=int(user_id)
|
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')
|
flash('Секрет успешно отправлен! Получатель сможет его просмотреть.', 'success')
|
||||||
|
|
||||||
return render_template('request_secret_submit_done.html',
|
return render_template('request_secret_submit_done.html',
|
||||||
@@ -782,6 +937,8 @@ def my_requests():
|
|||||||
user_id = session.get('user_id')
|
user_id = session.get('user_id')
|
||||||
requests = []
|
requests = []
|
||||||
|
|
||||||
|
MAX_TTL = 24 * 60 * 60 # 24 часа
|
||||||
|
|
||||||
cursor = 0
|
cursor = 0
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -797,6 +954,11 @@ def my_requests():
|
|||||||
token = key.replace("request:", "")
|
token = key.replace("request:", "")
|
||||||
ttl = redis_client.ttl(key)
|
ttl = redis_client.ttl(key)
|
||||||
|
|
||||||
|
if ttl > 0:
|
||||||
|
created_at = datetime.now() - timedelta(seconds=(MAX_TTL - ttl))
|
||||||
|
else:
|
||||||
|
created_at = datetime.now() - timedelta(hours=24)
|
||||||
|
|
||||||
note_key = f"request_note:{token}"
|
note_key = f"request_note:{token}"
|
||||||
note = redis_client.get(note_key) or ''
|
note = redis_client.get(note_key) or ''
|
||||||
|
|
||||||
@@ -811,7 +973,7 @@ def my_requests():
|
|||||||
'note': safe_note,
|
'note': safe_note,
|
||||||
'ttl_seconds': ttl,
|
'ttl_seconds': ttl,
|
||||||
'ttl_hours': round(ttl / 3600, 1),
|
'ttl_hours': round(ttl / 3600, 1),
|
||||||
'created_at': datetime.now()
|
'created_at': created_at
|
||||||
})
|
})
|
||||||
|
|
||||||
if cursor == 0:
|
if cursor == 0:
|
||||||
@@ -971,8 +1133,7 @@ def admin_panel():
|
|||||||
def admin_create_user():
|
def admin_create_user():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
username = request.form.get('username', '').strip()
|
username = request.form.get('username', '').strip()
|
||||||
password = request.form.get('password', '')
|
email = request.form.get('email', '').strip()
|
||||||
confirm_password = request.form.get('confirm_password', '')
|
|
||||||
role = request.form.get('role', 'user')
|
role = request.form.get('role', 'user')
|
||||||
|
|
||||||
if not username or len(username) < 3:
|
if not username or len(username) < 3:
|
||||||
@@ -983,12 +1144,16 @@ def admin_create_user():
|
|||||||
flash('Логин может содержать только буквы, цифры и знак подчеркивания', 'danger')
|
flash('Логин может содержать только буквы, цифры и знак подчеркивания', 'danger')
|
||||||
return render_template('admin_user_create.html')
|
return render_template('admin_user_create.html')
|
||||||
|
|
||||||
if not password or len(password) < 6:
|
if not email:
|
||||||
flash('Пароль должен содержать минимум 6 символов', 'danger')
|
flash('Email обязателен для заполнения', 'danger')
|
||||||
return render_template('admin_user_create.html')
|
return render_template('admin_user_create.html')
|
||||||
|
|
||||||
if password != confirm_password:
|
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
|
||||||
flash('Пароли не совпадают', 'danger')
|
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')
|
return render_template('admin_user_create.html')
|
||||||
|
|
||||||
existing = database.get_user(username)
|
existing = database.get_user(username)
|
||||||
@@ -996,17 +1161,69 @@ def admin_create_user():
|
|||||||
flash(f'Пользователь "{username}" уже существует', 'danger')
|
flash(f'Пользователь "{username}" уже существует', 'danger')
|
||||||
return render_template('admin_user_create.html')
|
return render_template('admin_user_create.html')
|
||||||
|
|
||||||
if database.create_user(username, password, role):
|
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(
|
database.log_security_event(
|
||||||
request.remote_addr,
|
request.remote_addr,
|
||||||
'user_created',
|
'user_created_with_email',
|
||||||
f'Создан пользователь: {username} (role: {role}) администратором {session.get("username")}',
|
f'Создан пользователь: {username} (role: {role}) администратором {admin_username}. Пароль отправлен на {email}',
|
||||||
user_id=session.get('user_id')
|
user_id=admin_id
|
||||||
)
|
)
|
||||||
flash(f'Пользователь "{username}" успешно создан с ролью {role}', 'success')
|
flash(f'Пользователь "{username}" успешно создан! Пароль отправлен на email.', 'success')
|
||||||
return redirect(url_for('admin_panel'))
|
return redirect(url_for('admin_panel'))
|
||||||
else:
|
|
||||||
flash('Ошибка при создании пользователя', 'danger')
|
|
||||||
|
|
||||||
return render_template('admin_user_create.html')
|
return render_template('admin_user_create.html')
|
||||||
|
|
||||||
@@ -1242,6 +1459,10 @@ def health_check():
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
run_migrations()
|
||||||
|
create_admin()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(
|
app.run(
|
||||||
debug=app.config['DEBUG'],
|
debug=app.config['DEBUG'],
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from database import db
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations():
|
||||||
|
db.create_all()
|
||||||
|
print("Таблицы созданы (или уже существуют)")
|
||||||
|
_add_profile_columns()
|
||||||
|
print("Все миграции применены!")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_profile_columns():
|
||||||
|
inspector = inspect(db.engine)
|
||||||
|
existing_columns = [col['name'] for col in inspector.get_columns('users')]
|
||||||
|
|
||||||
|
columns_to_add = {
|
||||||
|
'email': 'VARCHAR(255) NULL',
|
||||||
|
'jabber': 'VARCHAR(255) NULL',
|
||||||
|
'updated_at': 'DATETIME NULL',
|
||||||
|
'notify_email': 'BOOLEAN DEFAULT 0',
|
||||||
|
'notify_jabber': 'BOOLEAN DEFAULT 0',
|
||||||
|
}
|
||||||
|
|
||||||
|
added = []
|
||||||
|
for col_name, col_type in columns_to_add.items():
|
||||||
|
if col_name not in existing_columns:
|
||||||
|
print(f"Добавляем колонку: {col_name}")
|
||||||
|
db.session.execute(text(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}"))
|
||||||
|
added.append(col_name)
|
||||||
|
|
||||||
|
if added:
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Добавлены колонки: {', '.join(added)}")
|
||||||
|
else:
|
||||||
|
print("ℹВсе колонки профиля уже существуют")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
run_migrations()
|
||||||
@@ -10,4 +10,3 @@ PyMySQL
|
|||||||
SQLAlchemy
|
SQLAlchemy
|
||||||
Flask-SQLAlchemy
|
Flask-SQLAlchemy
|
||||||
bleach
|
bleach
|
||||||
paramiko
|
|
||||||
@@ -170,6 +170,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Имя пользователя</th>
|
<th>Имя пользователя</th>
|
||||||
|
<th>Email</th>
|
||||||
<th>Роль</th>
|
<th>Роль</th>
|
||||||
<th>Создан</th>
|
<th>Создан</th>
|
||||||
<th>Последний вход</th>
|
<th>Последний вход</th>
|
||||||
@@ -182,6 +183,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ user.id }}</td>
|
<td>{{ user.id }}</td>
|
||||||
<td><strong>{{ user.username }}</strong></td>
|
<td><strong>{{ user.username }}</strong></td>
|
||||||
|
<td>{{ user.email or '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form action="{{ url_for('admin_change_role', user_id=user.id) }}" method="POST"
|
<form action="{{ url_for('admin_change_role', user_id=user.id) }}" method="POST"
|
||||||
class="d-flex">
|
class="d-flex">
|
||||||
@@ -227,6 +229,9 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
|
|||||||
@@ -14,23 +14,17 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">Логин</label>
|
<label for="username" class="form-label">Логин <span class="text-danger">*</span></label>
|
||||||
<input type="text" class="form-control" id="username" name="username"
|
<input type="text" class="form-control" id="username" name="username"
|
||||||
placeholder="Введите логин (минимум 3 символа)" required minlength="3">
|
placeholder="Введите логин (минимум 3 символа)" required minlength="3">
|
||||||
<div class="form-text text-secondary">Только буквы, цифры и знак подчеркивания</div>
|
<div class="form-text text-secondary">Только буквы, цифры и знак подчеркивания</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label">Пароль</label>
|
<label for="email" class="form-label">Email <span class="text-danger">*</span></label>
|
||||||
<input type="password" class="form-control" id="password" name="password"
|
<input type="email" class="form-control" id="email" name="email"
|
||||||
placeholder="Введите пароль (минимум 6 символов)" required minlength="6">
|
placeholder="user@example.com" required>
|
||||||
<div class="form-text text-secondary">Минимум 6 символов</div>
|
<div class="form-text text-secondary">На этот адрес будет отправлен пароль</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="confirm_password" class="form-label">Подтверждение пароля</label>
|
|
||||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password"
|
|
||||||
placeholder="Повторите пароль" required>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -39,9 +33,13 @@
|
|||||||
<option value="user">Пользователь</option>
|
<option value="user">Пользователь</option>
|
||||||
<option value="admin">Администратор</option>
|
<option value="admin">Администратор</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text text-secondary">Администратор может управлять пользователями и просматривать логи
|
<div class="form-text text-secondary">Администратор может управлять пользователями и просматривать логи секретов.</div>
|
||||||
секретов.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
<strong>Пароль будет сгенерирован автоматически</strong> и отправлен на указанный email
|
||||||
|
в виде секретной ссылки для безопасной передачи.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-secondary w-100">Создать пользователя</button>
|
<button type="submit" class="btn btn-secondary w-100">Создать пользователя</button>
|
||||||
|
|||||||
+6
-2
@@ -35,7 +35,9 @@
|
|||||||
<a href="{{ url_for('admin_panel') }}" class="btn btn-sm btn-secondary">Админка</a>
|
<a href="{{ url_for('admin_panel') }}" class="btn btn-sm btn-secondary">Админка</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="text-secondary small mx-1">|</span>
|
<span class="text-secondary small mx-1">|</span>
|
||||||
<span class="text-secondary small me-1">{{ current_user }}</span>
|
<a href="{{ url_for('profile') }}" class="text-decoration-none text-secondary small me-1">
|
||||||
|
<i class="bi bi-person-circle"></i> {{ current_user }}
|
||||||
|
</a>
|
||||||
<a href="{{ url_for('logout') }}" class="btn btn-sm btn-outline-danger">Выйти</a>
|
<a href="{{ url_for('logout') }}" class="btn btn-sm btn-outline-danger">Выйти</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('login') }}" class="btn btn-sm btn-secondary">Войти</a>
|
<a href="{{ url_for('login') }}" class="btn btn-sm btn-secondary">Войти</a>
|
||||||
@@ -57,7 +59,9 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
<footer class="pt-3 mt-4 border-top text-center text-secondary">
|
<footer class="pt-3 mt-4 border-top text-center text-secondary">
|
||||||
<small>Все данные шифруются и автоматически удаляются через 24 часа или после первого просмотра</small>
|
<small><a href="https://git.palchikov.name/PalchikovAleksandr/secrettext"
|
||||||
|
class="link-secondary link-offset-2 link-underline-opacity-25 link-underline-opacity-100-hover">Проект
|
||||||
|
с открытым исходным кодом secrettext</a></small>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% block title %}SecretText{% endblock %}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: 'Courier New', monospace; background-color: #fafafa;">
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #fafafa; padding: 30px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="480" cellpadding="0" cellspacing="0" border="0" style="width: 480px; background-color: #ffffff; border: 1px solid #cccccc;">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px 30px 14px 30px; border-bottom: 1px solid #cccccc; text-align: center;">
|
||||||
|
<span style="font-size: 16px; font-weight: 700; color: #000000; letter-spacing: 3px;">SECRETTEXT</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 24px 30px 20px 30px;">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 30px 16px 30px; border-top: 1px solid #dddddd; text-align: center; font-size: 11px; color: #999999;">
|
||||||
|
SecretText
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "emails/base_email.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div style="font-size: 14px; line-height: 1.6; color: #333333; margin-bottom: 16px;">
|
||||||
|
Кто-то отправил вам секрет.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 16px 0 14px 0;">
|
||||||
|
<a href="{{ base_url }}/my-secrets" style="display: inline-block; padding: 10px 28px; background-color: #000000; color: #ffffff; text-decoration: none; font-size: 13px; font-weight: 600; letter-spacing: 1px;">МОИ СЕКРЕТЫ</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 11px; color: #888888; text-align: center; word-break: break-all; margin-bottom: 14px;">
|
||||||
|
{{ base_url }}/my-secrets
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 11px; color: #666666; border-top: 1px solid #dddddd; padding-top: 12px; margin-top: 8px; line-height: 1.5;">
|
||||||
|
Секрет доступен для одноразового просмотра.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "emails/base_email.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div style="font-size: 14px; line-height: 1.6; color: #333333; margin-bottom: 16px;">
|
||||||
|
Вам отправили секрет по запросу.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if note %}
|
||||||
|
<div style="background-color: #f4f4f4; padding: 10px 14px; font-size: 13px; color: #666666; margin-bottom: 18px; border: 1px solid #dddddd;">
|
||||||
|
Примечание: {{ note }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 16px 0 14px 0;">
|
||||||
|
<a href="{{ base_url }}/my-secrets" style="display: inline-block; padding: 10px 28px; background-color: #000000; color: #ffffff; text-decoration: none; font-size: 13px; font-weight: 600; letter-spacing: 1px;">МОИ СЕКРЕТЫ</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 11px; color: #888888; text-align: center; word-break: break-all; margin-bottom: 14px;">
|
||||||
|
{{ base_url }}/my-secrets
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 11px; color: #666666; border-top: 1px solid #dddddd; padding-top: 12px; margin-top: 8px; line-height: 1.5;">
|
||||||
|
Секрет доступен для одноразового просмотра.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "emails/base_email.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div style="font-size: 14px; line-height: 1.6; color: #333333; margin-bottom: 16px;">
|
||||||
|
Ваш секрет был просмотрен.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 16px 0 14px 0;">
|
||||||
|
<a href="{{ base_url }}" style="display: inline-block; padding: 10px 28px; background-color: #000000; color: #ffffff; text-decoration: none; font-size: 13px; font-weight: 600; letter-spacing: 1px;">СОЗДАТЬ НОВЫЙ</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 11px; color: #888888; text-align: center; word-break: break-all;">
|
||||||
|
{{ base_url }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "emails/base_email.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div style="font-size: 14px; line-height: 1.6; color: #333333; margin-bottom: 16px;">
|
||||||
|
Для вас создан аккаунт в SecretText.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #f4f4f4; padding: 10px 14px; font-size: 14px; color: #000000; margin-bottom: 18px; border: 1px solid #dddddd;">
|
||||||
|
Логин: <span style="font-weight: 700;">{{ username }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 13px; color: #333333; margin-bottom: 10px;">
|
||||||
|
Ссылка для получения пароля:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 16px 0 14px 0;">
|
||||||
|
<a href="{{ view_url }}" style="display: inline-block; padding: 10px 28px; background-color: #000000; color: #ffffff; text-decoration: none; font-size: 13px; font-weight: 600; letter-spacing: 1px;">ПОЛУЧИТЬ ПАРОЛЬ</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 11px; color: #888888; text-align: center; word-break: break-all; margin-bottom: 14px;">
|
||||||
|
{{ view_url }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 11px; color: #666666; border-top: 1px solid #dddddd; padding-top: 12px; margin-top: 8px; line-height: 1.5;">
|
||||||
|
Ссылка действительна 24 часа.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
+10
-2
@@ -8,14 +8,22 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<textarea class="form-control" id="secret" name="secret" rows="6"
|
<textarea class="form-control" id="secret" name="secret" rows="6"
|
||||||
placeholder="" required></textarea>
|
placeholder="Введите ваш секрет..." required></textarea>
|
||||||
<div class="form-text text-secondary">Максимум {{ config.MAX_SECRET_LENGTH }} символов. Автоудаление
|
<div class="form-text text-secondary">Максимум {{ config.MAX_SECRET_LENGTH }} символов. Автоудаление
|
||||||
через 24 часа или после просмотра.
|
через 24 часа или после просмотра.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- НОВОЕ: поле для примечания -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="user_note" class="form-label">Примечание (только для вас)</label>
|
||||||
|
<input type="text" class="form-control" id="user_note" name="user_note"
|
||||||
|
placeholder="" maxlength="500">
|
||||||
|
<div class="form-text text-secondary">Не обязательно.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-secondary w-100">Создать ссылку</button>
|
<button type="submit" class="btn btn-secondary w-100">Создать ссылку</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h4 class="card-title text-center mb-4">Вход в систему</h4>
|
<h4 class="card-title text-center mb-4">Вход в систему</h4>
|
||||||
<p class="text-secondary text-center mb-3">Войдите под своей учётной записью</p>
|
|
||||||
<form method="POST" action="{{ url_for('login') }}">
|
<form method="POST" action="{{ url_for('login') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<button type="submit" class="btn btn-secondary w-100">Войти</button>
|
<button type="submit" class="btn btn-secondary w-100">Войти</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="text-center mt-3">
|
<div class="text-center mt-3">
|
||||||
<span class="text-secondary small">Если у вас нет аккаунта, обратитесь к администратору</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Профиль пользователя{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="mb-3">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5 class="mb-1">{{ user.username }}</h5>
|
||||||
|
<p class="text-muted small">Роль: {{ user.role }}</p>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-center gap-3 mb-3">
|
||||||
|
{% if user.is_active %}
|
||||||
|
<span class="badge bg-success">Активен</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">Деактивирован</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-start small text-muted">
|
||||||
|
<div class="mb-1"><i class="bi me-2"></i>Регистрация: {{ user.created_at|format_datetime }}</div>
|
||||||
|
<div class="mb-1"><i class="bi me-2"></i>Последний вход: {{ user.last_login|format_datetime or 'Никогда' }}</div>
|
||||||
|
{% if user.updated_at %}
|
||||||
|
<div><i class="bi me-2"></i>Обновлен: {{ user.updated_at|format_datetime }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" id="profileTabs" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link active" id="info-tab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#info" type="button" role="tab">
|
||||||
|
<i class="bi me-1"></i>Личные данные
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link" id="password-tab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#password" type="button" role="tab">
|
||||||
|
<i class="bi me-1"></i>Смена пароля
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Вкладка "Личные данные" -->
|
||||||
|
<div class="tab-pane fade show active" id="info" role="tabpanel">
|
||||||
|
<form method="POST" action="{{ url_for('profile') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email"
|
||||||
|
value="{{ user.email or '' }}" placeholder="user@example.com">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h6 class="mb-3">Настройки уведомлений</h6>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="notify_email"
|
||||||
|
name="notify_email" {% if user.notify_email %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="notify_email">
|
||||||
|
<i class="bi me-1"></i>Уведомления на email
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-secondary w-100">
|
||||||
|
<i class="bi me-1"></i>Сохранить изменения
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладка "Смена пароля" -->
|
||||||
|
<div class="tab-pane fade" id="password" role="tabpanel">
|
||||||
|
<form method="POST" action="{{ url_for('profile_change_password') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
<i class="bi me-2"></i>Для безопасности используйте сложный пароль (не менее 8 символов,
|
||||||
|
с буквами разного регистра и цифрами)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="old_password" class="form-label">Текущий пароль</label>
|
||||||
|
<input type="password" class="form-control" id="old_password"
|
||||||
|
name="old_password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_password" class="form-label">Новый пароль</label>
|
||||||
|
<input type="password" class="form-control" id="new_password"
|
||||||
|
name="new_password" required minlength="6">
|
||||||
|
<div class="form-text">Минимум 6 символов</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm_password" class="form-label">Подтвердите пароль</label>
|
||||||
|
<input type="password" class="form-control" id="confirm_password"
|
||||||
|
name="confirm_password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-secondary w-100">
|
||||||
|
<i class="bi me-1"></i>Сменить пароль
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user