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()
|
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
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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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__)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
6
run.py
|
|
@ -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')
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue