diff --git a/ui/api.py b/ui/api.py index 3b3d9a1..5a8bc3f 100644 --- a/ui/api.py +++ b/ui/api.py @@ -1,10 +1,13 @@ import logging import os -from typing import Any, Dict, Optional, Tuple, List, IO +from typing import Any, Dict, Optional, Tuple, List, IO import requests +import mimetypes +import unicodedata +import re from django.conf import settings from django.core.exceptions import PermissionDenied logger = logging.getLogger(__name__) class ApiError(Exception): @@ -14,6 +17,34 @@ class ApiError(Exception): self.status = status self.payload = payload or {} +def _get_setting(name: str, default: str = "") -> str: + return getattr(settings, name, "") or os.environ.get(name, "") or default + +def _api_base() -> str: + base = _get_setting("API_BASE_URL", "http://localhost:8080").rstrip("/") + return base + +def _safe_filename(name: str, default_ext: str = ".jpg") -> str: + """ + Нормализуем имя файла до ASCII, чтобы избежать падений бекенда на не-ASCII filename. + """ + name = name or f"photo{default_ext}" + base, ext = os.path.splitext(name) + # нормализуем и выкидываем не-ascii + base_ascii = unicodedata.normalize("NFKD", base).encode("ascii", "ignore").decode("ascii") or "photo" + # оставим алфанум/._- + base_ascii = re.sub(r"[^A-Za-z0-9._-]", "_", base_ascii) + ext = ext if ext else default_ext + if not ext.startswith("."): + ext = "." + ext + ext = re.sub(r"[^A-Za-z0-9._-]", "", ext) + return (base_ascii or "photo") + (ext or default_ext) + +def _guess_mime(filename: str, fallback: str = "application/octet-stream") -> str: + mt, _ = mimetypes.guess_type(filename) + return mt or fallback + + def _headers(request, *, for_files: bool = False, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: """ Если for_files=True, убираем Content-Type, чтобы requests сам проставил multipart/form-data. """ headers = { 'Accept': 'application/json', } if not for_files: headers['Content-Type'] = 'application/json' # токены и из cookies, и из session - token = ( - request.COOKIES.get('access_token') - or request.session.get('access_token') - or (request.session.get('auth') or {}).get('access_token') - ) + token = ( + (request.COOKIES.get('access_token') if hasattr(request, "COOKIES") else None) + or request.session.get('access_token') + or (request.session.get('auth') or {}).get('access_token') + ) if token: headers['Authorization'] = f'Bearer {token}' - api_key = getattr(settings, 'API_KEY', '') or os.environ.get('API_KEY', '') + api_key = _get_setting('API_KEY', '') if api_key: headers['X-API-Key'] = api_key if extra: headers.update(extra) return headers -def _url(path: str) -> str: - base = settings.API_BASE_URL.rstrip('/') - path = path if path.startswith('/') else '/' + path - return base + path +def _url(path: str) -> str: + path = path if path.startswith('/') else '/' + path + return _api_base() + path def request_api( request, method: str, path: str, *, params: Optional[dict] = None, json: Optional[dict] = None, - files: Optional[dict] = None + files: Optional[dict] = None, + auth_token: Optional[str] = None, # <— можно принудительно задать Authorization ) -> Tuple[int, Any]: url = _url(path) - # Включённый подробный лог согласно .env - api_debug = bool(int(getattr(settings, "API_DEBUG", 1))) + # Включённый подробный лог согласно .env + api_debug = bool(int(_get_setting("API_DEBUG", "1"))) + log_headers = bool(int(_get_setting("API_LOG_HEADERS", "1"))) + log_body_max = int(_get_setting("API_LOG_BODY_MAX", "2000")) req_id = os.urandom(4).hex() try: - hdrs = _headers(request, for_files=bool(files)) + extra_hdr = {'Authorization': f'Bearer {auth_token}'} if auth_token else None + hdrs = _headers(request, for_files=bool(files), extra=extra_hdr) if files and 'Content-Type' in hdrs: # гарантия: multipart должен ставить requests hdrs.pop('Content-Type', None) if api_debug: - safe_body = "***" if (files or (json and "password" in (json or {}))) else json - logger.info("API[req_id=%s] REQUEST %s %s params=%s headers=%s body=%s", - req_id, method.upper(), url, params, {k: ('***' if k=='Authorization' else v) for k,v in hdrs.items()}, safe_body) + safe_body = "***" if (files or (json and "password" in (json or {}))) else json + files_meta = None + if files: + files_meta = { + k: { + "filename": (v[0] if isinstance(v, (list, tuple)) and len(v) >= 1 else ""), + "content_type": (v[2] if isinstance(v, (list, tuple)) and len(v) >= 3 else ""), + "size": (getattr(v[1], "size", None) if isinstance(v, (list, tuple)) and len(v) >= 2 else None), + } + for k, v in files.items() + } + logger.info( + "API[req_id=%s] REQUEST %s %s params=%s headers=%s body=%s files=%s", + req_id, method.upper(), url, params, + ({k: ('***' if k=='Authorization' else v) for k,v in hdrs.items()} if log_headers else "{hidden}"), + safe_body, files_meta + ) resp = requests.request( method=method.upper(), url=url, headers=hdrs, params=params, json=(None if files else json), # при files json не отправляем files=files, - timeout=settings.API_TIMEOUT, + timeout=float(_get_setting("API_TIMEOUT", "6.0")), ) except requests.RequestException as e: logger.exception('API network error: %s', e) raise ApiError(0, f'Network unavailable or timeout when accessing API ({e})') content_type = resp.headers.get('Content-Type', '') try: data = resp.json() if 'application/json' in content_type else {} except ValueError: data = {} + # дополнительно снимем текст тела (например, при 500 text/plain) + text_body = "" + try: + if not data: + text_body = (resp.text or "")[:log_body_max] + except Exception: + text_body = "" - if api_debug: - logger.info("API[req_id=%s] RESPONSE %s %sms ct=%s headers=%s body=%s", - req_id, resp.status_code, getattr(resp, 'elapsed', None), content_type, - dict(list(resp.headers.items())[:10]), ('***' if 'access_token' in str(data) else data)) + if api_debug: + logger.info( + "API[req_id=%s] RESPONSE %s %sms ct=%s headers=%s body=%s text=%s", + req_id, resp.status_code, getattr(resp, 'elapsed', None), content_type, + (dict(list(resp.headers.items())[:10]) if log_headers else "{hidden}"), + ('***' if 'access_token' in str(data) else data), + text_body + ) # Попытка refresh, если есть refresh_token if resp.status_code == 401: refresh_token = ( - request.COOKIES.get('refresh_token') + (request.COOKIES.get('refresh_token') if hasattr(request, "COOKIES") else None) or request.session.get('refresh_token') or (request.session.get('auth') or {}).get('refresh_token') ) @@ -76,13 +137,15 @@ def request_api( content_type = resp.headers.get('Content-Type', '') try: data = resp.json() if 'application/json' in content_type else {} except ValueError: data = {} + if not data: + text_body = (resp.text or "")[:log_body_max] # если не вышло — пойдём ниже по обработке статуса except requests.RequestException as e: logger.exception('Refresh token error: %s', e) raise ApiError(401, f'Token refresh failed ({e})') if not (200 <= resp.status_code < 300): - msg = (data.get('detail') or data.get('message') or f'API error: {resp.status_code}') + msg = (data.get('detail') or data.get('message') or text_body or f'API error: {resp.status_code}') if resp.status_code in (401, 403): raise PermissionDenied(msg) raise ApiError(resp.status_code, msg, data) return resp.status_code, data @@ -90,6 +153,7 @@ def request_api( # ----- HIGH-LEVEL HELPERS ----- def get_my_profile(request) -> Dict[str, Any]: _, data = request_api(request, 'GET', '/profiles/v1/profiles/me') return data # ProfileOut @@ -101,10 +165,41 @@ def create_my_profile(request, gender: str, city: str, languages: List[str], interests: List[str]) -> Dict[str, Any]: _, data = request_api(request, 'POST', '/profiles/v1/profiles', json=payload) return data # ProfileOut +def get_current_user_with_token(request, token: str) -> Dict[str, Any]: + """ + Получить /auth/v1/me, принудительно подставляя только что полученный access_token. + Нельзя полагаться на cookies в том же запросе после логина. + """ + _, data = request_api(request, 'GET', '/auth/v1/me', auth_token=token) + return data # UserRead + def upload_my_profile_photo(request, file_obj: IO[bytes]) -> Dict[str, Any]: """ POST /profiles/v1/profiles/me/photo (multipart/form-data, field: file) - Возвращает ProfileOut с photo_url. (см. OpenAPI) + Возвращает ProfileOut с photo_url. (фактический контракт шлюза) """ - filename = getattr(file_obj, 'name', 'photo.jpg') - content_type = getattr(file_obj, 'content_type', 'application/octet-stream') - files = {'file': (filename, file_obj, content_type)} + # нормализуем имя и тип + raw_name = getattr(file_obj, 'name', 'photo.jpg') + safe_name = _safe_filename(raw_name, ".jpg") + content_type = getattr(file_obj, 'content_type', '') or _guess_mime(safe_name, 'image/jpeg') + # на всякий случай выставим указатель в начало + try: + file_obj.seek(0) + except Exception: + pass + files = {'file': (safe_name, file_obj, content_type)} _, data = request_api(request, 'POST', '/profiles/v1/profiles/me/photo', files=files) return data @@ -113,6 +208,40 @@ def upload_my_profile_photo(request, file_obj: IO[bytes]) -> Dict[str, Any]: def register_user(request, email: str, password: str, full_name: Optional[str] = None, role: str = 'CLIENT') -> Dict[str, Any]: json_body = {'email': email, 'password': password, 'full_name': full_name, 'role': role} _, data = request_api(request, 'POST', '/auth/v1/register', json=json_body) return data # Returns UserRead def login(request, email: str, password: str) -> Dict[str, Any]: json_body = {'email': email, 'password': password} _, data = request_api(request, 'POST', '/auth/v1/token', json=json_body) return data # Returns TokenPair diff --git a/ui/views.py b/ui/views.py index 9f42bd1..8a9b1c2 100644 --- a/ui/views.py +++ b/ui/views.py @@ -1,12 +1,14 @@ from typing import List, Dict, Any, Optional from django.http import Http404, HttpResponse, JsonResponse from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods, require_POST from django.contrib import messages from django.db.models import Q +from django.conf import settings from . import api from .api import ApiError from django.core.exceptions import PermissionDenied @@ -88,14 +90,14 @@ def profile_list(request): try: - data = api.list_users(request, offset=offset, limit=limit) + data = api.list_users(request, offset=offset, limit=limit) profiles = data if isinstance(data, list) else data.get("items", []) except PermissionDenied: messages.error(request, "Сессия истекла, войдите снова") - return redirect("cabinet") + return redirect("ui:cabinet") except ApiError as e: error = str(e) ctx = { "profiles": cards, "filters": filters, "count": len(cards), "error": error, } return render(request, "ui/profiles_list.html", ctx) @@ -150,22 +152,63 @@ def login_view(request): - if request.method == "POST": - email = (request.POST.get("email") or "").strip() - password = (request.POST.get("password") or "").strip() - if not email or not password: - messages.error(request, "Укажите email и пароль") - else: - try: - data = api.login(request, email, password) - # Expected: {'access_token': '...', 'user': {...}} - auth = {"access_token": data.get("access_token"), "user": data.get("user")} - if not auth["access_token"]: - raise ApiError(0, "API не вернул access_token") - request.session["auth"] = auth - request.session.modified = True - messages.success(request, "Вы успешно вошли") - next_url = request.GET.get("next") or "profiles" - return redirect(next_url) - except ApiError as e: - messages.error(request, f"Ошибка входа: {e.args[0]}") - return render(request, "ui/login.html", {}) + if request.method == "POST": + email = (request.POST.get("email") or "").strip() + password = (request.POST.get("password") or "").strip() + if not email or not password: + messages.error(request, "Укажите email и пароль") + else: + try: + toks = api.login(request, email, password) + access = toks.get("access_token") + refresh = toks.get("refresh_token") + if not access or not refresh: + raise ApiError(0, "Auth error: API не вернул токены") + + # ВАЖНО: сразу получаем /me, принудительно подставив свежий access + me = api.get_current_user_with_token(request, access) + + # сохраняем в session + request.session["access_token"] = access + request.session["refresh_token"] = refresh + request.session["user_id"] = me.get("id") + request.session["user_email"] = me.get("email") + request.session["user_full_name"] = me.get("full_name") or me.get("email") + request.session["user_role"] = me.get("role") + request.session["auth"] = {"access_token": access, "refresh_token": refresh, "user": me} + request.session.modified = True + + # редирект на нужную страницу + next_name = request.GET.get("next") + if not next_name: + next_name = "ui:profiles" if me.get("role") == "ADMIN" else "ui:cabinet" + resp = redirect(next_name) + + # выставим cookies для последующих запросов + cookie_kwargs = { + "httponly": True, + "secure": bool(getattr(settings, "SESSION_COOKIE_SECURE", False)), + "samesite": getattr(settings, "CSRF_COOKIE_SAMESITE", "Lax") or "Lax", + "max_age": 60 * 60 * 24 * 7, # 7 дней + } + resp.set_cookie("access_token", access, **cookie_kwargs) + resp.set_cookie("refresh_token", refresh, **cookie_kwargs) + + messages.success(request, "Вы успешно вошли") + return resp + except PermissionDenied as e: + messages.error(request, f"Доступ запрещён: {e}") + except ApiError as e: + messages.error(request, f"Ошибка входа: {e}") + return render(request, "ui/login.html", {}) @require_POST def logout_view(request): try: api.logout(request) finally: request.session.pop("auth", None) request.session.modified = True messages.info(request, "Вы вышли из аккаунта") - return redirect("ui:index") + return redirect("ui:index") @@ -180,6 +223,57 @@ def logout_view(request): return redirect("ui:index") @require_POST def cabinet_upload_photo(request): """ Загрузка фото: валидируем тип/размер заранее, отправляем multipart c безопасным именем. """ # проверка авторизации if not ( request.COOKIES.get('access_token') or request.session.get('access_token') or (request.session.get('auth') or {}).get('access_token') ): messages.error(request, "Сначала войдите") - return redirect('ui:login') + return redirect('ui:login') file_obj = request.FILES.get('file') or request.FILES.get('photo') if not file_obj: messages.error(request, "Не выбрано фото") - return redirect('ui:cabinet') + return redirect('ui:cabinet') # валидации (можно вынести в настройки) allowed = {'image/jpeg', 'image/png', 'image/webp'} max_mb = getattr(settings, "UPLOAD_MAX_MB", 10) max_bytes = max_mb * 1024 * 1024 ctype = (getattr(file_obj, 'content_type', '') or '').lower() size = getattr(file_obj, 'size', 0) if ctype and ctype not in allowed: messages.error(request, f"Недопустимый тип файла: {ctype}. Разрешено: JPEG/PNG/WebP") - return redirect('ui:cabinet') + return redirect('ui:cabinet') if size and size > max_bytes: messages.error(request, f"Файл слишком большой ({size//1024} КБ). Максимум {max_mb} МБ") - return redirect('ui:cabinet') + return redirect('ui:cabinet') try: prof = api.upload_my_profile_photo(request, file_obj) if prof and prof.get("photo_url"): messages.success(request, "Фото успешно обновлено") else: messages.info(request, "Фото загружено, но сервер не вернул ссылку. Обновите страницу позже.") except PermissionDenied: messages.error(request, "Сессия истекла, войдите снова") - return redirect('ui:login') + return redirect('ui:login') except ApiError as e: # покажем текст с бэка, если был (мы его теперь вытаскиваем из resp.text) messages.error(request, f"Не удалось загрузить фото: {e.payload.get('detail') or e.args[0]}") - return redirect('ui:cabinet') + return redirect('ui:cabinet') diff --git a/ui/urls.py b/ui/urls.py index 8b8e71b..36a3b88 100644 --- a/ui/urls.py +++ b/ui/urls.py @@ -1,16 +1,17 @@ from django.urls import path from . import views app_name = "ui" urlpatterns = [ path("", views.index, name="index"), path("login/", views.login_view, name="login"), path("logout/", views.logout_view, name="logout"), path("register/", views.register_view, name="register"), path("cabinet/", views.cabinet_view, name="cabinet"), + path("cabinet/photo/", views.cabinet_upload_photo, name="cabinet_upload_photo"), path("profiles/", views.profile_list, name="profiles"), path("profiles//", views.profile_detail, name="profile_detail"), path("profiles//like/", views.like_profile, name="like_profile"), ]