04b1625fde
- 2FA: убраны /api/ и /media/ из ALLOWED_PREFIXES — staff-сессия без TOTP больше не дотягивается до API/медиа (устройства в /api/ идут без staff-сессии) - Nextcloud «Обмен»: writable только для staff (не для любого logged-in) - Nextcloud путь отдела/адрес аптеки: safe_component() санитайзит сегмент (сепараторы/управляющие/ведущие точки) в провижининге и в браузере - Mattermost: shared secret кнопки «Виконано» перенесён из query string в context (тело POST) — без утечки в логи/Referer - Login: throttle на кэше (5 попыток / 5 мин, 429); logout только POST (CSRF), logout-ссылки в шаблонах → POST-формы Тесты обновлены под enforcement 2FA (login_2fa helper) и новые правила. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
102 lines
4.1 KiB
Python
Executable File
102 lines
4.1 KiB
Python
Executable File
import json
|
||
import logging
|
||
|
||
from django.conf import settings
|
||
from django.contrib.auth import get_user_model
|
||
from django.core import signing
|
||
from django.http import JsonResponse, HttpResponseForbidden
|
||
from django.views.decorators.csrf import csrf_exempt
|
||
from django.views.decorators.http import require_POST
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
Employee = get_user_model()
|
||
|
||
|
||
def _find_employee(mattermost_user_id):
|
||
if not mattermost_user_id:
|
||
return None
|
||
return Employee.objects.filter(mattermost_user_id=mattermost_user_id).first()
|
||
|
||
|
||
@csrf_exempt
|
||
@require_POST
|
||
def slash_command_tasks(request):
|
||
"""Обработчик слэш-команды /tasks — Mattermost шлёт application/x-www-form-urlencoded.
|
||
https://developers.mattermost.com/integrate/slash-commands/custom/"""
|
||
token = request.POST.get('token', '')
|
||
if not settings.MATTERMOST_SLASH_COMMAND_TOKEN or token != settings.MATTERMOST_SLASH_COMMAND_TOKEN:
|
||
return HttpResponseForbidden('invalid token')
|
||
|
||
employee = _find_employee(request.POST.get('user_id'))
|
||
if employee is None:
|
||
return JsonResponse({
|
||
'response_type': 'ephemeral',
|
||
'text': 'Не знайшов вашого облікового запису в порталі ACORE. Зверніться до HR.',
|
||
})
|
||
|
||
from apps.tasks.models import Task
|
||
tasks = (
|
||
Task.objects.for_user(employee)
|
||
.exclude(status__in=['done', 'cancelled'])
|
||
.order_by('due_date', '-priority')[:10]
|
||
)
|
||
if not tasks:
|
||
text = 'У вас немає активних задач. 🎉'
|
||
else:
|
||
base_url = settings.PORTAL_BASE_URL.rstrip('/')
|
||
lines = ['**Ваші активні задачі:**']
|
||
for task in tasks:
|
||
due = f' (до {task.due_date:%d.%m.%Y})' if task.due_date else ''
|
||
lines.append(f'- [{task.title}]({base_url}/tasks/{task.pk}/){due}')
|
||
text = '\n'.join(lines)
|
||
|
||
return JsonResponse({'response_type': 'ephemeral', 'text': text})
|
||
|
||
|
||
@csrf_exempt
|
||
@require_POST
|
||
def task_done_action(request):
|
||
"""Обработчик кнопки «Виконано» в інтерактивному повідомленні бота.
|
||
https://developers.mattermost.com/integrate/interactive-messages/
|
||
|
||
Fail-closed: потрібен MATTERMOST_INTERACTIVE_TOKEN (у context тіла POST)
|
||
+ валідний підписаний task id.
|
||
"""
|
||
expected = settings.MATTERMOST_INTERACTIVE_TOKEN
|
||
if not expected:
|
||
return HttpResponseForbidden('invalid token')
|
||
|
||
try:
|
||
payload = json.loads(request.body)
|
||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||
return HttpResponseForbidden('bad payload')
|
||
|
||
from apps.mattermost_connector.services.bot_service import TASK_ACTION_SALT, TASK_ACTION_MAX_AGE
|
||
|
||
context = payload.get('context') or {}
|
||
# Токен приходить у context (тіло POST), не в URL — без витоку в логи/Referer.
|
||
provided = context.get('token') or payload.get('token') or ''
|
||
if provided != expected:
|
||
return HttpResponseForbidden('invalid token')
|
||
|
||
try:
|
||
task_id = signing.loads(context.get('t', ''), salt=TASK_ACTION_SALT, max_age=TASK_ACTION_MAX_AGE)
|
||
except signing.BadSignature:
|
||
return JsonResponse({'ephemeral_text': 'Посилання застаріло або недійсне.'})
|
||
|
||
employee = _find_employee(payload.get('user_id'))
|
||
if employee is None:
|
||
return JsonResponse({'ephemeral_text': 'Не знайшов вашого облікового запису в порталі ACORE.'})
|
||
|
||
from apps.tasks.models import Task
|
||
task = Task.objects.filter(pk=task_id).first()
|
||
if task is None:
|
||
return JsonResponse({'ephemeral_text': 'Задачу не знайдено — можливо, її видалили.'})
|
||
|
||
if not task.is_participant(employee):
|
||
return JsonResponse({'ephemeral_text': 'У вас немає прав змінювати статус цієї задачі.'})
|
||
|
||
task.set_status('done')
|
||
return JsonResponse({'ephemeral_text': f'✅ Задача «{task.title}» позначена виконаною.'})
|