1
0
Fork 0
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:
Деев Егор Викторович 2026-10-02 13:10:20 +00:00
parent af060982cf
commit 164623417c
11 changed files with 61 additions and 67 deletions

View file

@ -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
View 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

View file

@ -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,

View file

@ -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"]

View file

@ -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):

View file

@ -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

View file

@ -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__)

View file

@ -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:

View file

@ -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

View file

@ -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
View file

@ -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')