Compare commits
7
Commits
a86ffe5741
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fb52fd1cb | ||
|
|
cccfae3c51 | ||
|
|
7ae77c0775 | ||
|
|
216da22bc2 | ||
|
|
93fdac7de8 | ||
|
|
00e2f39480 | ||
|
|
09f02208af |
@@ -1,3 +1,4 @@
|
||||
APP_URL=http://localhost:5000
|
||||
|
||||
SECRET_KEY=
|
||||
WTF_CSRF_SECRET_KEY=
|
||||
@@ -24,3 +25,12 @@ DB_USER=root
|
||||
DB_PASSWORD=
|
||||
DB_NAME=secrettext
|
||||
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
|
||||
/.env
|
||||
/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():
|
||||
|
||||
database.init_db()
|
||||
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)
|
||||
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')
|
||||
security_logs = relationship('SecurityLog', back_populates='user', cascade='all, delete-orphan')
|
||||
|
||||
@@ -40,7 +49,10 @@ class User(db.Model):
|
||||
'role': self.role,
|
||||
'created_at': self.created_at.isoformat() if self.created_at 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()
|
||||
|
||||
|
||||
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_id=secret_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,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):
|
||||
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',
|
||||
f'Создан пользователь: {username} (role: {role}) администратором {session.get("username")}',
|
||||
user_id=session.get('user_id')
|
||||
'user_created_with_email',
|
||||
f'Создан пользователь: {username} (role: {role}) администратором {admin_username}. Пароль отправлен на {email}',
|
||||
user_id=admin_id
|
||||
)
|
||||
flash(f'Пользователь "{username}" успешно создан с ролью {role}', 'success')
|
||||
flash(f'Пользователь "{username}" успешно создан! Пароль отправлен на email.', 'success')
|
||||
return redirect(url_for('admin_panel'))
|
||||
else:
|
||||
flash('Ошибка при создании пользователя', 'danger')
|
||||
|
||||
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'],
|
||||
|
||||
+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
|
||||
Flask-SQLAlchemy
|
||||
bleach
|
||||
paramiko
|
||||
@@ -170,6 +170,7 @@
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя пользователя</th>
|
||||
<th>Email</th>
|
||||
<th>Роль</th>
|
||||
<th>Создан</th>
|
||||
<th>Последний вход</th>
|
||||
@@ -182,6 +183,7 @@
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td><strong>{{ user.username }}</strong></td>
|
||||
<td>{{ user.email or '-' }}</td>
|
||||
<td>
|
||||
<form action="{{ url_for('admin_change_role', user_id=user.id) }}" method="POST"
|
||||
class="d-flex">
|
||||
@@ -227,6 +229,9 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
|
||||
@@ -14,23 +14,17 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<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"
|
||||
placeholder="Введите логин (минимум 3 символа)" required minlength="3">
|
||||
<div class="form-text text-secondary">Только буквы, цифры и знак подчеркивания</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Пароль</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
placeholder="Введите пароль (минимум 6 символов)" required minlength="6">
|
||||
<div class="form-text text-secondary">Минимум 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"
|
||||
placeholder="Повторите пароль" required>
|
||||
<label for="email" class="form-label">Email <span class="text-danger">*</span></label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
placeholder="user@example.com" required>
|
||||
<div class="form-text text-secondary">На этот адрес будет отправлен пароль</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -39,9 +33,13 @@
|
||||
<option value="user">Пользователь</option>
|
||||
<option value="admin">Администратор</option>
|
||||
</select>
|
||||
<div class="form-text text-secondary">Администратор может управлять пользователями и просматривать логи
|
||||
секретов.
|
||||
<div class="form-text text-secondary">Администратор может управлять пользователями и просматривать логи секретов.</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-secondary">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>Пароль будет сгенерирован автоматически</strong> и отправлен на указанный email
|
||||
в виде секретной ссылки для безопасной передачи.
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
<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>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="btn btn-sm btn-secondary">Войти</a>
|
||||
@@ -57,7 +59,9 @@
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<footer class="pt-3 mt-4 border-top text-center text-secondary">
|
||||
<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>
|
||||
<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>
|
||||
</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 }}">
|
||||
<div class="mb-3">
|
||||
<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 }} символов. Автоудаление
|
||||
через 24 часа или после просмотра.
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<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') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
@@ -22,7 +22,7 @@
|
||||
<button type="submit" class="btn btn-secondary w-100">Войти</button>
|
||||
</form>
|
||||
<div class="text-center mt-3">
|
||||
<span class="text-secondary small">Если у вас нет аккаунта, обратитесь к администратору</span>
|
||||
|
||||
</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