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() login_manager = LoginManager()
csrf = CSRFProtect() csrf = CSRFProtect()
def create_app(): def create_app(config_overrides=None):
app = Flask(__name__) app = Flask(__name__)
app.config.from_object('app.config.Config') 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) db.init_app(app)
migrate.init_app(app, db) 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 import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
class Config:
SECRET_KEY = os.getenv('SECRET_KEY', 'change-me-in-production') def _database_url():
SQLALCHEMY_DATABASE_URI = ( if os.getenv('DATABASE_URL'):
return os.getenv('DATABASE_URL')
return (
f"postgresql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}" f"postgresql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}"
f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}" 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_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = { SQLALCHEMY_ENGINE_OPTIONS = {
'pool_pre_ping': True, '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.schedule import Schedule, SubjectConfig
from app.models.metric import Metric from app.models.metric import Metric
from app.models.share import Share, ShareEditor from app.models.share import Share, ShareEditor
__all__ = ["User", "Schedule", "SubjectConfig", "Metric", "Share", "ShareEditor"]

View file

@ -1,20 +1,12 @@
import urllib.parse import urllib.parse
from flask import Blueprint, Response, redirect, url_for, flash 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.schedule import Schedule
from app.models.share import Share from app.access import can_view as _can_view
from app import db
bp = Blueprint('export', __name__) 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') @bp.route('/schedules/<int:schedule_id>/export/json')
@login_required @login_required
def export_json(schedule_id: int): def export_json(schedule_id: int):

View file

@ -1,10 +1,10 @@
import copy 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) flash, request, jsonify)
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db, csrf from app import db, csrf
from app.models.schedule import Schedule, SubjectConfig 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.ai_scan import scan_image
from app.services.merge import merge_schedules_data from app.services.merge import merge_schedules_data
from app.services.schedule_helpers import ( from app.services.schedule_helpers import (
@ -46,8 +46,9 @@ def upload():
try: try:
image_bytes = file.read() image_bytes = file.read()
data = scan_image(image_bytes, ext, extra_prompt) data = scan_image(image_bytes, ext, extra_prompt)
except Exception as e: except Exception:
flash(f'Ошибка сканирования: {e}', 'danger') current_app.logger.exception('Ошибка распознавания расписания')
flash('Не удалось распознать расписание. Попробуйте другое фото или повторите позже.', 'danger')
return render_template('schedule/upload.html') return render_template('schedule/upload.html')
schedule = Schedule(user_id=current_user.id, name=name, data=data) 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): 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 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: for slot in slots:
while len(slot) <= idx: while len(slot) <= idx:
slot.append(None) slot.append(None)
@ -442,30 +443,3 @@ def delete_slot(schedule_id, subject_name):
db.session.commit() db.session.commit()
return jsonify({'ok': True}) 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) flash, request, jsonify)
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db, csrf 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.share import Share, ShareEditor
from app.models.user import User 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.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 from datetime import date
bp = Blueprint('shares', __name__) bp = Blueprint('shares', __name__)

View file

@ -1,10 +1,11 @@
from flask import Blueprint, render_template, request, jsonify from flask import Blueprint, current_app, jsonify, render_template, request
from flask_login import login_required, current_user from flask_login import login_required
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from app import db, csrf from app import db, csrf
from app.models.schedule import Schedule, SubjectConfig, COMPLETION_TYPES from app.models.schedule import Schedule, SubjectConfig, COMPLETION_TYPES
from app.models.metric import Metric, METRIC_TYPES, METRIC_LABELS from app.models.metric import Metric, METRIC_TYPES, METRIC_LABELS
from app.services.ai_metrics import generate_metric_from_prompt from app.services.ai_metrics import generate_metric_from_prompt
from app.access import can_edit as _check_edit_access
bp = Blueprint('subjects', __name__) bp = Blueprint('subjects', __name__)
@ -87,8 +88,9 @@ def add_metric_ai(schedule_id, subject_name):
try: try:
result = generate_metric_from_prompt(subject_name, prompt) result = generate_metric_from_prompt(subject_name, prompt)
except Exception as e: except Exception:
return jsonify({'error': str(e)}), 500 current_app.logger.exception('Ошибка генерации метрики')
return jsonify({'error': 'Не удалось сгенерировать метрику, попробуйте позже'}), 500
cfg = _get_or_create_config(schedule_id, subject_name) cfg = _get_or_create_config(schedule_id, subject_name)
metric = Metric(subject_config_id=cfg.id, 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 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: def _render_metric_html(metric: Metric) -> str:

View file

@ -1,8 +1,6 @@
import base64 import base64
import json import json
import os import os
import tempfile
from pathlib import Path
from openai import OpenAI from openai import OpenAI
from dotenv import load_dotenv from dotenv import load_dotenv

View file

@ -2,7 +2,7 @@ import csv
import io import io
import json import json
from flask import current_app, render_template from flask import render_template
DAYS_ORDER = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье'] DAYS_ORDER = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье']

6
run.py
View file

@ -1,9 +1,11 @@
import os
import sys import sys
sys.dont_write_bytecode = True sys.dont_write_bytecode = True
from app import create_app from app import create_app # noqa: E402
app = create_app() app = create_app()
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=False) app.run(debug=os.getenv('FLASK_DEBUG') == '1')