Первый коммит: загрузка проекта SecretText
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
instance/
|
||||||
|
*.pid
|
||||||
|
/.deploy_cache.db
|
||||||
|
/.env
|
||||||
|
/sync.py
|
||||||
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="~/Nextcloud/DEV/PYTHON/pass_toket/.venv" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/pass_toket.iml" filepath="$PROJECT_DIR$/.idea/pass_toket.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="jdk" jdkName="~/Nextcloud/DEV/PYTHON/pass_toket/.venv" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
<component name="PackageRequirementsSettings" />
|
||||||
|
<component name="PyDocumentationSettings" />
|
||||||
|
<component name="ReSTService" />
|
||||||
|
<component name="TestRunnerService" />
|
||||||
|
</module>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
+334
@@ -0,0 +1,334 @@
|
|||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
import bcrypt
|
||||||
|
import ipaddress
|
||||||
|
import html
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
username = Column(String(100), unique=True, nullable=False)
|
||||||
|
password_hash = Column(String(255), nullable=False)
|
||||||
|
role = Column(String(20), default='user')
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
last_login = Column(DateTime, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
secrets = relationship('Secret', back_populates='user', cascade='all, delete-orphan')
|
||||||
|
security_logs = relationship('SecurityLog', back_populates='user', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def set_password(self, password: str):
|
||||||
|
password_bytes = password.encode('utf-8')[:72]
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
self.password_hash = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
|
||||||
|
|
||||||
|
def check_password(self, password: str) -> bool:
|
||||||
|
password_bytes = password.encode('utf-8')[:72]
|
||||||
|
return bcrypt.checkpw(password_bytes, self.password_hash.encode('utf-8'))
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'username': self.username,
|
||||||
|
'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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Secret(db.Model):
|
||||||
|
__tablename__ = 'secrets'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
secret_id = Column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.now, index=True)
|
||||||
|
created_ip = Column(String(45), nullable=True)
|
||||||
|
created_user_agent = Column(String(255), nullable=True)
|
||||||
|
viewed_at = Column(DateTime, nullable=True)
|
||||||
|
viewed_ip = Column(String(45), nullable=True)
|
||||||
|
viewed_user_agent = Column(String(255), nullable=True)
|
||||||
|
is_viewed = Column(Boolean, default=False, index=True)
|
||||||
|
view_duration = Column(Integer, nullable=True)
|
||||||
|
request_note = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
user = relationship('User', back_populates='secrets')
|
||||||
|
security_logs = relationship('SecurityLog', back_populates='secret', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'secret_id': self.secret_id,
|
||||||
|
'user_id': self.user_id,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'created_ip': self.created_ip,
|
||||||
|
'viewed_at': self.viewed_at.isoformat() if self.viewed_at else None,
|
||||||
|
'viewed_ip': self.viewed_ip,
|
||||||
|
'is_viewed': self.is_viewed,
|
||||||
|
'view_duration': self.view_duration,
|
||||||
|
'request_note': self.request_note
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityLog(db.Model):
|
||||||
|
__tablename__ = 'security_logs'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
timestamp = Column(DateTime, default=datetime.now, index=True)
|
||||||
|
ip = Column(String(45), nullable=False, index=True)
|
||||||
|
event_type = Column(String(50), nullable=False, index=True)
|
||||||
|
details = Column(Text, nullable=True)
|
||||||
|
user_agent = Column(String(255), nullable=True)
|
||||||
|
secret_id = Column(String(100), ForeignKey('secrets.secret_id', ondelete='SET NULL'), nullable=True)
|
||||||
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
|
||||||
|
user = relationship('User', back_populates='security_logs')
|
||||||
|
secret = relationship('Secret', back_populates='security_logs')
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
|
||||||
|
'ip': self.ip,
|
||||||
|
'event_type': self.event_type,
|
||||||
|
'details': self.details,
|
||||||
|
'user_agent': self.user_agent,
|
||||||
|
'secret_id': self.secret_id,
|
||||||
|
'user_id': self.user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
password_bytes = password.encode('utf-8')[:72]
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
return bcrypt.hashpw(password_bytes, salt).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
password_bytes = password.encode('utf-8')[:72]
|
||||||
|
return bcrypt.checkpw(password_bytes, password_hash.encode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(username: str, password: str, role: str = 'user') -> bool:
|
||||||
|
try:
|
||||||
|
existing = User.query.filter_by(username=username).first()
|
||||||
|
if existing:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user = User(username=username, role=role)
|
||||||
|
user.set_password(password)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Ошибка создания пользователя: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(username: str) -> User | None:
|
||||||
|
return User.query.filter_by(username=username).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id: int) -> User | None:
|
||||||
|
return User.query.get(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def update_last_login(username: str):
|
||||||
|
user = get_user(username)
|
||||||
|
if user:
|
||||||
|
user.last_login = datetime.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_users() -> list:
|
||||||
|
return User.query.order_by(User.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(user_id: int):
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if user:
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_active(user_id: int, is_active: bool):
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if user:
|
||||||
|
user.is_active = is_active
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_role(user_id: int, role: str):
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if user:
|
||||||
|
user.role = role
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def log_creation(secret_id: str, client_info: dict, user_id: int = None, request_note: str = None):
|
||||||
|
secret = Secret(
|
||||||
|
secret_id=secret_id,
|
||||||
|
user_id=user_id,
|
||||||
|
created_ip=client_info.get('ip'),
|
||||||
|
created_user_agent=client_info.get('user_agent'),
|
||||||
|
request_note=request_note
|
||||||
|
)
|
||||||
|
db.session.add(secret)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def log_view(secret_id: str, client_info: dict):
|
||||||
|
secret = Secret.query.filter_by(secret_id=secret_id).first()
|
||||||
|
if secret:
|
||||||
|
if secret.created_at:
|
||||||
|
view_duration = int((datetime.now() - secret.created_at).total_seconds())
|
||||||
|
else:
|
||||||
|
view_duration = None
|
||||||
|
|
||||||
|
secret.viewed_at = datetime.now()
|
||||||
|
secret.viewed_ip = client_info.get('ip')
|
||||||
|
secret.viewed_user_agent = client_info.get('user_agent')
|
||||||
|
secret.is_viewed = True
|
||||||
|
secret.view_duration = view_duration
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def log_security_event(ip: str, event_type: str, details: str = None,
|
||||||
|
user_agent: str = None, secret_id: str = None, user_id: int = None):
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
ip = '0.0.0.0'
|
||||||
|
|
||||||
|
if details and len(details) > 1000:
|
||||||
|
details = details[:1000]
|
||||||
|
if user_agent:
|
||||||
|
user_agent = user_agent[:255]
|
||||||
|
|
||||||
|
log = SecurityLog(
|
||||||
|
ip=ip[:45],
|
||||||
|
event_type=event_type[:50],
|
||||||
|
details=details,
|
||||||
|
user_agent=user_agent,
|
||||||
|
secret_id=secret_id[:100] if secret_id else None,
|
||||||
|
user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.add(log)
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Error logging security event: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_secret_log(secret_id: str) -> dict | None:
|
||||||
|
secret = Secret.query.filter_by(secret_id=secret_id).first()
|
||||||
|
return secret.to_dict() if secret else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_logs(limit: int = 100, offset: int = 0, filter_viewed: str = None) -> list:
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
if limit > 1000:
|
||||||
|
limit = 1000
|
||||||
|
if offset < 0:
|
||||||
|
offset = 0
|
||||||
|
if offset > 100000:
|
||||||
|
offset = 100000
|
||||||
|
|
||||||
|
if filter_viewed not in ['viewed', 'pending', None]:
|
||||||
|
filter_viewed = None
|
||||||
|
|
||||||
|
query = Secret.query
|
||||||
|
if filter_viewed == 'viewed':
|
||||||
|
query = query.filter_by(is_viewed=True)
|
||||||
|
elif filter_viewed == 'pending':
|
||||||
|
query = query.filter_by(is_viewed=False)
|
||||||
|
|
||||||
|
return query.order_by(Secret.created_at.desc()).limit(limit).offset(offset).all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_stats() -> dict:
|
||||||
|
total = Secret.query.count()
|
||||||
|
viewed = Secret.query.filter_by(is_viewed=True).count()
|
||||||
|
not_viewed = Secret.query.filter_by(is_viewed=False).count()
|
||||||
|
|
||||||
|
unique_creators = db.session.query(Secret.created_ip).distinct().count()
|
||||||
|
unique_viewers = db.session.query(Secret.viewed_ip).distinct().filter(Secret.viewed_ip.isnot(None)).count()
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
avg_duration = db.session.query(func.avg(Secret.view_duration)).filter(Secret.is_viewed == True).scalar()
|
||||||
|
|
||||||
|
security_events = SecurityLog.query.count()
|
||||||
|
blocked_ips = SecurityLog.query.filter(
|
||||||
|
SecurityLog.event_type.in_(['temporary_block', 'permanent_block'])
|
||||||
|
).with_entities(SecurityLog.ip).distinct().count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total': total,
|
||||||
|
'viewed': viewed,
|
||||||
|
'not_viewed': not_viewed,
|
||||||
|
'viewed_percent': round((viewed / total * 100) if total > 0 else 0, 2),
|
||||||
|
'unique_creators': unique_creators,
|
||||||
|
'unique_viewers': unique_viewers,
|
||||||
|
'avg_view_duration': round(avg_duration / 60, 2) if avg_duration else 0,
|
||||||
|
'security_events': security_events,
|
||||||
|
'blocked_ips': blocked_ips
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_security_logs(limit: int = 50) -> list:
|
||||||
|
return SecurityLog.query.order_by(SecurityLog.timestamp.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_data():
|
||||||
|
cutoff_date = datetime.now() - timedelta(days=30)
|
||||||
|
old_secrets = Secret.query.filter(Secret.created_at < cutoff_date).all()
|
||||||
|
for secret in old_secrets:
|
||||||
|
db.session.delete(secret)
|
||||||
|
|
||||||
|
cutoff_logs = datetime.now() - timedelta(days=90)
|
||||||
|
old_logs = SecurityLog.query.filter(SecurityLog.timestamp < cutoff_logs).all()
|
||||||
|
for log in old_logs:
|
||||||
|
db.session.delete(log)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_secrets(user_id: int, limit: int = 100) -> list:
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
if limit > 500:
|
||||||
|
limit = 500
|
||||||
|
|
||||||
|
secrets = Secret.query.filter_by(user_id=user_id).order_by(Secret.created_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
for secret in secrets:
|
||||||
|
log = SecurityLog.query.filter_by(
|
||||||
|
secret_id=secret.secret_id,
|
||||||
|
event_type='secret_submitted_from_request'
|
||||||
|
).first()
|
||||||
|
secret.is_request = bool(log)
|
||||||
|
|
||||||
|
return secrets
|
||||||
|
|
||||||
|
|
||||||
|
def get_requests_for_user(user_id: int) -> list:
|
||||||
|
return SecurityLog.query.filter_by(
|
||||||
|
user_id=user_id,
|
||||||
|
event_type='secret_request_created'
|
||||||
|
).order_by(SecurityLog.timestamp.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
db.create_all()
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/exclude.txt
|
||||||
|
/sync.py
|
||||||
|
/.idea
|
||||||
|
/.git
|
||||||
|
/.env
|
||||||
|
/.gitignore
|
||||||
|
/.venv
|
||||||
|
/venv
|
||||||
|
/test
|
||||||
|
/.vscode
|
||||||
|
/uploads
|
||||||
|
/__pycache__
|
||||||
|
/test.py
|
||||||
|
/device.db
|
||||||
|
/.deploy_cache.db
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Flask
|
||||||
|
Flask-Bootstrap
|
||||||
|
Flask-WTF
|
||||||
|
Flask-Limiter
|
||||||
|
redis
|
||||||
|
cryptography
|
||||||
|
python-dotenv
|
||||||
|
bcrypt
|
||||||
|
PyMySQL
|
||||||
|
SQLAlchemy
|
||||||
|
Flask-SQLAlchemy
|
||||||
|
bleach
|
||||||
|
paramiko
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
from flask import request, render_template
|
||||||
|
import redis
|
||||||
|
import os
|
||||||
|
import database
|
||||||
|
|
||||||
|
redis_client = redis.Redis(
|
||||||
|
host=os.environ.get('REDIS_HOST', 'localhost'),
|
||||||
|
port=int(os.environ.get('REDIS_PORT', 6379)),
|
||||||
|
db=int(os.environ.get('REDIS_DB', 0)),
|
||||||
|
password=os.environ.get('REDIS_PASSWORD', None),
|
||||||
|
decode_responses=True
|
||||||
|
)
|
||||||
|
|
||||||
|
BLACKLIST_PREFIX = 'blacklist:'
|
||||||
|
ATTEMPTS_PREFIX = 'attempts:'
|
||||||
|
BLOCKED_PREFIX = 'blocked:'
|
||||||
|
|
||||||
|
SECURITY_CONFIG = {
|
||||||
|
'max_attempts': 5,
|
||||||
|
'block_duration': 300,
|
||||||
|
'block_duration_increment': 60,
|
||||||
|
'max_block_duration': 3600,
|
||||||
|
'attempt_window': 60,
|
||||||
|
'permanent_blacklist': False,
|
||||||
|
'permanent_after_blocks': 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
admin_attempts = defaultdict(list)
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_identifier():
|
||||||
|
ip = request.remote_addr
|
||||||
|
user_agent = request.headers.get('User-Agent', 'unknown')[:100]
|
||||||
|
identifier = hashlib.sha256(f"{ip}:{user_agent}".encode()).hexdigest()
|
||||||
|
return identifier, ip
|
||||||
|
|
||||||
|
|
||||||
|
def get_ip_block_info(ip: str) -> dict:
|
||||||
|
if redis_client.exists(f"{BLACKLIST_PREFIX}{ip}"):
|
||||||
|
return {'blocked': True, 'permanent': True, 'reason': 'Постоянная блокировка', 'until': None}
|
||||||
|
|
||||||
|
block_key = f"{BLOCKED_PREFIX}{ip}"
|
||||||
|
ttl = redis_client.ttl(block_key)
|
||||||
|
if ttl > 0:
|
||||||
|
return {
|
||||||
|
'blocked': True,
|
||||||
|
'permanent': False,
|
||||||
|
'reason': 'Слишком много попыток',
|
||||||
|
'until': time.time() + ttl,
|
||||||
|
'remaining_seconds': ttl,
|
||||||
|
'remaining_minutes': ttl // 60
|
||||||
|
}
|
||||||
|
return {'blocked': False}
|
||||||
|
|
||||||
|
|
||||||
|
def get_attempts_count(ip: str) -> int:
|
||||||
|
attempts_key = f"{ATTEMPTS_PREFIX}{ip}"
|
||||||
|
attempts_data = redis_client.get(attempts_key)
|
||||||
|
if not attempts_data:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
attempts = json.loads(attempts_data)
|
||||||
|
current_time = time.time()
|
||||||
|
valid_attempts = [t for t in attempts if current_time - t < SECURITY_CONFIG['attempt_window']]
|
||||||
|
return len(valid_attempts)
|
||||||
|
except:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def block_ip(ip: str):
|
||||||
|
block_count_key = f"{BLOCKED_PREFIX}{ip}:count"
|
||||||
|
block_count = int(redis_client.get(block_count_key) or 0)
|
||||||
|
|
||||||
|
block_duration = min(
|
||||||
|
SECURITY_CONFIG['block_duration'] + (block_count * SECURITY_CONFIG['block_duration_increment']),
|
||||||
|
SECURITY_CONFIG['max_block_duration']
|
||||||
|
)
|
||||||
|
|
||||||
|
redis_client.setex(f"{BLOCKED_PREFIX}{ip}", block_duration, '1')
|
||||||
|
redis_client.setex(block_count_key, 86400 * 30, str(block_count + 1))
|
||||||
|
redis_client.delete(f"{ATTEMPTS_PREFIX}{ip}")
|
||||||
|
|
||||||
|
database.log_security_event(
|
||||||
|
ip=ip,
|
||||||
|
event_type='temporary_block',
|
||||||
|
details=f'Блокировка на {block_duration} секунд (попытка #{block_count + 1})'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_failed_attempt(identifier: str):
|
||||||
|
_, ip = get_client_identifier()
|
||||||
|
attempts_key = f"{ATTEMPTS_PREFIX}{ip}"
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
attempts_data = redis_client.get(attempts_key)
|
||||||
|
attempts = []
|
||||||
|
if attempts_data:
|
||||||
|
try:
|
||||||
|
attempts = json.loads(attempts_data)
|
||||||
|
attempts = [t for t in attempts if current_time - t < SECURITY_CONFIG['attempt_window']]
|
||||||
|
except:
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
attempts.append(current_time)
|
||||||
|
redis_client.setex(attempts_key, SECURITY_CONFIG['attempt_window'] * 2, json.dumps(attempts))
|
||||||
|
|
||||||
|
if len(attempts) >= SECURITY_CONFIG['max_attempts']:
|
||||||
|
block_ip(ip)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_failed_attempts(identifier: str):
|
||||||
|
_, ip = get_client_identifier()
|
||||||
|
redis_client.delete(f"{ATTEMPTS_PREFIX}{ip}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_blocked_ips() -> list:
|
||||||
|
result = []
|
||||||
|
|
||||||
|
cursor = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
cursor, keys = redis_client.scan(
|
||||||
|
cursor,
|
||||||
|
match=f"{BLOCKED_PREFIX}*",
|
||||||
|
count=100
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
if key.endswith(':count'):
|
||||||
|
continue
|
||||||
|
ip = key.replace(BLOCKED_PREFIX, '')
|
||||||
|
ttl = redis_client.ttl(key)
|
||||||
|
if ttl > 0:
|
||||||
|
count_key = f"{BLOCKED_PREFIX}{ip}:count"
|
||||||
|
count = int(redis_client.get(count_key) or 1)
|
||||||
|
result.append({
|
||||||
|
'ip': ip,
|
||||||
|
'permanent': False,
|
||||||
|
'remaining_seconds': ttl,
|
||||||
|
'remaining_minutes': ttl // 60,
|
||||||
|
'block_count': count
|
||||||
|
})
|
||||||
|
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Redis scan error in get_blocked_ips: {e}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
cursor = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
cursor, keys = redis_client.scan(
|
||||||
|
cursor,
|
||||||
|
match=f"{BLACKLIST_PREFIX}*",
|
||||||
|
count=100
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
ip = key.replace(BLACKLIST_PREFIX, '')
|
||||||
|
result.append({'ip': ip, 'permanent': True, 'block_count': '∞'})
|
||||||
|
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Redis scan error in get_blocked_ips (blacklist): {e}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def unblock_ip(ip: str):
|
||||||
|
redis_client.delete(f"{BLACKLIST_PREFIX}{ip}")
|
||||||
|
redis_client.delete(f"{BLOCKED_PREFIX}{ip}")
|
||||||
|
redis_client.delete(f"{ATTEMPTS_PREFIX}{ip}")
|
||||||
|
redis_client.delete(f"{BLOCKED_PREFIX}{ip}:count")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_admin_rate_limit(ip: str) -> bool:
|
||||||
|
current_time = time.time()
|
||||||
|
admin_attempts[ip] = [t for t in admin_attempts[ip] if current_time - t < 300]
|
||||||
|
if len(admin_attempts[ip]) >= 5:
|
||||||
|
return True
|
||||||
|
admin_attempts[ip].append(current_time)
|
||||||
|
return False
|
||||||
Vendored
+6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2078
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Vendored
+7
File diff suppressed because one or more lines are too long
@@ -0,0 +1,365 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2 class="mb-0">Панель администратора</h2>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted me-2">{{ session.username }}</span>
|
||||||
|
<a href="{{ url_for('admin_logout') }}" class="btn btn-sm btn-outline-secondary">Выйти</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-xl-3 col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-uppercase text-muted small">Всего секретов</div>
|
||||||
|
<div class="h5 mb-0">{{ stats.total }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-uppercase text-muted small">Просмотрено</div>
|
||||||
|
<div class="h5 mb-0">{{ stats.viewed }} ({{ stats.viewed_percent }}%)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-uppercase text-muted small">Ожидают просмотра</div>
|
||||||
|
<div class="h5 mb-0">{{ stats.not_viewed }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-uppercase text-muted small">Заблокированные IP</div>
|
||||||
|
<div class="h5 mb-0">{{ stats.blocked_ips }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-4" id="adminTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="secrets-tab" data-bs-toggle="tab" data-bs-target="#secrets"
|
||||||
|
type="button" role="tab" aria-controls="secrets" aria-selected="true">
|
||||||
|
Секреты <span class="badge bg-secondary">{{ logs|length }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="users-tab" data-bs-toggle="tab" data-bs-target="#users" type="button"
|
||||||
|
role="tab" aria-controls="users" aria-selected="false">
|
||||||
|
Пользователи <span class="badge bg-secondary">{{ users|length }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="security-tab" data-bs-toggle="tab" data-bs-target="#security" type="button"
|
||||||
|
role="tab" aria-controls="security" aria-selected="false">
|
||||||
|
Безопасность
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="blocked-tab" data-bs-toggle="tab" data-bs-target="#blocked" type="button"
|
||||||
|
role="tab" aria-controls="blocked" aria-selected="false">
|
||||||
|
Заблокированные <span class="badge bg-secondary">{{ blocked_ips|length }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="secrets" role="tabpanel" aria-labelledby="secrets-tab">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span>Фильтр:</span>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin_panel') }}?filter=all"
|
||||||
|
class="btn btn-sm btn-outline-secondary {% if filter == 'all' or not filter %}active{% endif %}">Все</a>
|
||||||
|
<a href="{{ url_for('admin_panel') }}?filter=pending"
|
||||||
|
class="btn btn-sm btn-outline-secondary {% if filter == 'pending' %}active{% endif %}">Ожидают</a>
|
||||||
|
<a href="{{ url_for('admin_panel') }}?filter=viewed"
|
||||||
|
class="btn btn-sm btn-outline-secondary {% if filter == 'viewed' %}active{% endif %}">Просмотрены</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th>IP создания</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Просмотрен</th>
|
||||||
|
<th>IP просмотра</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td><code class="bg-light p-1 rounded">{{ log.secret_id[:12] }}...</code></td>
|
||||||
|
<td>{{ log.created_at|format_datetime if log.created_at else '-' }}</td>
|
||||||
|
<td><code>{{ log.created_ip or '-' }}</code></td>
|
||||||
|
<td>
|
||||||
|
{% if log.user %}
|
||||||
|
<span class="badge bg-secondary">{{ log.user.username }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Аноним</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ log.viewed_at|format_datetime if log.viewed_at else '-' }}</td>
|
||||||
|
<td><code>{{ log.viewed_ip or '-' }}</code></td>
|
||||||
|
<td>
|
||||||
|
{% if log.is_viewed %}
|
||||||
|
<span class="badge bg-secondary">Просмотрен</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Ожидает</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center py-4 text-muted">Нет записей</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if logs|length >= per_page %}
|
||||||
|
<div class="p-3">
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination justify-content-center mb-0">
|
||||||
|
<li class="page-item {% if page <= 1 %}disabled{% endif %}">
|
||||||
|
<a class="page-link"
|
||||||
|
href="{{ url_for('admin_panel', page=page-1, filter=filter) if page > 1 else '#' }}">Назад</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ page }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link"
|
||||||
|
href="{{ url_for('admin_panel', page=page+1, filter=filter) }}">Вперед</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="users" role="tabpanel" aria-labelledby="users-tab">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Управление пользователями</span>
|
||||||
|
<a href="{{ url_for('admin_create_user') }}" class="btn btn-sm btn-secondary">Создать
|
||||||
|
пользователя</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Имя пользователя</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th>Последний вход</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.id }}</td>
|
||||||
|
<td><strong>{{ user.username }}</strong></td>
|
||||||
|
<td>
|
||||||
|
<form action="{{ url_for('admin_change_role', user_id=user.id) }}" method="POST"
|
||||||
|
class="d-flex">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<select name="role" class="form-select form-select-sm me-1"
|
||||||
|
style="width: auto;">
|
||||||
|
<option value="user" {% if user.role==
|
||||||
|
'user' %}selected{% endif %}>user</option>
|
||||||
|
<option value="admin" {% if user.role==
|
||||||
|
'admin' %}selected{% endif %}>admin</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-secondary">Сохранить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>{{ user.created_at|format_datetime if user.created_at else '-' }}</td>
|
||||||
|
<td>{{ user.last_login|format_datetime if user.last_login else 'Никогда' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if user.is_active %}
|
||||||
|
<span class="badge bg-secondary">Активен</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Деактивирован</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="{{ url_for('admin_toggle_user', user_id=user.id) }}"
|
||||||
|
class="btn btn-outline-secondary">
|
||||||
|
{% if user.is_active %}Деактивировать{% else %}Активировать{% endif %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin_delete_user', user_id=user.id) }}"
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
onclick="return confirm('Удалить пользователя?')">Удалить</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center py-4 text-muted">Нет пользователей</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">Сменить пароль</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ url_for('admin_change_password') }}" method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="old_password" class="form-label small">Текущий пароль</label>
|
||||||
|
<input type="password" name="old_password" id="old_password"
|
||||||
|
class="form-control form-control-sm" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_password" class="form-label small">Новый пароль</label>
|
||||||
|
<input type="password" name="new_password" id="new_password"
|
||||||
|
class="form-control form-control-sm" required minlength="6">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm_password" class="form-label small">Подтвердите пароль</label>
|
||||||
|
<input type="password" name="confirm_password" id="confirm_password"
|
||||||
|
class="form-control form-control-sm" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm btn-secondary">Сменить пароль</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="security" role="tabpanel" aria-labelledby="security-tab">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Журнал событий безопасности</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Тип события</th>
|
||||||
|
<th>Детали</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in security_logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log.timestamp|format_datetime_seconds if log.timestamp else '-' }}</td>
|
||||||
|
<td><code>{{ log.ip }}</code></td>
|
||||||
|
<td><span class="badge bg-secondary">{{ log.event_type }}</span></td>
|
||||||
|
<td>{{ log.details or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if log.user %}
|
||||||
|
<span class="badge bg-secondary">{{ log.user.username }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Аноним</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-4 text-muted">Нет событий безопасности</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="blocked" role="tabpanel" aria-labelledby="blocked-tab">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Заблокированные IP-адреса</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>IP адрес</th>
|
||||||
|
<th>Тип блокировки</th>
|
||||||
|
<th>Осталось времени</th>
|
||||||
|
<th>Количество блокировок</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ip in blocked_ips %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ ip.ip }}</code></td>
|
||||||
|
<td>
|
||||||
|
{% if ip.permanent %}
|
||||||
|
<span class="badge bg-secondary">Постоянная</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Временная</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if ip.permanent %}
|
||||||
|
—
|
||||||
|
{% else %}
|
||||||
|
{{ ip.remaining_minutes }} мин.
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ ip.block_count }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="{{ url_for('admin_unblock', ip=ip.ip) }}"
|
||||||
|
class="btn btn-outline-secondary">Разблокировать</a>
|
||||||
|
{% if not ip.permanent %}
|
||||||
|
<a href="{{ url_for('admin_block', ip=ip.ip) }}"
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
onclick="return confirm('Заблокировать IP постоянно?')">Заблокировать</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-4 text-muted">Нет заблокированных IP-адресов</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Вход в админку{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-center">Вход в панель управления</h5>
|
||||||
|
<form method="POST" action="{{ url_for('admin_login') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Логин</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Пароль</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary w-100">Войти</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Создание пользователя{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-normal">Создание пользователя</h4>
|
||||||
|
<a href="{{ url_for('admin_panel') }}" class="btn btn-sm btn-outline-secondary">← Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('admin_create_user') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Логин</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="role" class="form-label">Роль</label>
|
||||||
|
<select class="form-control" id="role" name="role">
|
||||||
|
<option value="user">Пользователь</option>
|
||||||
|
<option value="admin">Администратор</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text text-secondary">Администратор может управлять пользователями и просматривать логи
|
||||||
|
секретов.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-secondary w-100">Создать пользователя</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}SecretText{% endblock %}</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='icons/bootstrap-icons.css') }}">
|
||||||
|
<link rel="icon" type="image/svg+xml"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23333'%3E%3Cpath fill-rule='evenodd' d='m4.736 1.968-.892 3.269-.014.058C2.113 5.568 1 6.006 1 6.5 1 7.328 4.134 8 8 8s7-.672 7-1.5c0-.494-1.113-.932-2.83-1.205l-.014-.058-.892-3.27c-.146-.533-.698-.849-1.239-.734C9.411 1.363 8.62 1.5 8 1.5s-1.411-.136-2.025-.267c-.541-.115-1.093.2-1.239.735m.015 3.867a.25.25 0 0 1 .274-.224c.9.092 1.91.143 2.975.143a30 30 0 0 0 2.975-.143.25.25 0 0 1 .05.498c-.918.093-1.944.145-3.025.145s-2.107-.052-3.025-.145a.25.25 0 0 1-.224-.274M3.5 10h2a.5.5 0 0 1 .5.5v1a1.5 1.5 0 0 1-3 0v-1a.5.5 0 0 1 .5-.5m-1.5.5q.001-.264.085-.5H2a.5.5 0 0 1 0-1h3.5a1.5 1.5 0 0 1 1.488 1.312 3.5 3.5 0 0 1 2.024 0A1.5 1.5 0 0 1 10.5 9H14a.5.5 0 0 1 0 1h-.085q.084.236.085.5v1a2.5 2.5 0 0 1-5 0v-.14l-.21-.07a2.5 2.5 0 0 0-1.58 0l-.21.07v.14a2.5 2.5 0 0 1-5 0zm8.5-.5h2a.5.5 0 0 1 .5.5v1a1.5 1.5 0 0 1-3 0v-1a.5.5 0 0 1 .5-.5'/%3E%3C/svg%3E">
|
||||||
|
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container py-4">
|
||||||
|
<header class="pb-3 mb-4 border-bottom">
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-6 fw-normal mb-0">
|
||||||
|
<a href="{{ url_for('index') }}" class="text-decoration-none text-dark">SecretText</a>
|
||||||
|
</h1>
|
||||||
|
<p class="text-secondary mb-0 small">Безопасная передача одноразовых секретов</p>
|
||||||
|
</div>
|
||||||
|
<nav class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
{% if is_authenticated %}
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-sm btn-outline-secondary">Главная</a>
|
||||||
|
<a href="{{ url_for('my_secrets') }}" class="btn btn-sm btn-outline-secondary">Мои секреты</a>
|
||||||
|
<a href="{{ url_for('request_secret_form') }}" class="btn btn-sm btn-outline-secondary">Запросить
|
||||||
|
секрет</a>
|
||||||
|
<a href="{{ url_for('my_requests') }}" class="btn btn-sm btn-outline-secondary">Мои запросы</a>
|
||||||
|
{% if is_admin %}
|
||||||
|
<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('logout') }}" class="btn btn-sm btn-outline-danger">Выйти</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('login') }}" class="btn btn-sm btn-secondary">Войти</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
<footer class="pt-3 mt-4 border-top text-center text-secondary">
|
||||||
|
<small>Все данные шифруются и автоматически удаляются через 24 часа или после первого просмотра</small>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js.js') }}"
|
||||||
|
integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Доступ заблокирован{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<h3 class="text-danger fw-normal">{% if permanent %}Доступ запрещён{% else %}Слишком много попыток{% endif
|
||||||
|
%}</h3>
|
||||||
|
<p class="text-secondary my-4">{{ reason }}</p>
|
||||||
|
{% if not permanent and remaining %}
|
||||||
|
<p class="text-secondary">Подождите: <strong>{{ (remaining // 60) }} минут {{ (remaining % 60) }}
|
||||||
|
секунд</strong></p>
|
||||||
|
<p class="small text-secondary">Попробуйте обновить страницу после ожидания</p>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary mt-3">Вернуться на главную</a>
|
||||||
|
{% if permanent %}<p class="small text-secondary mt-3">Для разблокировки обратитесь к администратору</p>{% endif
|
||||||
|
%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card border-secondary">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title">Секрет создан!</h5>
|
||||||
|
<p class="text-secondary">Ссылка для получения секрета готова.</p>
|
||||||
|
<div class="alert alert-secondary text-start position-relative">
|
||||||
|
<label class="form-label fw-bold small mb-1">
|
||||||
|
<i class="bi bi-clipboard me-1"></i> Скопируйте этот текст и отправьте получателю:
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="fullMessageBlock" class="bg-white p-2 rounded border"
|
||||||
|
style="font-family: monospace; font-size: 0.95rem; word-wrap: break-word; user-select: all; cursor: text; line-height: 1.6; white-space: pre-line;">Перейдите по ссылке, чтобы прочитать сообщение.
|
||||||
|
Ссылка действительна 24 часа.
|
||||||
|
После открытия доступ к данным будет потерян навсегда.
|
||||||
|
Повторно запросить или восстановить ссылку невозможно.
|
||||||
|
{{ link }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary js-copy-trigger" type="button"
|
||||||
|
data-target="fullMessageBlock">
|
||||||
|
<i class="bi bi-clipboard-plus"></i> Скопировать всё сообщение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-secondary text-start position-relative">
|
||||||
|
<label class="form-label fw-bold small mb-1">
|
||||||
|
<i class="bi bi-clipboard me-1"></i> Скопируйте этот текст и отправьте получателю:
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="fullMessageBlock1" class="bg-white p-2 rounded border"
|
||||||
|
style="font-family: monospace; font-size: 0.95rem; word-wrap: break-word; user-select: all; cursor: text; line-height: 1.6; white-space: pre-line;">Follow the link to read the message.
|
||||||
|
The link is valid for 24 hours.
|
||||||
|
Once opened, access to the data will be lost forever.
|
||||||
|
It is impossible to request the link again or restore it.
|
||||||
|
{{ link }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary js-copy-trigger" type="button"
|
||||||
|
data-target="fullMessageBlock1">
|
||||||
|
<i class="bi bi-clipboard-plus"></i> Скопировать всё сообщение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-secondary">Только ссылка:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="secretLink" value="{{ link }}" readonly>
|
||||||
|
<button class="btn btn-secondary" type="button" id="copyBtn">
|
||||||
|
<i class="bi bi-clipboard"></i> Копировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col">
|
||||||
|
<a href="{{ link }}" class="btn btn-secondary w-100" target="_blank" rel="noopener noreferrer">Открыть
|
||||||
|
секрет</a>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary w-100">Создать ещё</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|
||||||
|
var copyTriggers = document.querySelectorAll('.js-copy-trigger');
|
||||||
|
|
||||||
|
copyTriggers.forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var targetId = this.getAttribute('data-target');
|
||||||
|
var targetContainer = document.getElementById(targetId);
|
||||||
|
|
||||||
|
if (targetContainer) {
|
||||||
|
var textToCopy = targetContainer.innerText || targetContainer.textContent;
|
||||||
|
textToCopy = textToCopy.trim();
|
||||||
|
|
||||||
|
var isEnglish = targetId.includes('1');
|
||||||
|
var successMsg = isEnglish ? 'English text copied!' : 'Русский текст скопирован!';
|
||||||
|
|
||||||
|
handleCopy(textToCopy, this, successMsg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var copyLinkBtn = document.getElementById('copyBtn');
|
||||||
|
var input = document.getElementById('secretLink');
|
||||||
|
|
||||||
|
if (copyLinkBtn && input) {
|
||||||
|
copyLinkBtn.addEventListener('click', function () {
|
||||||
|
var textToCopy = input.value.trim();
|
||||||
|
handleCopy(textToCopy, this, 'Ссылка скопирована! / Link copied!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.handleCopyRegistered) {
|
||||||
|
window.handleCopyRegistered = true;
|
||||||
|
|
||||||
|
window.handleCopy = function (text, buttonElement, successMessage) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(function () {
|
||||||
|
processVisualSuccess(buttonElement, successMessage);
|
||||||
|
}).catch(function () {
|
||||||
|
executeFallback(text, buttonElement, successMessage);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
executeFallback(text, buttonElement, successMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.executeFallback = function (text, buttonElement, successMessage) {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
var successful = document.execCommand('copy');
|
||||||
|
if (successful) {
|
||||||
|
processVisualSuccess(buttonElement, successMessage);
|
||||||
|
} else {
|
||||||
|
alert('Не удалось скопировать. Выделите текст руками и нажмите Ctrl+C');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Не удалось скопировать. Выделите текст руками и нажмите Ctrl+C');
|
||||||
|
}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.processVisualSuccess = function (btn, message) {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(message, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalHtml = btn.innerHTML;
|
||||||
|
var isSmallBtn = btn.classList.contains('btn-sm');
|
||||||
|
|
||||||
|
btn.innerHTML = '<i class="bi bi-check-lg"></i> Скопировано!';
|
||||||
|
btn.classList.remove('btn-secondary', 'btn-outline-secondary');
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
btn.innerHTML = originalHtml;
|
||||||
|
btn.classList.remove('btn-success');
|
||||||
|
if (isSmallBtn) {
|
||||||
|
btn.classList.add('btn-outline-secondary');
|
||||||
|
} else {
|
||||||
|
btn.classList.add('btn-secondary');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<h3 class="text-danger fw-normal">Ошибка</h3>
|
||||||
|
<p class="text-secondary my-4">{{ error or 'Произошла непредвиденная ошибка' }}</p>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">На главную</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<h3 class="text-danger fw-normal">Секрет недоступен</h3>
|
||||||
|
<div class="my-4 text-secondary">
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li>Ссылка уже была использована</li>
|
||||||
|
<li>Истёк срок действия (24 часа)</li>
|
||||||
|
<li>Неверная ссылка</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">Создать новый секрет</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Ваш секрет</h5>
|
||||||
|
<form method="POST" action="{{ url_for('create_secret') }}">
|
||||||
|
<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>
|
||||||
|
<div class="form-text text-secondary">Максимум {{ config.MAX_SECRET_LENGTH }} символов. Автоудаление
|
||||||
|
через 24 часа или после просмотра.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary w-100">Создать ссылку</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Вход{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<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">
|
||||||
|
<label for="username" class="form-label">Логин</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Пароль</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Мои запросы{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-normal">Мои запросы на секрет</h4>
|
||||||
|
<a href="{{ url_for('request_secret_form') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi"></i> Создать запрос
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if requests %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Примечание</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th>Срок действия</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th class="text-center">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for req in requests %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code class="text-muted small">{{ req.token[:18] }}...</code>
|
||||||
|
</td>
|
||||||
|
<td style="white-space: pre-wrap; word-break: break-word; max-width: 150px;">{% if req.note %}<span
|
||||||
|
class="text-muted small">{{ req.note }}</span>{% else %}<span class="text-muted fst-italic small">Без примечания</span>{%
|
||||||
|
endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-muted small">{{ req.created_at|format_datetime }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if req.ttl_seconds > 0 %}<span class="text-muted small">{{ req.ttl_hours }} ч.</span> {% else %}<span
|
||||||
|
class="text-muted small">Истек</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if req.ttl_seconds > 0 %}<span class="badge bg-success">Активен</span>{% else %}<span
|
||||||
|
class="badge bg-secondary">Истек</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<div class="btn-group btn-group-sm"> {% if req.ttl_seconds > 0 %} <a href="{{ req.link }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
title="Открыть ссылку"> <i
|
||||||
|
class="bi bi-box-arrow-up-right"></i></a>
|
||||||
|
<button type="button" class="btn btn-outline-danger delete-request-btn"
|
||||||
|
data-url="{{ url_for('request_secret_delete', token=req.token) }}" title="Удалить запрос"><i
|
||||||
|
class="bi bi-trash"></i></button>
|
||||||
|
{% else %} <span class="text-muted small">Истек</span> {% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-inbox display-1 text-secondary"></i>
|
||||||
|
<p class="text-secondary mt-3">У вас пока нет запросов на секрет</p>
|
||||||
|
<a href="{{ url_for('request_secret_form') }}" class="btn btn-secondary">Создать первый запрос</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form id="globalDeleteForm" method="POST" style="display: none;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var deleteButtons = document.querySelectorAll('.delete-request-btn');
|
||||||
|
var globalForm = document.getElementById('globalDeleteForm');
|
||||||
|
|
||||||
|
deleteButtons.forEach(function (button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
if (confirm('Вы уверены, что хотите удалить этот запрос?')) {
|
||||||
|
var deleteUrl = this.getAttribute('data-url');
|
||||||
|
if (deleteUrl && globalForm) {
|
||||||
|
globalForm.action = deleteUrl;
|
||||||
|
globalForm.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Мои секреты{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-normal">Мои секреты</h4>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi"></i> Создать секрет
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if secrets %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Примечание</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Просмотрен</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for secret in secrets %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code class="text-muted small">{{ secret.secret_id[:12] }}...</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if secret.is_request %}
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<i class="bi bi-arrow-down-circle"></i> Получен
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-info">
|
||||||
|
<i class="bi bi-arrow-up-circle"></i> Создан
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if secret.request_note %}
|
||||||
|
<span class="text-muted small">{{ secret.request_note|e if secret.request_note else '-' }}</span>
|
||||||
|
{% elif secret.is_request %}
|
||||||
|
<span class="text-muted fst-italic small">Без примечания</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted fst-italic small">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-muted small">{{ secret.created_at|format_datetime }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if secret.is_viewed %}
|
||||||
|
<span class="badge bg-secondary">Просмотрен</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success">Активен</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if secret.is_viewed %}
|
||||||
|
<span class="text-muted small">{{ secret.viewed_at|format_datetime }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
{% if not secret.is_viewed %}
|
||||||
|
<a href="{{ url_for('view_secret', secret_id=secret.secret_id) }}"
|
||||||
|
class="btn btn-outline-secondary" title="Просмотреть секрет">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">Просмотрен</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-inbox display-1 text-secondary"></i>
|
||||||
|
<p class="text-secondary mt-3">У вас пока нет секретов</p>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">Создать первый секрет</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Запросить секрет{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title text-center mb-3">Запросить секрет</h4>
|
||||||
|
<p class="text-secondary text-center mb-4">
|
||||||
|
Создайте ссылку, по которой кто-то сможет отправить вам секрет.
|
||||||
|
Секрет будет доступен для одноразового просмотра в вашем ЛК.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
<strong>Как это работает:</strong>
|
||||||
|
<ol class="mb-0 small">
|
||||||
|
<li>Вы создаете ссылку-запрос</li>
|
||||||
|
<li>Отправляете ссылку получателю (по почте, в мессенджере)</li>
|
||||||
|
<li>Получатель переходит по ссылке и вводит секрет</li>
|
||||||
|
<li>Секрет появляется в ваших <strong>"Моих секретах"</strong> и доступен для одноразового просмотра
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('request_secret_create') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="note" class="form-label">Примечание только для Вас (необязательно)</label>
|
||||||
|
<textarea class="form-control" id="note" name="note" rows="3"
|
||||||
|
placeholder="Например: пароль от замка велосипеда"></textarea>
|
||||||
|
<div class="form-text text-secondary">Максимум 500 символов.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-secondary w-100">Создать ссылку-запрос</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card border-secondary">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title">Ссылка для запроса секрета создана!</h5>
|
||||||
|
<p class="text-secondary">Отправьте эту ссылку получателю. Ссылка действительна <strong>24 часа</strong>.</p>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
<strong>Важно:</strong> Получатель сможет отправить вам секрет по этой ссылке только один раз.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-secondary text-start position-relative">
|
||||||
|
<label class="form-label fw-bold small mb-1">
|
||||||
|
<i class="bi bi-clipboard me-1"></i> Скопируйте этот текст и отправьте получателю:
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="fullMessageBlock" class="bg-white p-2 rounded border"
|
||||||
|
style="font-family: monospace; font-size: 0.95rem; word-wrap: break-word; user-select: all; cursor: text; line-height: 1.6; white-space: pre-line;">Для безопасной передачи данных перейдите по ссылке, введите текст и нажмите "Отправить".
|
||||||
|
Ссылка сработает один раз.
|
||||||
|
Данные будут удалены сразу после просмотра или через 24 часа.
|
||||||
|
{{ link }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary js-copy-trigger" type="button"
|
||||||
|
data-target="fullMessageBlock">
|
||||||
|
<i class="bi bi-clipboard-plus"></i> Скопировать всё сообщение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-secondary text-start position-relative">
|
||||||
|
<label class="form-label fw-bold small mb-1">
|
||||||
|
<i class="bi bi-clipboard me-1"></i> Скопируйте этот текст и отправьте получателю:
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="fullMessageBlock1" class="bg-white p-2 rounded border"
|
||||||
|
style="font-family: monospace; font-size: 0.95rem; word-wrap: break-word; user-select: all; cursor: text; line-height: 1.6; white-space: pre-line;">To securely transmit data, please follow the link, enter your text, and click "Submit".
|
||||||
|
This link is single-use.
|
||||||
|
The data will be erased immediately upon viewing or after 24 hours.
|
||||||
|
{{ link }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary js-copy-trigger" type="button"
|
||||||
|
data-target="fullMessageBlock1">
|
||||||
|
<i class="bi bi-clipboard-plus"></i> Скопировать всё сообщение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 text-start">
|
||||||
|
<label class="form-label small text-secondary">Только ссылка:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="requestLink" value="{{ link }}" readonly>
|
||||||
|
<button class="btn btn-secondary" type="button" id="copyRequestLinkBtn">
|
||||||
|
<i class="bi bi-clipboard"></i> Копировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col">
|
||||||
|
<a href="{{ link }}" class="btn btn-secondary w-100" target="_blank" rel="noopener noreferrer">Открыть
|
||||||
|
ссылку</a>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<a href="{{ url_for('my_requests') }}" class="btn btn-outline-secondary w-100">Мои запросы</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|
||||||
|
var copyTriggers = document.querySelectorAll('.js-copy-trigger');
|
||||||
|
|
||||||
|
copyTriggers.forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var targetId = this.getAttribute('data-target');
|
||||||
|
var targetContainer = document.getElementById(targetId);
|
||||||
|
|
||||||
|
if (targetContainer) {
|
||||||
|
var textToCopy = targetContainer.innerText || targetContainer.textContent;
|
||||||
|
textToCopy = textToCopy.trim();
|
||||||
|
var isEnglish = targetId.includes('1');
|
||||||
|
var successMsg = isEnglish ? 'English text copied!' : 'Русский текст скопирован!';
|
||||||
|
|
||||||
|
handleCopy(textToCopy, this, successMsg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var copyLinkBtn = document.getElementById('copyRequestLinkBtn') || document.getElementById('copyBtn');
|
||||||
|
var input = document.getElementById('requestLink') || document.getElementById('secretLink');
|
||||||
|
|
||||||
|
if (copyLinkBtn && input) {
|
||||||
|
copyLinkBtn.addEventListener('click', function () {
|
||||||
|
var textToCopy = input.value.trim();
|
||||||
|
handleCopy(textToCopy, this, 'Ссылка скопирована! / Link copied!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopy(text, buttonElement, successMessage) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(function () {
|
||||||
|
processVisualSuccess(buttonElement, successMessage);
|
||||||
|
}).catch(function () {
|
||||||
|
executeFallback(text, buttonElement, successMessage);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
executeFallback(text, buttonElement, successMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeFallback(text, buttonElement, successMessage) {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
var successful = document.execCommand('copy');
|
||||||
|
if (successful) {
|
||||||
|
processVisualSuccess(buttonElement, successMessage);
|
||||||
|
} else {
|
||||||
|
alert('Не удалось скопировать. Выделите текст руками и нажмите Ctrl+C');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Не удалось скопировать. Выделите текст руками и нажмите Ctrl+C');
|
||||||
|
}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processVisualSuccess(btn, message) {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(message, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalHtml = btn.innerHTML;
|
||||||
|
var isSmallBtn = btn.classList.contains('btn-sm');
|
||||||
|
|
||||||
|
btn.innerHTML = '<i class="bi bi-check-lg"></i> Скопировано!';
|
||||||
|
btn.classList.remove('btn-secondary', 'btn-outline-secondary');
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
btn.innerHTML = originalHtml;
|
||||||
|
btn.classList.remove('btn-success');
|
||||||
|
if (isSmallBtn) {
|
||||||
|
btn.classList.add('btn-outline-secondary');
|
||||||
|
} else {
|
||||||
|
btn.classList.add('btn-secondary');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Ссылка недействительна{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<h5 class="card-title text-danger">Ссылка недействительна</h5>
|
||||||
|
<p class="text-secondary">
|
||||||
|
Ссылка для запроса секрета недействительна или истекла.
|
||||||
|
</p>
|
||||||
|
<ul class="list-unstyled text-secondary small">
|
||||||
|
<li>Ссылка уже была использована</li>
|
||||||
|
<li>Истек срок действия (24 часа)</li>
|
||||||
|
<li>Запрос был удален</li>
|
||||||
|
</ul>
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">На главную</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Отправить секрет{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title text-center mb-3">Отправить секрет</h4>
|
||||||
|
|
||||||
|
|
||||||
|
<p class="text-secondary text-center mb-4">
|
||||||
|
Пользователь <strong>{{ recipient_name }}</strong> запрашивает у вас данные.
|
||||||
|
Введите их и отправьте.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('request_secret_submit', token=token) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="secret" class="form-label">Секрет</label>
|
||||||
|
<textarea class="form-control" id="secret" name="secret" rows="6"
|
||||||
|
placeholder="" required></textarea>
|
||||||
|
<div class="form-text text-secondary">Максимум {{ config.MAX_SECRET_LENGTH }} символов. Автоудаление
|
||||||
|
через 24 часа или после просмотра.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-secondary w-100">Отправить секрет</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Секрет отправлен{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<h5 class="card-title">Секрет успешно отправлен!</h5>
|
||||||
|
<p class="text-secondary">
|
||||||
|
Ваш секрет получит <strong>{{ recipient_name }}</strong>.
|
||||||
|
</p>
|
||||||
|
<p class="text-secondary">
|
||||||
|
Секрет будет доступен получателю для <strong>одноразового просмотра</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">На главную</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert ">
|
||||||
|
<i class="bi me-2"></i>
|
||||||
|
<strong>Внимание!</strong> Данные удалены, после закрытия страницы она не откроется повторно.
|
||||||
|
<ul class="mb-0 mt-1 small">
|
||||||
|
<li>Обновление страницы или повторный переход по ссылке не откроют данные.</li>
|
||||||
|
<li>Восстановить информацию невозможно.</li>
|
||||||
|
<li>При необходимости <strong>сохраните или скопируйте</strong> информацию сейчас.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi me-2"></i>Полученное сообщение</span>
|
||||||
|
|
||||||
|
<button class="btn btn-sm btn-light"
|
||||||
|
id="viewCopyBtn"
|
||||||
|
title="Копировать секрет">
|
||||||
|
<i class="bi bi-clipboard"></i> Копировать секрет
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="bg-white p-4 rounded border"
|
||||||
|
id="secretContainer"
|
||||||
|
style="font-family: 'Courier New', monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.8;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 2px dashed #6c757d !important;
|
||||||
|
box-shadow: inset 0 0 10px rgba(0,0,0,0.05);
|
||||||
|
white-space: pre-wrap;">{{ secret | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||||
|
<div class="text-secondary small">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Размер: {{ secret|length }} символов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_authenticated %}
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi me-1"></i> Создать новый секрет
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var copyBtn = document.getElementById('viewCopyBtn');
|
||||||
|
var container = document.getElementById('secretContainer');
|
||||||
|
|
||||||
|
if (!copyBtn || !container) return;
|
||||||
|
|
||||||
|
copyBtn.addEventListener('click', function () {
|
||||||
|
var textToCopy = container.innerText || container.textContent;
|
||||||
|
textToCopy = textToCopy.trim();
|
||||||
|
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(textToCopy).then(function () {
|
||||||
|
processVisualSuccess();
|
||||||
|
}).catch(function () {
|
||||||
|
executeFallback(textToCopy);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
executeFallback(textToCopy);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function executeFallback(text) {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
var successful = document.execCommand('copy');
|
||||||
|
if (successful) {
|
||||||
|
processVisualSuccess();
|
||||||
|
} else {
|
||||||
|
alert('Не удалось скопировать автоматически. Выделите текст и нажмите Ctrl+C');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Не удалось скопировать автоматически. Выделите текст и нажмите Ctrl+C');
|
||||||
|
}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processVisualSuccess() {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Секрет успешно скопирован!', 'success');
|
||||||
|
}
|
||||||
|
var originalHtml = copyBtn.innerHTML;
|
||||||
|
copyBtn.innerHTML = '<i class="bi bi-check-lg"></i> Скопировано!';
|
||||||
|
copyBtn.classList.remove('btn-light');
|
||||||
|
copyBtn.classList.add('btn-success', 'text-white');
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
copyBtn.innerHTML = originalHtml;
|
||||||
|
copyBtn.classList.remove('btn-success', 'text-white');
|
||||||
|
copyBtn.classList.add('btn-light');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user