From d60dc1f8c28f68846386d0c67d8e1c007d1bf543ad502aa869b92bdd294af0ba Mon Sep 17 00:00:00 2001 From: Vladislav Date: Sun, 2 Aug 2026 12:08:08 -0400 Subject: [PATCH] fix --- auth.py | 48 +++++ build.ipynb | 68 +++++++ main.ipynb | 112 +++++++---- main.py | 25 ++- migrate.py | 3 + models.py | 2 +- static/icons/exit.svg | 2 +- static/img/icons/exit.svg | 2 +- templates/about.html | 12 +- templates/articles.html | 2 +- templates/base.html | 182 ++++++++++-------- templates/competition/card.html | 2 +- templates/competition/edit.html | 17 +- templates/competition/form.html | 2 +- templates/competition/index.html | 18 +- templates/competition/list.html | 2 +- templates/competition/member/index.html | 2 +- templates/components/checkbox.html | 2 +- templates/components/footer.html | 8 +- templates/components/header.html | 35 ++-- templates/components/profile_nav.html | 2 +- templates/error_4xx.html | 2 +- templates/exhibition/index.html | 18 +- templates/exhibitions.html | 4 +- templates/form/exhibition.html | 2 +- templates/form/exhibition_edit.html | 2 +- templates/index.html | 12 +- templates/login.html | 16 +- templates/main.html | 4 +- templates/news/edit.html | 2 +- templates/news/form.html | 2 +- templates/news/index.html | 14 +- templates/paintings/paint.html | 16 +- templates/partner/galerist.html | 2 +- templates/partner/list.html | 2 +- templates/posts/edit.html | 2 +- templates/posts/form.html | 6 +- templates/posts/profile_card.html | 2 +- templates/profile/admin/statistics.html | 2 +- templates/profile/competition.html | 2 +- templates/profile/competition/index.html | 2 +- templates/profile/competition/select.html | 2 +- templates/profile/delivery.html | 6 +- templates/profile/delivery_history.html | 6 +- templates/profile/exhibition.html | 2 +- templates/profile/form.html | 2 +- templates/profile/index.html | 2 +- templates/profile/invitation.html | 6 +- templates/profile/invitation/competition.html | 4 +- templates/profile/invitations.html | 4 +- templates/profile/news.html | 4 +- templates/profile/partner/competition.html | 4 +- .../profile/partner/competition_member.html | 4 +- templates/profile/partner/competitions.html | 2 +- .../profile/partner/exhibition catalog.html | 2 +- templates/profile/partner/exhibition.html | 2 +- templates/profile/partner/exhibitions.html | 2 +- templates/profile/password.html | 2 +- templates/profile/posts.html | 2 +- templates/profile/social.html | 6 +- templates/registration.html | 2 +- templates/restore.html | 2 +- templates/restore_token.html | 2 +- templates/users/index.html | 2 +- templates/users/user.html | 2 +- token_manager.py | 5 +- utils.py | 6 + vk.py | 12 +- 68 files changed, 479 insertions(+), 280 deletions(-) create mode 100644 migrate.py diff --git a/auth.py b/auth.py index 64b2434..aa0c491 100644 --- a/auth.py +++ b/auth.py @@ -266,6 +266,8 @@ async def get_user_details(access: str, scopes, response: Response, request: Req {"hashed_password": 0} ) if user: + if user.get("blocked"): + raise HTTPException(status_code=403, detail="Аккаунт заблокирован администратором") user['scopes'] = scopes user['id'] = user_id_str user = bson_clear(user) @@ -302,6 +304,8 @@ async def get_user( user = await users_collection.find_one({"_id": ObjectId(user_id_str)}, {"hashed_password": 0}) if not user: return None + if user.get("blocked"): + return None scopes = get_all_user_scopes(user.get("roles", []), user.get("extra_scopes", [])) if request.cookies.get("admin_access_token"): @@ -315,3 +319,47 @@ async def get_user( return None except jwt.InvalidTokenError: return None + + +async def issue_session(user_id: str, request: Request, response: Response, + client_id: str = 'web', scopes=None): + """Выдаёт новую сессию: JWT access/refresh, запись в Redis и cookie. + + Переиспользуемый хелпер (например, для входа/регистрации через VK). + Если scopes не переданы — вычисляются из ролей пользователя. + """ + if scopes is None: + user = await users_collection.find_one({"_id": ObjectId(user_id)}) + scopes = get_all_user_scopes(user.get("roles", []), user.get("extra_scopes", [])) + + client_ip = request.headers.get('X-Real-IP') or (request.client.host if request.client else None) + + access_exp = timedelta(days=ACCESS_TOKEN_EXPIRE_MINUTES) + refresh_exp = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + + access_token = create_jwt_token( + {"sub": user_id, "scopes": scopes, "client_id": client_id, "ip": client_ip}, + access_exp, + ) + refresh_token = create_jwt_token( + {"sub": user_id, "type": "refresh", "client_id": client_id, "ip": client_ip}, + refresh_exp, + ) + + await redis_client.setex(f"access:{access_token}", int(access_exp.total_seconds()), user_id) + await redis_client.setex(f"refresh:{hash_token(refresh_token)}", int(refresh_exp.total_seconds()), user_id) + + session_key = f"user_sessions:{user_id}" + await redis_client.sadd(session_key, f"access:{access_token}") + await redis_client.sadd(session_key, f"refresh:{hash_token(refresh_token)}") + + response.set_cookie( + key="access_token", value=access_token, httponly=True, secure=PROD, + samesite="Strict", max_age=int(access_exp.total_seconds()), + ) + response.set_cookie( + key="refresh_token", value=refresh_token, httponly=True, secure=PROD, + samesite="Strict", max_age=int(refresh_exp.total_seconds()), + ) + + return access_token, refresh_token diff --git a/build.ipynb b/build.ipynb index a6da596..60915f3 100644 --- a/build.ipynb +++ b/build.ipynb @@ -773,6 +773,74 @@ ], "display_name": ".venv" }, + { + "cell_type": "markdown", + "id": "4c434852", + "metadata": {}, + "source": [ + "### local service" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45bddd01", + "metadata": { + "vscode": { + "languageId": "shellscript" + } + }, + "outputs": [], + "source": [ + "%%bash\n", + "'tee /etc/systemd/system/staging.service >/dev/null' <<'EOF'\n", + "[Unit]\n", + "Description=FastAPI stag service\n", + "After=network.target\n", + "\n", + "[Service]\n", + "WorkingDirectory=$pwd\n", + "ExecStart=$pwd/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 30082 --reload\n", + "Restart=always\n", + "EnvironmentFile=$pwd/env\n", + "\n", + "[Install]\n", + "WantedBy=default.target\n", + "EOF" + ], + "display_name": ".venv" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "837698d9", + "metadata": { + "vscode": { + "languageId": "shellscript" + } + }, + "outputs": [], + "source": [ + "%%bash\n", + "ssh kultmost 'tee /etc/systemd/system/staging.service >/dev/null' <<'EOF'\n", + "[Unit]\n", + "Description=FastAPI stag service\n", + "After=network.target\n", + "\n", + "[Service]\n", + "WorkingDirectory=/app/staging\n", + "ExecStart=/app/staging/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 30082 --reload\n", + "Restart=always\n", + "EnvironmentFile=/app/staging/env\n", + "\n", + "[Install]\n", + "WantedBy=default.target\n", + "EOF\n", + "\n", + "ssh kultmost 'systemctl daemon-reload && systemctl enable --now staging.service && systemctl status --no-pager -l staging.service'\n" + ], + "display_name": ".venv" + }, { "cell_type": "markdown", "id": "0474516d", diff --git a/main.ipynb b/main.ipynb index 7ded608..913521e 100644 --- a/main.ipynb +++ b/main.ipynb @@ -1056,12 +1056,18 @@ " identifier = safe_strip(login_data.identifier)\n", " await check_bruteforce(identifier)\n", "\n", - " users = [doc async for doc in users_collection.find({\n", - " \"$or\": [\n", + " or_conditions = [\n", " {\"email\": identifier},\n", - " {\"email\": identifier.lower()}\n", + " {\"email\": identifier.lower()},\n", " ]\n", - " })]\n", + " \n", + " try:\n", + " normalized_phone = normalize_phone(identifier)\n", + " if normalized_phone:\n", + " or_conditions.append({\"phone\": normalized_phone})\n", + " except:\n", + " pass\n", + " users = [doc async for doc in users_collection.find({\"$or\": or_conditions})]\n", "\n", "\n", " if not users:\n", @@ -1790,43 +1796,77 @@ " request: Request,\n", " restore_data: RestoreRequest = Body(...),\n", "):\n", - " email = restore_data.identifier.strip().lower()\n", - " next = restore_data.next\n", + " # 1. Валидация и очистка входных данных\n", + " identifier = restore_data.identifier.strip().lower()\n", " \n", - " user = await users_collection.find_one({'$or': [\n", - " {'email': email}, \n", - " {'email': email.lower()}\n", - " ]})\n", + " if not identifier or not is_valid_email(identifier):\n", + " raise HTTPException(status_code=400, detail=\"Некорректный формат email\")\n", + "\n", + " # 2. Проверка анти-спама / ограничения частоты запросов\n", + " client_ip = request.client.host\n", + " rate_limit_key = f\"rate_limit:restore:{identifier}:{client_ip}\"\n", + " \n", + " # Проверяем, запрашивал ли пользователь сброс недавно (например, за последние 60 секунд)\n", + " existing_request = await redis_client.get(rate_limit_key)\n", + " \n", + " if existing_request:\n", + " # Опционально: получаем TTL, чтобы сообщить пользователю, когда можно повторить попытку\n", + " ttl = await redis_client.ttl(rate_limit_key)\n", + " raise HTTPException(\n", + " status_code=429, \n", + " detail=f\"Слишком много запросов. Попробуйте через {ttl} секунд.\"\n", + " )\n", + "\n", + " # Устанавливаем флаг ограничения частоты (истекает через 60 секунд)\n", + " await redis_client.set(rate_limit_key, \"1\", ex=60)\n", + "\n", + " # 3. Поиск пользователя\n", + " user = await users_collection.find_one({\"email\": identifier})\n", " \n", " if not user:\n", - " raise HTTPException(status_code=404, detail='Email не найден')\n", + " # Безопасность: всегда возвращать одинаковое время ответа/структуру сообщения, если возможно\n", + " # Но для удобства использования мы можем сказать \"не найден\".\n", + " # Чтобы предотвратить перечисление, некоторые приложения всегда возвращают \"OK\", но отправляют письмо только если найдено.\n", + " # Здесь мы придерживаемся вашей оригинальной логики, но помните о риске перечисления.\n", + " raise HTTPException(status_code=404, detail=\"Пользователь с таким email не найден\")\n", " \n", + " # 4. Генерация токена\n", " token = secrets.token_urlsafe(32)\n", - " expires_at = datetime.utcnow() + timedelta(hours=1)\n", + " expires_at = datetime.now(timezone.utc) + timedelta(hours=1)\n", " \n", " token_data = {\n", " \"user_id\": str(user[\"_id\"]),\n", " \"email\": user[\"email\"],\n", - " \"next\": next,\n", + " \"next\": restore_data.next,\n", " \"expires_at\": expires_at.isoformat()\n", " }\n", " \n", - " await redis_client.set(\n", - " f\"reset_token:{token}\", \n", - " json.dumps(token_data), \n", - " ex=3600 \n", - " )\n", - " \n", - " reset_link = f\"{str(request.base_url).rstrip('/')}/restore/{token}\"\n", + " # 5. Сохранение токена сброса в Redis\n", + " reset_token_key = f\"reset_token:{token}\"\n", + " await redis_client.set(reset_token_key, json.dumps(token_data), ex=3600)\n", "\n", + " # 6. Генерация ссылки для сброса\n", + " base_url = str(request.base_url).rstrip('/')\n", + " reset_link = f\"{base_url}/restore/{token}\"\n", + "\n", + " # 7. Асинхронная отправка email\n", + " try:\n", + " email_task.notify.delay(\n", + " recipient=user['email'],\n", + " context={\n", + " 'link': reset_link, \n", + " 'email': user['email']\n", + " },\n", + " subject='Восстановление пароля',\n", + " template_name='restore.html'\n", + " )\n", + " except Exception as e:\n", + " # Логирование ошибки\n", + " print(f\"Не удалось отправить письмо: {e}\")\n", + " # В зависимости от политики, вы можете захотеть удалить токен, если письмо не отправлено\n", + " await redis_client.delete(reset_token_key)\n", + " raise HTTPException(status_code=500, detail=\"Ошибка отправки письма\")\n", "\n", - " email_task.notify.delay(\n", - " user['email'], \n", - " {'link': reset_link, 'email': user['email']}, \n", - " 'Восстановление пароля', \n", - " 'restore.html'\n", - " )\n", - " \n", " return {'ok': True}\n", "\n", "\n", @@ -1880,7 +1920,7 @@ " )\n", "\n", "\n", - "app.include_router(router, tags=['Restore passwrod'])" + "app.include_router(router, tags=['Восстановление пароля'])" ], "display_name": ".venv" }, @@ -1894,13 +1934,13 @@ "@app.get(\"/login\", response_class=HTMLResponse)\n", "async def login_(\n", " request: Request,\n", - " next: str | None = None,\n", + " next_url: str | None = None,\n", "):\n", - " if not next:\n", - " next = request.headers.get(\"Referer\")\n", - " context={\"next\": next}\n", - " print(next)\n", - " if 'profile/competition' in next:\n", + " if not next_url:\n", + " next_url = request.headers.get(\"Referer\")\n", + " context={\"next\": next_url}\n", + " print(next_url)\n", + " if next_url and 'profile/competition' in next_url:\n", " context.update({'notify':{'text':'Участвовать в конкурсе могут только пользователи сервиса! Пожалуйста, войдите или зарегистрируйтесь.', 'status':'info','duration':8000}})\n", " return templates.TemplateResponse(\n", " request=request,\n", @@ -2749,7 +2789,7 @@ " users = [doc async for doc in users_collection.find({'roles':'user'}).limit(1000)]\n", " return templates.TemplateResponse(\n", " request=request, name=\"users/index.html\", context={\"id\": 1, \"user\":user, \"users\": users}\n", - " )" + " )\n" ], "display_name": ".venv" }, @@ -3448,7 +3488,7 @@ " \"serviceSocial\": payload.serviceSocial,\n", " \"userSocial\": payload.userSocial,\n", " \"short_desc\": safe_strip(payload.short_desc) if payload.short_desc else None,\n", - " \"poster_file_id\": poster_id,\n", + " \"poster_file_id\": po/api/restorster_id,\n", " \"poster_orig_id\": poster_id,\n", " \"rules_file_id\": rules_id,\n", " \"rules_link\": payload.rules_link,\n", diff --git a/main.py b/main.py index 258c342..2b921ec 100644 --- a/main.py +++ b/main.py @@ -788,6 +788,18 @@ async def login( {"email": identifier.lower()} ] })] + or_conditions = [ + {"email": identifier}, + {"email": identifier.lower()}, + ] + + try: + normalized_phone = normalize_phone(identifier) + if normalized_phone: + or_conditions.append({"phone": normalized_phone}) + except: + pass + users = [doc async for doc in users_collection.find({"$or": or_conditions})] if not users: @@ -1417,13 +1429,13 @@ app.include_router(router, tags=['Restore passwrod']) @app.get("/login", response_class=HTMLResponse) async def login_( request: Request, - next: str | None = None, + next_url: str | None = None, ): - if not next: - next = request.headers.get("Referer") - context={"next": next} - print(next) - if 'profile/competition' in next: + if not next_url: + next_url = request.headers.get("Referer") + context={"next": next_url} + print(next_url) + if next_url and 'profile/competition' in next_url: context.update({'notify':{'text':'Участвовать в конкурсе могут только пользователи сервиса! Пожалуйста, войдите или зарегистрируйтесь.', 'status':'info','duration':8000}}) return templates.TemplateResponse( request=request, @@ -2041,6 +2053,7 @@ async def artists( request=request, name="users/index.html", context={"id": 1, "user":user, "users": users} ) + @app.get("/my-paints", response_class=HTMLResponse) async def My_paints( request: Request, diff --git a/migrate.py b/migrate.py new file mode 100644 index 0000000..33a1ba1 --- /dev/null +++ b/migrate.py @@ -0,0 +1,3 @@ +from db import * + +await posts_collection.update_many({'status':'publish'},{'$set':{'status':'В каталоге'}}) \ No newline at end of file diff --git a/models.py b/models.py index 1a2d4f8..0f71e19 100644 --- a/models.py +++ b/models.py @@ -74,7 +74,7 @@ class LoginRequest(BaseModel): class RestoreRequest(BaseModel): identifier: str - next: str + next: str | None = None class ResetPasswordRequest(BaseModel): diff --git a/static/icons/exit.svg b/static/icons/exit.svg index d79aff8..52b8289 100644 --- a/static/icons/exit.svg +++ b/static/icons/exit.svg @@ -5,7 +5,7 @@ viewBox="0 0 0.37 0.37" xmlns:xlink="http://www.w3.org/1999/xlink"> - + + + - @@ -36,13 +101,14 @@ var userAgent = navigator.userAgent;var isFirefox = userAgent.indexOf("Firefox") - - - + - - - - - -
@@ -794,63 +864,8 @@ shareBtns.forEach(btn => {

- - - diff --git a/templates/competition/card.html b/templates/competition/card.html index ea1a935..404e231 100644 --- a/templates/competition/card.html +++ b/templates/competition/card.html @@ -110,7 +110,7 @@ {%- endmacro %} {% macro style() -%} - - -
- + logo avtopost kultmost
{% if user %} @@ -183,15 +171,14 @@ document.addEventListener('DOMContentLoaded', () => { {% endif %}
-