Compare commits

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