mirror of
https://github.com/EDeev/tablo.git
synced 2026-10-07 20:49:31 +03:00
Безопасность: права на экспорт, обязательный SECRET_KEY, ошибки ИИ не показываются пользователю
- экспорт в JSON/CSV/PDF/PNG пускал любого вошедшего к любому расписанию, у которого есть хоть одна ссылка-приглашение (достаточно перебрать id); теперь — только владелец и редакторы, как и просмотр по id; - три копии проверки прав сведены в app/access.py; - без SECRET_KEY приложение не запускается (кроме отладки и тестов): со значением по умолчанию cookie сессии можно было подделать и войти под любым пользователем; - текст исключения от ИИ-провайдера больше не уходит в интерфейс, ошибка пишется в лог; - DATABASE_URL как альтернатива DB_*; debug в run.py — из FLASK_DEBUG; - неиспользуемые импорты и переменные удалены (ruff).
This commit is contained in:
parent
af060982cf
commit
164623417c
11 changed files with 61 additions and 67 deletions
|
|
@ -9,9 +9,15 @@ migrate = Migrate()
|
|||
login_manager = LoginManager()
|
||||
csrf = CSRFProtect()
|
||||
|
||||
def create_app():
|
||||
def create_app(config_overrides=None):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object('app.config.Config')
|
||||
if config_overrides:
|
||||
app.config.update(config_overrides)
|
||||
if not app.config.get('SECRET_KEY'):
|
||||
if not (app.debug or app.testing):
|
||||
raise RuntimeError('Задайте SECRET_KEY: без него cookie сессий можно подделать')
|
||||
app.config['SECRET_KEY'] = 'dev-only-secret' # noqa: S105 — только для отладки и тестов
|
||||
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
|
|
|
|||
20
app/access.py
Normal file
20
app/access.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Права доступа к расписанию: владелец или редактор по ссылке на редактирование."""
|
||||
from flask_login import current_user
|
||||
|
||||
from app.models.schedule import Schedule
|
||||
from app.models.share import Share, ShareEditor
|
||||
|
||||
|
||||
def can_edit(schedule: Schedule) -> bool:
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
if schedule.user_id == current_user.id:
|
||||
return True
|
||||
edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
|
||||
if edit_share is None:
|
||||
return False
|
||||
return ShareEditor.query.filter_by(share_id=edit_share.id, user_id=current_user.id).first() is not None
|
||||
|
||||
|
||||
# Просмотр по id доступен тем же, кто может редактировать; остальные смотрят по ссылке /shared/<token>
|
||||
can_view = can_edit
|
||||
|
|
@ -1,14 +1,22 @@
|
|||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'change-me-in-production')
|
||||
SQLALCHEMY_DATABASE_URI = (
|
||||
|
||||
def _database_url():
|
||||
if os.getenv('DATABASE_URL'):
|
||||
return os.getenv('DATABASE_URL')
|
||||
return (
|
||||
f"postgresql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}"
|
||||
f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}"
|
||||
)
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||
SQLALCHEMY_DATABASE_URI = _database_url()
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_pre_ping': True,
|
||||
|
|
|
|||
|
|
@ -2,3 +2,5 @@ from app.models.user import User
|
|||
from app.models.schedule import Schedule, SubjectConfig
|
||||
from app.models.metric import Metric
|
||||
from app.models.share import Share, ShareEditor
|
||||
|
||||
__all__ = ["User", "Schedule", "SubjectConfig", "Metric", "Share", "ShareEditor"]
|
||||
|
|
|
|||
|
|
@ -1,20 +1,12 @@
|
|||
import urllib.parse
|
||||
from flask import Blueprint, Response, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from flask_login import login_required
|
||||
from app.models.schedule import Schedule
|
||||
from app.models.share import Share
|
||||
from app import db
|
||||
from app.access import can_view as _can_view
|
||||
|
||||
bp = Blueprint('export', __name__)
|
||||
|
||||
|
||||
def _can_view(schedule: Schedule) -> bool:
|
||||
if schedule.user_id == current_user.id:
|
||||
return True
|
||||
share = Share.query.filter_by(schedule_id=schedule.id).first()
|
||||
return share is not None
|
||||
|
||||
|
||||
@bp.route('/schedules/<int:schedule_id>/export/json')
|
||||
@login_required
|
||||
def export_json(schedule_id: int):
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import copy
|
||||
from flask import (Blueprint, render_template, redirect, url_for,
|
||||
from flask import (Blueprint, current_app, render_template, redirect, url_for,
|
||||
flash, request, jsonify)
|
||||
from flask_login import login_required, current_user
|
||||
from app import db, csrf
|
||||
from app.models.schedule import Schedule, SubjectConfig
|
||||
from app.models.metric import Metric
|
||||
from app.access import can_edit as _can_edit, can_view as _can_view
|
||||
from app.services.ai_scan import scan_image
|
||||
from app.services.merge import merge_schedules_data
|
||||
from app.services.schedule_helpers import (
|
||||
|
|
@ -46,8 +46,9 @@ def upload():
|
|||
try:
|
||||
image_bytes = file.read()
|
||||
data = scan_image(image_bytes, ext, extra_prompt)
|
||||
except Exception as e:
|
||||
flash(f'Ошибка сканирования: {e}', 'danger')
|
||||
except Exception:
|
||||
current_app.logger.exception('Ошибка распознавания расписания')
|
||||
flash('Не удалось распознать расписание. Попробуйте другое фото или повторите позже.', 'danger')
|
||||
return render_template('schedule/upload.html')
|
||||
|
||||
schedule = Schedule(user_id=current_user.id, name=name, data=data)
|
||||
|
|
@ -198,9 +199,9 @@ def rename_subject_field(schedule_id, subject_name):
|
|||
|
||||
def _replace_slot_field(item: dict, idx: int, value):
|
||||
"""Меняет поле слота по индексу во всех типах и датах"""
|
||||
for tn, type_data in item.get('types', {}).items():
|
||||
for type_data in item.get('types', {}).values():
|
||||
date_ranges = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data
|
||||
for dr, slots in date_ranges.items():
|
||||
for slots in date_ranges.values():
|
||||
for slot in slots:
|
||||
while len(slot) <= idx:
|
||||
slot.append(None)
|
||||
|
|
@ -442,30 +443,3 @@ def delete_slot(schedule_id, subject_name):
|
|||
db.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ─── Вспомогательные функции доступа ─────────────────────────────────────────
|
||||
|
||||
def _can_view(schedule: Schedule) -> bool:
|
||||
if schedule.user_id == current_user.id:
|
||||
return True
|
||||
from app.models.share import ShareEditor, Share
|
||||
edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
|
||||
if edit_share:
|
||||
editor = ShareEditor.query.filter_by(
|
||||
share_id=edit_share.id, user_id=current_user.id).first()
|
||||
if editor:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _can_edit(schedule: Schedule) -> bool:
|
||||
if schedule.user_id == current_user.id:
|
||||
return True
|
||||
from app.models.share import ShareEditor, Share
|
||||
edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
|
||||
if edit_share:
|
||||
editor = ShareEditor.query.filter_by(
|
||||
share_id=edit_share.id, user_id=current_user.id).first()
|
||||
if editor:
|
||||
return True
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ from flask import (Blueprint, render_template, redirect, url_for,
|
|||
flash, request, jsonify)
|
||||
from flask_login import login_required, current_user
|
||||
from app import db, csrf
|
||||
from app.models.schedule import Schedule, SubjectConfig
|
||||
from app.models.schedule import Schedule
|
||||
from app.models.share import Share, ShareEditor
|
||||
from app.models.user import User
|
||||
from app.services.schedule_helpers import build_week_view, get_week_dates, DAYS_DISPLAY, DAYS_RU
|
||||
from app.services.merge import merge_schedules_data
|
||||
from datetime import date
|
||||
|
||||
bp = Blueprint('shares', __name__)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from flask import Blueprint, render_template, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||
from flask_login import login_required
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from app import db, csrf
|
||||
from app.models.schedule import Schedule, SubjectConfig, COMPLETION_TYPES
|
||||
from app.models.metric import Metric, METRIC_TYPES, METRIC_LABELS
|
||||
from app.services.ai_metrics import generate_metric_from_prompt
|
||||
from app.access import can_edit as _check_edit_access
|
||||
|
||||
bp = Blueprint('subjects', __name__)
|
||||
|
||||
|
|
@ -87,8 +88,9 @@ def add_metric_ai(schedule_id, subject_name):
|
|||
|
||||
try:
|
||||
result = generate_metric_from_prompt(subject_name, prompt)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
except Exception:
|
||||
current_app.logger.exception('Ошибка генерации метрики')
|
||||
return jsonify({'error': 'Не удалось сгенерировать метрику, попробуйте позже'}), 500
|
||||
|
||||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||||
metric = Metric(subject_config_id=cfg.id,
|
||||
|
|
@ -282,15 +284,6 @@ def _get_or_create_config(schedule_id: int, subject_name: str) -> SubjectConfig:
|
|||
return config
|
||||
|
||||
|
||||
def _check_edit_access(schedule: Schedule) -> bool:
|
||||
if schedule.user_id == current_user.id:
|
||||
return True
|
||||
from app.models.share import Share, ShareEditor
|
||||
share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first()
|
||||
if share:
|
||||
return ShareEditor.query.filter_by(
|
||||
share_id=share.id, user_id=current_user.id).first() is not None
|
||||
return False
|
||||
|
||||
|
||||
def _render_metric_html(metric: Metric) -> str:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import base64
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from openai import OpenAI
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import csv
|
|||
import io
|
||||
import json
|
||||
|
||||
from flask import current_app, render_template
|
||||
from flask import render_template
|
||||
|
||||
DAYS_ORDER = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье']
|
||||
|
||||
|
|
|
|||
6
run.py
6
run.py
|
|
@ -1,9 +1,11 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
from app import create_app
|
||||
from app import create_app # noqa: E402
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=False)
|
||||
app.run(debug=os.getenv('FLASK_DEBUG') == '1')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue