Compare commits
40 Commits
| Author | SHA256 | Date | |
|---|---|---|---|
| c8ee828804 | |||
| 1d5ec62ab4 | |||
| 36e0f57d89 | |||
| 96c6951e1a | |||
| 66c604cbea | |||
| 1a39c29f68 | |||
| 77adc87b3c | |||
| d140c120ca | |||
| c9762fd959 | |||
| 209eee9f5c | |||
| e971d2d046 | |||
| 1509a93543 | |||
| 5f8a1bebf7 | |||
| b06a678fb2 | |||
| f776d6ce25 | |||
| 1d2ee61f5f | |||
| fe497b5132 | |||
| 91f17e7702 | |||
| 26cc09f8e9 | |||
| 583f41716e | |||
| cad8cb4998 | |||
| 56301e6c0e | |||
| 31a5d4059c | |||
| 7a92bacd61 | |||
| cbdbf53acd | |||
| 77a52bebc0 | |||
| 45d405b2e7 | |||
| 117073c0b1 | |||
| 4a05b50fee | |||
| c1d05b5984 | |||
| da15dc7fb1 | |||
| db0cd8395c | |||
| 71ac5c62a5 | |||
| 978cadf11b | |||
| ce16d4ea1b | |||
| 4cd1f597e8 | |||
| a2deeb3fd9 | |||
| b0fbde1ad4 | |||
| 0fc4d96f25 | |||
| 60229a49b3 |
170
build.ipynb
170
build.ipynb
@ -695,6 +695,23 @@
|
|||||||
"## RSYNC"
|
"## RSYNC"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "ead8aa36",
|
||||||
|
"metadata": {
|
||||||
|
"vscode": {
|
||||||
|
"languageId": "shellscript"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"%%bash\n",
|
||||||
|
"ssh kultmost 'mkdir -p /var/lib/app/.gnupg'\n",
|
||||||
|
"ssh kultmost 'chmod 700 /var/lib/app/.gnupg'"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
"id": "0a429fca",
|
"id": "0a429fca",
|
||||||
@ -817,6 +834,35 @@
|
|||||||
],
|
],
|
||||||
"display_name": ".venv"
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "434ecf4b",
|
||||||
|
"metadata": {
|
||||||
|
"vscode": {
|
||||||
|
"languageId": "shellscript"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"%%bash\n",
|
||||||
|
"ssh kultmost 'tee /app/production/start-uvicorn-back.sh >/dev/null' <<'EOF'\n",
|
||||||
|
"#!/usr/bin/bash\n",
|
||||||
|
"set -euo pipefail\n",
|
||||||
|
"\n",
|
||||||
|
"workers=\"$(nproc)\"\n",
|
||||||
|
"\n",
|
||||||
|
"exec /app/production/.venv/bin/uvicorn main:app \\\n",
|
||||||
|
" --host 127.0.0.1 \\\n",
|
||||||
|
" --port 30080 \\\n",
|
||||||
|
" --workers \"$workers\"\n",
|
||||||
|
"EOF\n",
|
||||||
|
"\n",
|
||||||
|
"ssh kultmost 'chmod +x /app/production/start-uvicorn-back.sh'\n",
|
||||||
|
"ssh kultmost 'ls -ln /app/production/start-uvicorn-back.sh'"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
@ -828,23 +874,24 @@
|
|||||||
},
|
},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
"# %%bash\n",
|
"%%bash\n",
|
||||||
"# ssh kultmost 'tee /etc/systemd/system/fastapi.service >/dev/null' <<'EOF'\n",
|
"ssh kultmost 'tee /etc/systemd/system/fastapi.service >/dev/null' <<'EOF'\n",
|
||||||
"# [Unit]\n",
|
"[Unit]\n",
|
||||||
"# Description=FastAPI prod service\n",
|
"Description=FastAPI prod service\n",
|
||||||
"# After=network.target\n",
|
"After=network.target\n",
|
||||||
"\n",
|
"\n",
|
||||||
"# [Service]\n",
|
"[Service]\n",
|
||||||
"# WorkingDirectory=/app/production\n",
|
"WorkingDirectory=/app/production\n",
|
||||||
"# ExecStart=/app/production/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 30080 --workers 8\n",
|
"ExecStart=/app/production/start-uvicorn-back.sh\n",
|
||||||
"# Restart=always\n",
|
"Restart=always\n",
|
||||||
"# EnvironmentFile=/app/production/env\n",
|
"EnvironmentFile=/app/production/env\n",
|
||||||
|
"CPUWeight=60\n",
|
||||||
"\n",
|
"\n",
|
||||||
"# [Install]\n",
|
"[Install]\n",
|
||||||
"# WantedBy=default.target\n",
|
"WantedBy=multi-user.target\n",
|
||||||
"# EOF\n",
|
"EOF\n",
|
||||||
"\n",
|
"\n",
|
||||||
"# ssh kultmost 'systemctl daemon-reload && systemctl enable --now fastapi.service && systemctl status --no-pager -l fastapi.service'\n"
|
"ssh kultmost 'systemctl daemon-reload && systemctl enable --now fastapi.service && systemctl status --no-pager -l fastapi.service'\n"
|
||||||
],
|
],
|
||||||
"display_name": ".venv"
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
@ -960,9 +1007,75 @@
|
|||||||
"id": "0474516d",
|
"id": "0474516d",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"### restart fastapi"
|
"### restart production "
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "5d35489f",
|
||||||
|
"metadata": {
|
||||||
|
"vscode": {
|
||||||
|
"languageId": "shellscript"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"!ssh kultmost 'systemctl start fastapi.service'"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "c477dcdd",
|
||||||
|
"metadata": {
|
||||||
|
"vscode": {
|
||||||
|
"languageId": "shellscript"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"!ssh kultmost 'systemctl status fastapi.service'"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "1eccaed4",
|
||||||
|
"metadata": {
|
||||||
|
"vscode": {
|
||||||
|
"languageId": "shellscript"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"%%bash\n",
|
||||||
|
"ssh kultmost 'tee /etc/nginx/sites-enabled/avtopost_back >/dev/null' <<'EOF'\n",
|
||||||
|
"upstream backend_avtopost_servers {\n",
|
||||||
|
" server 127.0.0.1:30080;\n",
|
||||||
|
"}\n",
|
||||||
|
"EOF\n",
|
||||||
|
"ssh kultmost 'nginx -t'\n"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "11a407c2",
|
||||||
|
"metadata": {
|
||||||
|
"vscode": {
|
||||||
|
"languageId": "shellscript"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"!ssh kultmost 'nginx -s reload'"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
@ -996,7 +1109,7 @@
|
|||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"id": "6fdc2d37",
|
"id": "1cef4109",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"languageId": "shellscript"
|
"languageId": "shellscript"
|
||||||
@ -1004,7 +1117,28 @@
|
|||||||
},
|
},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
"# !ssh kultmost 'systemctl restart fastapi.service'"
|
"%%bash\n",
|
||||||
|
"ssh kultmost 'tee /etc/nginx/sites-enabled/avtopost_back >/dev/null' <<'EOF'\n",
|
||||||
|
"upstream backend_avtopost_servers {\n",
|
||||||
|
" server 127.0.0.1:30081;\n",
|
||||||
|
"}\n",
|
||||||
|
"EOF\n",
|
||||||
|
"ssh kultmost 'nginx -t'"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "42b4958a",
|
||||||
|
"metadata": {
|
||||||
|
"vscode": {
|
||||||
|
"languageId": "shellscript"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"!ssh kultmost 'nginx -s reload'"
|
||||||
],
|
],
|
||||||
"display_name": ".venv"
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
@ -1019,7 +1153,7 @@
|
|||||||
},
|
},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
"# !ssh kultmost 'systemctl status fastapi.service'"
|
"!ssh kultmost 'systemctl stop fastapi.service'"
|
||||||
],
|
],
|
||||||
"display_name": ".venv"
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -125,7 +125,7 @@ async def accept_delivery(
|
|||||||
|
|
||||||
new_doc = {
|
new_doc = {
|
||||||
**existing,
|
**existing,
|
||||||
"status": "Получено",
|
"status": "Получено галерей" if 'partner' in current_user.get('roles', []) else "Получено художником",
|
||||||
"notify": notify.upserted_id,
|
"notify": notify.upserted_id,
|
||||||
"received_at": datetime.utcnow(),
|
"received_at": datetime.utcnow(),
|
||||||
"updated_at": datetime.utcnow(),
|
"updated_at": datetime.utcnow(),
|
||||||
|
|||||||
274
email_task.ipynb
274
email_task.ipynb
@ -38,7 +38,8 @@
|
|||||||
" load_env('env')\n",
|
" load_env('env')\n",
|
||||||
"except:\n",
|
"except:\n",
|
||||||
" load_env('env_example')"
|
" load_env('env_example')"
|
||||||
]
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
@ -48,17 +49,33 @@
|
|||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
"import smtplib\n",
|
"import smtplib\n",
|
||||||
|
"import os\n",
|
||||||
|
"from email.mime.application import MIMEApplication\n",
|
||||||
"from email.mime.multipart import MIMEMultipart\n",
|
"from email.mime.multipart import MIMEMultipart\n",
|
||||||
"from email.mime.text import MIMEText\n",
|
"from email.mime.text import MIMEText\n",
|
||||||
|
"from email.utils import formatdate, make_msgid\n",
|
||||||
|
"from typing import Optional\n",
|
||||||
|
"import gnupg\n",
|
||||||
"from celery import Celery\n",
|
"from celery import Celery\n",
|
||||||
"from config import *\n",
|
"from config import *\n",
|
||||||
"from db import *\n",
|
"from db import *\n",
|
||||||
"from templates import templates\n",
|
"from templates import templates\n",
|
||||||
"import logging\n",
|
"import logging\n",
|
||||||
"from typing import Optional\n",
|
"import asyncio \n"
|
||||||
"import asyncio \n",
|
],
|
||||||
"from email.utils import formatdate, make_msgid\n"
|
"display_name": ".venv"
|
||||||
]
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "ea1066a5",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"def build_unsubscribe_url(email: str, token: str, base_url: str) -> str:\n",
|
||||||
|
" return f\"{base_url}/api/v1/email/unsubscribe?token={token}&email={email}\"\n"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
@ -67,37 +84,211 @@
|
|||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
"def build_unsubscribe_url(email: str, token: str, base_url: str) -> str:\n",
|
"GPG_HOME = os.getenv(\"GPG_HOME\", \"/var/lib/app/.gnupg\")\n",
|
||||||
" return f\"{base_url}/api/v1/email/unsubscribe?token={token}&email={email}\"\n",
|
"GPG_BINARY = os.getenv(\"GPG_BINARY\", \"gpg\")\n",
|
||||||
|
"\n",
|
||||||
|
"gpg = gnupg.GPG(\n",
|
||||||
|
" gnupghome=GPG_HOME,\n",
|
||||||
|
" gpgbinary=GPG_BINARY,\n",
|
||||||
|
")\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"def build_unsubscribe_url(\n",
|
||||||
|
" to_email: str,\n",
|
||||||
|
" user_id: Optional[int],\n",
|
||||||
|
" domain: str,\n",
|
||||||
|
") -> str:\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
" Replace this implementation with your real unsubscribe URL builder.\n",
|
||||||
|
" Avoid putting a raw email address in the URL in production.\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
" from urllib.parse import quote\n",
|
||||||
|
"\n",
|
||||||
|
" return f\"https://{domain}/unsubscribe?email={quote(to_email)}\"\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"def import_public_key(public_key: str) -> str:\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
" Import an ASCII-armored public key and return its fingerprint.\n",
|
||||||
|
"\n",
|
||||||
|
" The database value should contain the complete public key, for example:\n",
|
||||||
|
"\n",
|
||||||
|
" -----BEGIN PGP PUBLIC KEY BLOCK-----\n",
|
||||||
|
" ...\n",
|
||||||
|
" -----END PGP PUBLIC KEY BLOCK-----\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
" result = gpg.import_keys(public_key)\n",
|
||||||
|
"\n",
|
||||||
|
" if not result.fingerprints:\n",
|
||||||
|
" raise RuntimeError(\n",
|
||||||
|
" f\"Unable to import the recipient's public key: {result}\"\n",
|
||||||
|
" )\n",
|
||||||
|
"\n",
|
||||||
|
" # Use the imported fingerprint rather than the email address. The email\n",
|
||||||
|
" # address in the key's UID may not exactly match the recipient address.\n",
|
||||||
|
" return result.fingerprints[0]\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"def encrypt_mime_message(\n",
|
||||||
|
" message,\n",
|
||||||
|
" recipient_fingerprint: str,\n",
|
||||||
|
"):\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
" Encrypt an already-built MIME message and return a PGP/MIME message.\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
"\n",
|
||||||
|
" # Serialize the original MIME message before encryption.\n",
|
||||||
|
" plaintext = message.as_bytes()\n",
|
||||||
|
"\n",
|
||||||
|
" # Binary OpenPGP data is appropriate for application/octet-stream.\n",
|
||||||
|
" encrypted = gpg.encrypt(\n",
|
||||||
|
" plaintext,\n",
|
||||||
|
" recipients=[recipient_fingerprint],\n",
|
||||||
|
" armor=False,\n",
|
||||||
|
" always_trust=False,\n",
|
||||||
|
" )\n",
|
||||||
|
"\n",
|
||||||
|
" if not encrypted.ok:\n",
|
||||||
|
" raise RuntimeError(\n",
|
||||||
|
" f\"GPG encryption failed: {encrypted.status}; \"\n",
|
||||||
|
" f\"stderr: {encrypted.stderr}\"\n",
|
||||||
|
" )\n",
|
||||||
|
"\n",
|
||||||
|
" encrypted_bytes = encrypted.data\n",
|
||||||
|
"\n",
|
||||||
|
" if not encrypted_bytes:\n",
|
||||||
|
" raise RuntimeError(\"GPG returned an empty encrypted message\")\n",
|
||||||
|
"\n",
|
||||||
|
" encrypted_message = MIMEMultipart(\n",
|
||||||
|
" \"encrypted\",\n",
|
||||||
|
" protocol=\"application/pgp-encrypted\",\n",
|
||||||
|
" )\n",
|
||||||
|
"\n",
|
||||||
|
" # Copy normal mail headers. Content-Type and MIME-Version are generated\n",
|
||||||
|
" # automatically for the new multipart/encrypted message.\n",
|
||||||
|
" for header in (\n",
|
||||||
|
" \"From\",\n",
|
||||||
|
" \"To\",\n",
|
||||||
|
" \"Subject\",\n",
|
||||||
|
" \"Message-ID\",\n",
|
||||||
|
" \"Date\",\n",
|
||||||
|
" \"X-Priority\",\n",
|
||||||
|
" \"Importance\",\n",
|
||||||
|
" \"X-Auto-Response-Suppress\",\n",
|
||||||
|
" \"List-Unsubscribe\",\n",
|
||||||
|
" \"List-Unsubscribe-Post\",\n",
|
||||||
|
" ):\n",
|
||||||
|
" value = message.get(header)\n",
|
||||||
|
" if value is not None:\n",
|
||||||
|
" encrypted_message[header] = value\n",
|
||||||
|
"\n",
|
||||||
|
" # Required PGP/MIME version part.\n",
|
||||||
|
" version_part = MIMEApplication(\n",
|
||||||
|
" b\"Version: 1\\r\\n\",\n",
|
||||||
|
" _subtype=\"pgp-encrypted\",\n",
|
||||||
|
" )\n",
|
||||||
|
" version_part.add_header(\n",
|
||||||
|
" \"Content-Description\",\n",
|
||||||
|
" \"PGP/MIME version identification\",\n",
|
||||||
|
" )\n",
|
||||||
|
" encrypted_message.attach(version_part)\n",
|
||||||
|
"\n",
|
||||||
|
" # Encrypted MIME payload.\n",
|
||||||
|
" encrypted_part = MIMEApplication(\n",
|
||||||
|
" encrypted_bytes,\n",
|
||||||
|
" _subtype=\"octet-stream\",\n",
|
||||||
|
" )\n",
|
||||||
|
" encrypted_part.add_header(\n",
|
||||||
|
" \"Content-Description\",\n",
|
||||||
|
" \"OpenPGP encrypted message\",\n",
|
||||||
|
" )\n",
|
||||||
|
" encrypted_part.add_header(\n",
|
||||||
|
" \"Content-Disposition\",\n",
|
||||||
|
" \"inline\",\n",
|
||||||
|
" filename=\"encrypted.asc\",\n",
|
||||||
|
" )\n",
|
||||||
|
" encrypted_message.attach(encrypted_part)\n",
|
||||||
|
"\n",
|
||||||
|
" return encrypted_message\n",
|
||||||
"\n",
|
"\n",
|
||||||
"\n",
|
"\n",
|
||||||
"async def create_email_message(\n",
|
"async def create_email_message(\n",
|
||||||
" to_email: str,\n",
|
" to_email: str,\n",
|
||||||
" subject: str,\n",
|
" subject: str,\n",
|
||||||
" html_content: str,\n",
|
" html_content: str,\n",
|
||||||
" user_id: int = None,\n",
|
" user_id: Optional[int] = None,\n",
|
||||||
"):\n",
|
"):\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
" Create an HTML email.\n",
|
||||||
|
"\n",
|
||||||
|
" If the recipient has a public GPG key in the database, the complete\n",
|
||||||
|
" MIME message is encrypted using PGP/MIME.\n",
|
||||||
|
"\n",
|
||||||
|
" Expected database structure:\n",
|
||||||
|
"\n",
|
||||||
|
" {\n",
|
||||||
|
" \"email\": \"recipient@example.com\",\n",
|
||||||
|
" \"gnupg\": \"-----BEGIN PGP PUBLIC KEY BLOCK-----\\\\n...\"\n",
|
||||||
|
" }\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
"\n",
|
||||||
" msg = MIMEMultipart(\"alternative\")\n",
|
" msg = MIMEMultipart(\"alternative\")\n",
|
||||||
" \n",
|
"\n",
|
||||||
" msg['From'] = f\"{FROM_NAME} <{FROM_EMAIL}>\"\n",
|
" msg[\"From\"] = f\"{FROM_NAME} <{FROM_EMAIL}>\"\n",
|
||||||
" msg['To'] = to_email\n",
|
" msg[\"To\"] = to_email\n",
|
||||||
" msg['Subject'] = subject\n",
|
" msg[\"Subject\"] = subject\n",
|
||||||
" msg['Message-ID'] = make_msgid(domain=FROM_EMAIL.split('@')[1])\n",
|
" msg[\"Message-ID\"] = make_msgid(\n",
|
||||||
" msg['Date'] = formatdate(localtime=True)\n",
|
" domain=FROM_EMAIL.rsplit(\"@\", 1)[1],\n",
|
||||||
" msg['X-Priority'] = \"3\"\n",
|
" )\n",
|
||||||
" msg['Importance'] = \"normal\"\n",
|
" msg[\"Date\"] = formatdate(localtime=True)\n",
|
||||||
" msg['X-Auto-Response-Suppress'] = \"All\"\n",
|
" msg[\"X-Priority\"] = \"3\"\n",
|
||||||
" \n",
|
" msg[\"Importance\"] = \"normal\"\n",
|
||||||
" # token = await create_unsubscribe_token(to_email, user_id)\n",
|
" msg[\"X-Auto-Response-Suppress\"] = \"All\"\n",
|
||||||
" unsubscribe_url = build_unsubscribe_url(to_email, None, FULL_DOMAIN)\n",
|
"\n",
|
||||||
" if to_email != FROM_EMAIL:\n",
|
" # Do not send unsubscribe headers to yourself.\n",
|
||||||
" msg['List-Unsubscribe'] = f\"<{unsubscribe_url}>\"\n",
|
" if to_email.lower() != FROM_EMAIL.lower():\n",
|
||||||
" msg['List-Unsubscribe-Post'] = \"List-Unsubscribe=One-Click\"\n",
|
" unsubscribe_url = build_unsubscribe_url(\n",
|
||||||
" \n",
|
" to_email=to_email,\n",
|
||||||
" msg.attach(MIMEText(html_content, 'html'))\n",
|
" user_id=user_id,\n",
|
||||||
" \n",
|
" domain=FULL_DOMAIN,\n",
|
||||||
" return msg\n"
|
" )\n",
|
||||||
]
|
"\n",
|
||||||
|
" msg[\"List-Unsubscribe\"] = f\"<{unsubscribe_url}>\"\n",
|
||||||
|
" msg[\"List-Unsubscribe-Post\"] = \"List-Unsubscribe=One-Click\"\n",
|
||||||
|
"\n",
|
||||||
|
" msg.attach(\n",
|
||||||
|
" MIMEText(\n",
|
||||||
|
" html_content,\n",
|
||||||
|
" \"html\",\n",
|
||||||
|
" \"utf-8\",\n",
|
||||||
|
" )\n",
|
||||||
|
" )\n",
|
||||||
|
"\n",
|
||||||
|
" # Fetch the recipient's public key.\n",
|
||||||
|
" user = await users_collection.find_one(\n",
|
||||||
|
" {\"email\": to_email},\n",
|
||||||
|
" {\"gnupg\": 1},\n",
|
||||||
|
" )\n",
|
||||||
|
"\n",
|
||||||
|
" public_key = user.get(\"gnupg\") if user else None\n",
|
||||||
|
"\n",
|
||||||
|
" if not public_key:\n",
|
||||||
|
" # Return a normal unencrypted email when no key is available.\n",
|
||||||
|
" return msg\n",
|
||||||
|
"\n",
|
||||||
|
" if not isinstance(public_key, str):\n",
|
||||||
|
" raise TypeError(\"The gnupg database field must be a string\")\n",
|
||||||
|
"\n",
|
||||||
|
" try:\n",
|
||||||
|
" fingerprint = import_public_key(public_key)\n",
|
||||||
|
" return encrypt_mime_message(msg, fingerprint)\n",
|
||||||
|
"\n",
|
||||||
|
" except Exception:\n",
|
||||||
|
" # Encryption is mandatory whenever a key exists.\n",
|
||||||
|
" # Do not silently send the message unencrypted.\n",
|
||||||
|
" raise\n"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
@ -140,7 +331,8 @@
|
|||||||
" f\"Subject: {subject}, Error: {str(e)}\"\n",
|
" f\"Subject: {subject}, Error: {str(e)}\"\n",
|
||||||
" )\n",
|
" )\n",
|
||||||
" return False\n"
|
" return False\n"
|
||||||
]
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
@ -150,7 +342,8 @@
|
|||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
"celery_app_email = Celery('emails', broker=f'{redis_uri}2', backend=f'{redis_uri}2', task_track_started=True)\n"
|
"celery_app_email = Celery('emails', broker=f'{redis_uri}2', backend=f'{redis_uri}2', task_track_started=True)\n"
|
||||||
]
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
@ -189,7 +382,8 @@
|
|||||||
" f\"Ошибка при отправке email на {to_email} (попытка {self.request.retries + 1}): {exc}\"\n",
|
" f\"Ошибка при отправке email на {to_email} (попытка {self.request.retries + 1}): {exc}\"\n",
|
||||||
" )\n",
|
" )\n",
|
||||||
" raise self.retry(exc=exc)\n"
|
" raise self.retry(exc=exc)\n"
|
||||||
]
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
@ -199,6 +393,18 @@
|
|||||||
"## Run"
|
"## Run"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "3aeae7e8",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"!mkdir -p /var/lib/app/.gnupg\n",
|
||||||
|
"!chmod 700 /var/lib/app/.gnupg"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
@ -228,7 +434,8 @@
|
|||||||
"\n",
|
"\n",
|
||||||
" with open(f'{filename_without_extension}.py', 'w') as f:\n",
|
" with open(f'{filename_without_extension}.py', 'w') as f:\n",
|
||||||
" f.write('\\n\\n'.join(app))\n"
|
" f.write('\\n\\n'.join(app))\n"
|
||||||
]
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
@ -238,7 +445,8 @@
|
|||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
"!celery -A email_task.celery_app_email worker --loglevel=info"
|
"!celery -A email_task.celery_app_email worker --loglevel=info"
|
||||||
]
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
|||||||
224
email_task.py
224
email_task.py
@ -32,48 +32,228 @@ except:
|
|||||||
load_env('env_example')
|
load_env('env_example')
|
||||||
|
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import os
|
||||||
|
from email.mime.application import MIMEApplication
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
from email.utils import formatdate, make_msgid
|
||||||
|
from typing import Optional
|
||||||
|
import gnupg
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
from config import *
|
from config import *
|
||||||
from db import *
|
from db import *
|
||||||
from templates import templates
|
from templates import templates
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from email.utils import formatdate, make_msgid
|
|
||||||
|
|
||||||
|
|
||||||
def build_unsubscribe_url(email: str, token: str, base_url: str) -> str:
|
def build_unsubscribe_url(email: str, token: str, base_url: str) -> str:
|
||||||
return f"{base_url}/api/v1/email/unsubscribe?token={token}&email={email}"
|
return f"{base_url}/api/v1/email/unsubscribe?token={token}&email={email}"
|
||||||
|
|
||||||
|
|
||||||
|
GPG_HOME = os.getenv("GPG_HOME", "/var/lib/app/.gnupg")
|
||||||
|
GPG_BINARY = os.getenv("GPG_BINARY", "gpg")
|
||||||
|
|
||||||
|
gpg = gnupg.GPG(
|
||||||
|
gnupghome=GPG_HOME,
|
||||||
|
gpgbinary=GPG_BINARY,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_unsubscribe_url(
|
||||||
|
to_email: str,
|
||||||
|
user_id: Optional[int],
|
||||||
|
domain: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Replace this implementation with your real unsubscribe URL builder.
|
||||||
|
Avoid putting a raw email address in the URL in production.
|
||||||
|
"""
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
return f"https://{domain}/unsubscribe?email={quote(to_email)}"
|
||||||
|
|
||||||
|
|
||||||
|
def import_public_key(public_key: str) -> str:
|
||||||
|
"""
|
||||||
|
Import an ASCII-armored public key and return its fingerprint.
|
||||||
|
|
||||||
|
The database value should contain the complete public key, for example:
|
||||||
|
|
||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
...
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
|
"""
|
||||||
|
result = gpg.import_keys(public_key)
|
||||||
|
|
||||||
|
if not result.fingerprints:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unable to import the recipient's public key: {result}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use the imported fingerprint rather than the email address. The email
|
||||||
|
# address in the key's UID may not exactly match the recipient address.
|
||||||
|
return result.fingerprints[0]
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_mime_message(
|
||||||
|
message,
|
||||||
|
recipient_fingerprint: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Encrypt an already-built MIME message and return a PGP/MIME message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Serialize the original MIME message before encryption.
|
||||||
|
plaintext = message.as_bytes()
|
||||||
|
|
||||||
|
# Binary OpenPGP data is appropriate for application/octet-stream.
|
||||||
|
encrypted = gpg.encrypt(
|
||||||
|
plaintext,
|
||||||
|
recipients=[recipient_fingerprint],
|
||||||
|
armor=False,
|
||||||
|
always_trust=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not encrypted.ok:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"GPG encryption failed: {encrypted.status}; "
|
||||||
|
f"stderr: {encrypted.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
encrypted_bytes = encrypted.data
|
||||||
|
|
||||||
|
if not encrypted_bytes:
|
||||||
|
raise RuntimeError("GPG returned an empty encrypted message")
|
||||||
|
|
||||||
|
encrypted_message = MIMEMultipart(
|
||||||
|
"encrypted",
|
||||||
|
protocol="application/pgp-encrypted",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy normal mail headers. Content-Type and MIME-Version are generated
|
||||||
|
# automatically for the new multipart/encrypted message.
|
||||||
|
for header in (
|
||||||
|
"From",
|
||||||
|
"To",
|
||||||
|
"Subject",
|
||||||
|
"Message-ID",
|
||||||
|
"Date",
|
||||||
|
"X-Priority",
|
||||||
|
"Importance",
|
||||||
|
"X-Auto-Response-Suppress",
|
||||||
|
"List-Unsubscribe",
|
||||||
|
"List-Unsubscribe-Post",
|
||||||
|
):
|
||||||
|
value = message.get(header)
|
||||||
|
if value is not None:
|
||||||
|
encrypted_message[header] = value
|
||||||
|
|
||||||
|
# Required PGP/MIME version part.
|
||||||
|
version_part = MIMEApplication(
|
||||||
|
b"Version: 1\r\n",
|
||||||
|
_subtype="pgp-encrypted",
|
||||||
|
)
|
||||||
|
version_part.add_header(
|
||||||
|
"Content-Description",
|
||||||
|
"PGP/MIME version identification",
|
||||||
|
)
|
||||||
|
encrypted_message.attach(version_part)
|
||||||
|
|
||||||
|
# Encrypted MIME payload.
|
||||||
|
encrypted_part = MIMEApplication(
|
||||||
|
encrypted_bytes,
|
||||||
|
_subtype="octet-stream",
|
||||||
|
)
|
||||||
|
encrypted_part.add_header(
|
||||||
|
"Content-Description",
|
||||||
|
"OpenPGP encrypted message",
|
||||||
|
)
|
||||||
|
encrypted_part.add_header(
|
||||||
|
"Content-Disposition",
|
||||||
|
"inline",
|
||||||
|
filename="encrypted.asc",
|
||||||
|
)
|
||||||
|
encrypted_message.attach(encrypted_part)
|
||||||
|
|
||||||
|
return encrypted_message
|
||||||
|
|
||||||
|
|
||||||
async def create_email_message(
|
async def create_email_message(
|
||||||
to_email: str,
|
to_email: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
html_content: str,
|
html_content: str,
|
||||||
user_id: int = None,
|
user_id: Optional[int] = None,
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Create an HTML email.
|
||||||
|
|
||||||
|
If the recipient has a public GPG key in the database, the complete
|
||||||
|
MIME message is encrypted using PGP/MIME.
|
||||||
|
|
||||||
|
Expected database structure:
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "recipient@example.com",
|
||||||
|
"gnupg": "-----BEGIN PGP PUBLIC KEY BLOCK-----\\n..."
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
msg = MIMEMultipart("alternative")
|
msg = MIMEMultipart("alternative")
|
||||||
|
|
||||||
msg['From'] = f"{FROM_NAME} <{FROM_EMAIL}>"
|
msg["From"] = f"{FROM_NAME} <{FROM_EMAIL}>"
|
||||||
msg['To'] = to_email
|
msg["To"] = to_email
|
||||||
msg['Subject'] = subject
|
msg["Subject"] = subject
|
||||||
msg['Message-ID'] = make_msgid(domain=FROM_EMAIL.split('@')[1])
|
msg["Message-ID"] = make_msgid(
|
||||||
msg['Date'] = formatdate(localtime=True)
|
domain=FROM_EMAIL.rsplit("@", 1)[1],
|
||||||
msg['X-Priority'] = "3"
|
)
|
||||||
msg['Importance'] = "normal"
|
msg["Date"] = formatdate(localtime=True)
|
||||||
msg['X-Auto-Response-Suppress'] = "All"
|
msg["X-Priority"] = "3"
|
||||||
|
msg["Importance"] = "normal"
|
||||||
# token = await create_unsubscribe_token(to_email, user_id)
|
msg["X-Auto-Response-Suppress"] = "All"
|
||||||
unsubscribe_url = build_unsubscribe_url(to_email, None, FULL_DOMAIN)
|
|
||||||
if to_email != FROM_EMAIL:
|
# Do not send unsubscribe headers to yourself.
|
||||||
msg['List-Unsubscribe'] = f"<{unsubscribe_url}>"
|
if to_email.lower() != FROM_EMAIL.lower():
|
||||||
msg['List-Unsubscribe-Post'] = "List-Unsubscribe=One-Click"
|
unsubscribe_url = build_unsubscribe_url(
|
||||||
|
to_email=to_email,
|
||||||
msg.attach(MIMEText(html_content, 'html'))
|
user_id=user_id,
|
||||||
|
domain=FULL_DOMAIN,
|
||||||
return msg
|
)
|
||||||
|
|
||||||
|
msg["List-Unsubscribe"] = f"<{unsubscribe_url}>"
|
||||||
|
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
|
||||||
|
|
||||||
|
msg.attach(
|
||||||
|
MIMEText(
|
||||||
|
html_content,
|
||||||
|
"html",
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch the recipient's public key.
|
||||||
|
user = await users_collection.find_one(
|
||||||
|
{"email": to_email},
|
||||||
|
{"gnupg": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
public_key = user.get("gnupg") if user else None
|
||||||
|
|
||||||
|
if not public_key:
|
||||||
|
# Return a normal unencrypted email when no key is available.
|
||||||
|
return msg
|
||||||
|
|
||||||
|
if not isinstance(public_key, str):
|
||||||
|
raise TypeError("The gnupg database field must be a string")
|
||||||
|
|
||||||
|
try:
|
||||||
|
fingerprint = import_public_key(public_key)
|
||||||
|
return encrypt_mime_message(msg, fingerprint)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Encryption is mandatory whenever a key exists.
|
||||||
|
# Do not silently send the message unencrypted.
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def connect(email_server, email_port):
|
def connect(email_server, email_port):
|
||||||
|
|||||||
264
main.ipynb
264
main.ipynb
@ -180,23 +180,42 @@
|
|||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
"def get_delivery_aggregate(_filter: dict = {}, _status: str = None):\n",
|
"from typing import Any, AsyncIterator\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"def get_delivery_aggregate(\n",
|
||||||
|
" _filter: dict[str, Any] | None = None,\n",
|
||||||
|
" _status: str | list[str] | None = None,\n",
|
||||||
|
"):\n",
|
||||||
" cursor = delivery_collection.aggregate([\n",
|
" cursor = delivery_collection.aggregate([\n",
|
||||||
" {\"$match\": {\n",
|
" {\n",
|
||||||
" \"prod\": PROD, **_filter\n",
|
" \"$match\": {\n",
|
||||||
" }},\n",
|
" \"prod\": PROD,\n",
|
||||||
|
" **(_filter or {}),\n",
|
||||||
|
" }\n",
|
||||||
|
" },\n",
|
||||||
" {\"$sort\": {\"_id\": -1}},\n",
|
" {\"$sort\": {\"_id\": -1}},\n",
|
||||||
" {\"$group\": {\n",
|
" {\n",
|
||||||
" \"_id\": \"$post_id\",\n",
|
" \"$group\": {\n",
|
||||||
" \"doc\": {\"$first\": \"$$ROOT\"}\n",
|
" \"_id\": \"$post_id\",\n",
|
||||||
" }},\n",
|
" \"doc\": {\"$first\": \"$$ROOT\"},\n",
|
||||||
|
" }\n",
|
||||||
|
" },\n",
|
||||||
" {\"$replaceRoot\": {\"newRoot\": \"$doc\"}},\n",
|
" {\"$replaceRoot\": {\"newRoot\": \"$doc\"}},\n",
|
||||||
" {\"$sort\": {\"_id\": -1}},\n",
|
" {\"$sort\": {\"_id\": -1}},\n",
|
||||||
" ])\n",
|
" ])\n",
|
||||||
" if _status:\n",
|
"\n",
|
||||||
" return (doc async for doc in cursor if doc.get('status') == _status)\n",
|
" if _status is None:\n",
|
||||||
" else:\n",
|
" return cursor\n",
|
||||||
" return cursor\n"
|
"\n",
|
||||||
|
" statuses = {_status} if isinstance(_status, str) else set(_status)\n",
|
||||||
|
"\n",
|
||||||
|
" async def filtered_cursor() -> AsyncIterator[dict[str, Any]]:\n",
|
||||||
|
" async for doc in cursor:\n",
|
||||||
|
" if doc.get(\"status\") in statuses:\n",
|
||||||
|
" yield doc\n",
|
||||||
|
"\n",
|
||||||
|
" return filtered_cursor()\n"
|
||||||
],
|
],
|
||||||
"display_name": ".venv"
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
@ -1086,6 +1105,13 @@
|
|||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"source": [
|
||||||
|
"import hashlib\n",
|
||||||
|
"\n",
|
||||||
|
"def sha256(text: str) -> str:\n",
|
||||||
|
" \"\"\"Server-side SHA-256 (hex)\"\"\"\n",
|
||||||
|
" return hashlib.sha256(text.encode(\"utf-8\")).hexdigest()\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
"@app.post(\"/api/login\")\n",
|
"@app.post(\"/api/login\")\n",
|
||||||
"async def login(\n",
|
"async def login(\n",
|
||||||
" request: Request,\n",
|
" request: Request,\n",
|
||||||
@ -1096,57 +1122,86 @@
|
|||||||
" await check_bruteforce(identifier)\n",
|
" await check_bruteforce(identifier)\n",
|
||||||
"\n",
|
"\n",
|
||||||
" or_conditions = [\n",
|
" or_conditions = [\n",
|
||||||
" {\"email\": identifier},\n",
|
" {\"email\": identifier},\n",
|
||||||
" {\"email\": identifier.lower()},\n",
|
" {\"email\": identifier.lower()},\n",
|
||||||
" ]\n",
|
" ]\n",
|
||||||
" \n",
|
"\n",
|
||||||
" try:\n",
|
" try:\n",
|
||||||
" normalized_phone = normalize_phone(identifier)\n",
|
" normalized_phone = normalize_phone(identifier)\n",
|
||||||
" if normalized_phone:\n",
|
" if normalized_phone:\n",
|
||||||
" or_conditions.append({\"phone\": normalized_phone})\n",
|
" or_conditions.append({\"phone\": normalized_phone})\n",
|
||||||
" except:\n",
|
" except Exception:\n",
|
||||||
" pass\n",
|
" pass\n",
|
||||||
" users = [doc async for doc in users_collection.find({\"$or\": or_conditions})]\n",
|
|
||||||
"\n",
|
"\n",
|
||||||
|
" users = [doc async for doc in users_collection.find({\"$or\": or_conditions})]\n",
|
||||||
"\n",
|
"\n",
|
||||||
" if not users:\n",
|
" if not users:\n",
|
||||||
" await increment_failed_attempt(identifier)\n",
|
" await increment_failed_attempt(identifier)\n",
|
||||||
" raise HTTPException(401, \"Incorrect credentials\")\n",
|
" raise HTTPException(status_code=401, detail=\"Incorrect credentials\")\n",
|
||||||
"\n",
|
"\n",
|
||||||
" # Track if a valid password was found\n",
|
" matched_user = None\n",
|
||||||
" valid_user_found = False\n",
|
" new_hash = None\n",
|
||||||
|
" is_hash_login = login_data.client_id == \"web_hash\"\n",
|
||||||
"\n",
|
"\n",
|
||||||
" for user in users:\n",
|
" for user in users:\n",
|
||||||
" stored = user.get(\"hashed_password\")\n",
|
" stored = user.get(\"hashed_password\")\n",
|
||||||
"\n",
|
|
||||||
" if isinstance(stored, bytes):\n",
|
" if isinstance(stored, bytes):\n",
|
||||||
" stored = stored.decode()\n",
|
" stored = stored.decode()\n",
|
||||||
"\n",
|
"\n",
|
||||||
" # Check if the password is hashed with Argon2\n",
|
" if not stored:\n",
|
||||||
" if stored.startswith(\"$argon2\"):\n",
|
" continue\n",
|
||||||
" ok = verify_password(login_data.password, stored)\n",
|
|
||||||
" if ok:\n",
|
|
||||||
" valid_user_found = True\n",
|
|
||||||
" break # Password is valid, exit the loop\n",
|
|
||||||
" \n",
|
|
||||||
" # Check if the password is hashed with bcrypt\n",
|
|
||||||
" elif stored.startswith(\"$2y$\"):\n",
|
|
||||||
" stored = stored.replace(\"$2y$\", \"$2b$\", 1)\n",
|
|
||||||
" if bcrypt.checkpw(login_data.password.encode(), stored.encode()):\n",
|
|
||||||
" valid_user_found = True\n",
|
|
||||||
" new_hash = get_password_hash(login_data.password)\n",
|
|
||||||
" break # Password is valid, exit the loop\n",
|
|
||||||
"\n",
|
"\n",
|
||||||
" # If no valid user was found, increment attempts and raise error\n",
|
" # ---------- 1. Client sent SHA-256 hash ----------\n",
|
||||||
" if not valid_user_found:\n",
|
" if is_hash_login:\n",
|
||||||
|
" # Stored value is expected to be Argon2(SHA256(password))\n",
|
||||||
|
" if stored.startswith(\"$argon2\"):\n",
|
||||||
|
" if verify_password(login_data.password, stored):\n",
|
||||||
|
" matched_user = user\n",
|
||||||
|
" break\n",
|
||||||
|
"\n",
|
||||||
|
" # ---------- 2. Client sent plaintext ----------\n",
|
||||||
|
" else:\n",
|
||||||
|
" # Modern: Argon2(SHA256(plain))\n",
|
||||||
|
" if stored.startswith(\"$argon2\"):\n",
|
||||||
|
" # Try verifying as if stored is Argon2(SHA256(plain))\n",
|
||||||
|
" hashed_input = sha256(login_data.password)\n",
|
||||||
|
" if verify_password(hashed_input, stored):\n",
|
||||||
|
" matched_user = user\n",
|
||||||
|
" break\n",
|
||||||
|
"\n",
|
||||||
|
" # Also try classic Argon2(plain) for very old accounts\n",
|
||||||
|
" if verify_password(login_data.password, stored):\n",
|
||||||
|
" matched_user = user\n",
|
||||||
|
" # Force migration to Argon2(SHA256)\n",
|
||||||
|
" new_hash = get_password_hash(sha256(login_data.password))\n",
|
||||||
|
" break\n",
|
||||||
|
"\n",
|
||||||
|
" # Legacy bcrypt(plain)\n",
|
||||||
|
" elif stored.startswith((\"$2y$\", \"$2b$\", \"$2a$\")):\n",
|
||||||
|
" stored_bcrypt = stored.replace(\"$2y$\", \"$2b$\", 1)\n",
|
||||||
|
" try:\n",
|
||||||
|
" if bcrypt.checkpw(\n",
|
||||||
|
" login_data.password.encode(\"utf-8\"),\n",
|
||||||
|
" stored_bcrypt.encode(\"utf-8\")\n",
|
||||||
|
" ):\n",
|
||||||
|
" matched_user = user\n",
|
||||||
|
" # Migrate to Argon2(SHA256(plain))\n",
|
||||||
|
" new_hash = get_password_hash(sha256(login_data.password))\n",
|
||||||
|
" break\n",
|
||||||
|
" except Exception:\n",
|
||||||
|
" continue\n",
|
||||||
|
"\n",
|
||||||
|
" if not matched_user:\n",
|
||||||
" await increment_failed_attempt(identifier)\n",
|
" await increment_failed_attempt(identifier)\n",
|
||||||
" raise HTTPException(401, \"Incorrect credentials\")\n",
|
" raise HTTPException(status_code=401, detail=\"Incorrect credentials\")\n",
|
||||||
"\n",
|
"\n",
|
||||||
" # If we reach this point, authentication succeeded\n",
|
" # Apply migration if needed\n",
|
||||||
" # Proceed with further logic such as generating tokens or returning user details\n",
|
" if new_hash:\n",
|
||||||
|
" await users_collection.update_one(\n",
|
||||||
|
" {\"_id\": matched_user[\"_id\"]},\n",
|
||||||
|
" {\"$set\": {\"hashed_password\": new_hash}}\n",
|
||||||
|
" )\n",
|
||||||
"\n",
|
"\n",
|
||||||
" # await users_collection.update_one({\"_id\": user[\"_id\"]}, {\"$set\": {\"hashed_password\": new_hash}})\n",
|
|
||||||
" \n",
|
|
||||||
" await reset_failed_attempts(identifier)\n",
|
" await reset_failed_attempts(identifier)\n",
|
||||||
"\n",
|
"\n",
|
||||||
" user_id_str = str(user[\"_id\"])\n",
|
" user_id_str = str(user[\"_id\"])\n",
|
||||||
@ -1653,8 +1708,10 @@
|
|||||||
"):\n",
|
"):\n",
|
||||||
" exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})\n",
|
" exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})\n",
|
||||||
" if exhibition:\n",
|
" if exhibition:\n",
|
||||||
" posts = [doc async for doc in get_delivery_aggregate({\"exhibition_id\": exhibition_id}, 'На выставке')]\n",
|
" posts = [doc async for doc in get_delivery_aggregate({\"exhibition_id\": exhibition_id}, ['Получено', 'Получено галерей', 'Продано', 'На выставке'])]\n",
|
||||||
" \n",
|
" for i in posts:\n",
|
||||||
|
" if (post_id:=i.get('post_id')) and 'post' not in i:\n",
|
||||||
|
" i['post'] = await posts_collection.find_one({'_id': ObjectId(post_id)})\n",
|
||||||
" return templates.TemplateResponse(\n",
|
" return templates.TemplateResponse(\n",
|
||||||
" request=request, name=\"exhibition/index.html\", context={\"id\": 1, \"user\": user, \"exhibition\": exhibition, \"posts\": posts, 'now': datetime.now()}\n",
|
" request=request, name=\"exhibition/index.html\", context={\"id\": 1, \"user\": user, \"exhibition\": exhibition, \"posts\": posts, 'now': datetime.now()}\n",
|
||||||
" )\n",
|
" )\n",
|
||||||
@ -1915,6 +1972,43 @@
|
|||||||
" )\n",
|
" )\n",
|
||||||
"\n",
|
"\n",
|
||||||
"\n",
|
"\n",
|
||||||
|
"@app.post(\"/api/reset-hash-password\")\n",
|
||||||
|
"async def reset_hash_password_user(\n",
|
||||||
|
" request: Request,\n",
|
||||||
|
" response: Response,\n",
|
||||||
|
" data: ResetPasswordRequest = Body(...),\n",
|
||||||
|
" current_user: Dict = Security(get_current_user)\n",
|
||||||
|
"):\n",
|
||||||
|
" user_id_str = current_user[\"id\"]\n",
|
||||||
|
"\n",
|
||||||
|
" session_key = f\"user_sessions:{user_id_str}\"\n",
|
||||||
|
" tokens = await redis_client.smembers(session_key)\n",
|
||||||
|
" for t in tokens:\n",
|
||||||
|
" await redis_client.delete(t)\n",
|
||||||
|
" await redis_client.delete(session_key)\n",
|
||||||
|
"\n",
|
||||||
|
" for cookie_name in request.cookies.keys():\n",
|
||||||
|
" if cookie_name in ['zoom']:\n",
|
||||||
|
" continue\n",
|
||||||
|
"\n",
|
||||||
|
" for dom in [None,'.kultmost.ru','www.kultmost.ru','avtopost.kultmost.ru','avtopost.kultmost.ru','kultmost.ru']:\n",
|
||||||
|
" response.delete_cookie(\n",
|
||||||
|
" key=cookie_name,\n",
|
||||||
|
" domain=dom,\n",
|
||||||
|
" path=\"/\", \n",
|
||||||
|
" secure=False, \n",
|
||||||
|
" httponly=False,\n",
|
||||||
|
" )\n",
|
||||||
|
" hashed_password = get_password_hash(data.new_password)\n",
|
||||||
|
" \n",
|
||||||
|
" await users_collection.update_one(\n",
|
||||||
|
" {\"_id\": ObjectId(user_id_str)},\n",
|
||||||
|
" {\"$set\": {\"hashed_password\": hashed_password}}\n",
|
||||||
|
" )\n",
|
||||||
|
" \n",
|
||||||
|
" return {\"ok\": True, \"message\": \"Пароль успешно изменён\", \"next\": '/profile'}\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
"@router.post(\"/api/reset-password\")\n",
|
"@router.post(\"/api/reset-password\")\n",
|
||||||
"async def reset_password(\n",
|
"async def reset_password(\n",
|
||||||
" data: ResetPasswordRequest = Body(...),\n",
|
" data: ResetPasswordRequest = Body(...),\n",
|
||||||
@ -3769,6 +3863,8 @@
|
|||||||
" update_doc[\"status\"] = -1\n",
|
" update_doc[\"status\"] = -1\n",
|
||||||
" if payload.date_finish is not None:\n",
|
" if payload.date_finish is not None:\n",
|
||||||
" update_doc[\"date_finish\"] = payload.date_finish\n",
|
" update_doc[\"date_finish\"] = payload.date_finish\n",
|
||||||
|
" if datetime.now(timezone.utc) + timedelta(hours=3) > payload.datetime_start and datetime.now(timezone.utc) + timedelta(hours=3) < payload.date_finish:\n",
|
||||||
|
" update_doc[\"status\"] = 0\n",
|
||||||
" if payload.address is not None:\n",
|
" if payload.address is not None:\n",
|
||||||
" await users_collection.update_one(\n",
|
" await users_collection.update_one(\n",
|
||||||
" { \"_id\": ObjectId(current_user[\"_id\"]) },\n",
|
" { \"_id\": ObjectId(current_user[\"_id\"]) },\n",
|
||||||
@ -4082,6 +4178,54 @@
|
|||||||
],
|
],
|
||||||
"display_name": ".venv"
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "30acea7c",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"@app.get(\"/b/{bucket_name}/{file_id}/{new_w}/{quality}/{GaussianBlur_radius}\")\n",
|
||||||
|
"async def get_file_with_config(\n",
|
||||||
|
" bucket_name: str,\n",
|
||||||
|
" file_id: str,\n",
|
||||||
|
" new_w: int,\n",
|
||||||
|
" quality: int,\n",
|
||||||
|
" GaussianBlur_radius: int,\n",
|
||||||
|
"):\n",
|
||||||
|
" res = await stream_gridfs_file(file_id, bucket_name)\n",
|
||||||
|
" if res is None:\n",
|
||||||
|
" raise HTTPException(status_code=404, detail=\"File not found\")\n",
|
||||||
|
"\n",
|
||||||
|
" filename, content_type, file_length, iterator = res\n",
|
||||||
|
"\n",
|
||||||
|
" # Собираем все байты файла\n",
|
||||||
|
" data = b\"\".join([chunk async for chunk in iterator()])\n",
|
||||||
|
"\n",
|
||||||
|
" # Обрабатываем\n",
|
||||||
|
" processed = await build_composite(\n",
|
||||||
|
" data,\n",
|
||||||
|
" new_w=new_w,\n",
|
||||||
|
" quality=quality,\n",
|
||||||
|
" GaussianBlur_radius=GaussianBlur_radius,\n",
|
||||||
|
" )\n",
|
||||||
|
"\n",
|
||||||
|
" headers = {\n",
|
||||||
|
" \"Content-Disposition\": content_disposition_header(filename, inline=True),\n",
|
||||||
|
" \"Cache-Control\": \"public, max-age=3600\",\n",
|
||||||
|
" \"ETag\": f'\"{bucket_name}:{file_id}:{new_w}:{quality}:{GaussianBlur_radius}\"',\n",
|
||||||
|
" \"Vary\": \"Accept-Encoding\",\n",
|
||||||
|
" \"Content-Length\": str(len(processed.getvalue())),\n",
|
||||||
|
" }\n",
|
||||||
|
"\n",
|
||||||
|
" return StreamingResponse(\n",
|
||||||
|
" processed,\n",
|
||||||
|
" media_type=\"image/webp\",\n",
|
||||||
|
" headers=headers,\n",
|
||||||
|
" )"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
@ -4146,6 +4290,30 @@
|
|||||||
],
|
],
|
||||||
"display_name": ".venv"
|
"display_name": ".venv"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "c7c3772b",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"@app.post(\"/api/admin/delete-user\")\n",
|
||||||
|
"async def delete_user(\n",
|
||||||
|
" payload: DeleteUserRequest,\n",
|
||||||
|
" user: Dict = Security(get_current_user, scopes=[\"admin\"]),\n",
|
||||||
|
"):\n",
|
||||||
|
" try:\n",
|
||||||
|
" result = users_collection.delete_one({\"_id\": ObjectId(payload._id)})\n",
|
||||||
|
" except Exception:\n",
|
||||||
|
" raise HTTPException(status_code=400, detail=\"Неверный ID пользователя\")\n",
|
||||||
|
"\n",
|
||||||
|
" if result.deleted_count == 0:\n",
|
||||||
|
" raise HTTPException(status_code=404, detail=\"Пользователь не найден\")\n",
|
||||||
|
"\n",
|
||||||
|
" return {\"ok\": True}\n"
|
||||||
|
],
|
||||||
|
"display_name": ".venv"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
@ -4161,7 +4329,7 @@
|
|||||||
" users = [doc async for doc in users_collection.find().sort({'_id':-1})]\n",
|
" users = [doc async for doc in users_collection.find().sort({'_id':-1})]\n",
|
||||||
" return templates.TemplateResponse(\n",
|
" return templates.TemplateResponse(\n",
|
||||||
" request=request, name=\"profile/admin/users.html\", context={\"user\": user,\n",
|
" request=request, name=\"profile/admin/users.html\", context={\"user\": user,\n",
|
||||||
" 'users': users \n",
|
" 'users': users\n",
|
||||||
" }\n",
|
" }\n",
|
||||||
" )"
|
" )"
|
||||||
],
|
],
|
||||||
@ -4851,8 +5019,8 @@
|
|||||||
" \n",
|
" \n",
|
||||||
" data = [doc async for doc in get_delivery_aggregate(_match)]\n",
|
" data = [doc async for doc in get_delivery_aggregate(_match)]\n",
|
||||||
"\n",
|
"\n",
|
||||||
" exhibitions = await exhibition_collection.find({'_id': {'$in': [ObjectId(i.get('exhibition_id')) for i in data if i.get('exhibition_id')]}}).to_list()\n",
|
" exhibitions = await exhibition_collection.find({'_id': {'$in': [ObjectId(i.get('exhibition_id')) for i in data if i.get('exhibition_id')]}}).sort(\"_id\", -1).to_list()\n",
|
||||||
" competitions = await competitions_col.find({'_id': {'$in': [i.get('competition_id') for i in data if i.get('competition_id')]}}).to_list()\n",
|
" competitions = await competitions_col.find({'_id': {'$in': [i.get('competition_id') for i in data if i.get('competition_id')]}}).sort(\"_id\", -1).to_list()\n",
|
||||||
" return templates.TemplateResponse(\n",
|
" return templates.TemplateResponse(\n",
|
||||||
" request=request,\n",
|
" request=request,\n",
|
||||||
" name=\"profile/delivery.html\",\n",
|
" name=\"profile/delivery.html\",\n",
|
||||||
|
|||||||
245
main.py
245
main.py
@ -81,24 +81,43 @@ def transliterate(text: str) -> str:
|
|||||||
return ''.join(RUS_TO_LAT.get(ch, '' if ch not in string.ascii_letters else ch) for ch in text)
|
return ''.join(RUS_TO_LAT.get(ch, '' if ch not in string.ascii_letters else ch) for ch in text)
|
||||||
|
|
||||||
|
|
||||||
def get_delivery_aggregate(_filter: dict = {}, _status: str = None):
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
|
|
||||||
|
def get_delivery_aggregate(
|
||||||
|
_filter: dict[str, Any] | None = None,
|
||||||
|
_status: str | list[str] | None = None,
|
||||||
|
):
|
||||||
cursor = delivery_collection.aggregate([
|
cursor = delivery_collection.aggregate([
|
||||||
{"$match": {
|
{
|
||||||
"prod": PROD, **_filter
|
"$match": {
|
||||||
}},
|
"prod": PROD,
|
||||||
|
**(_filter or {}),
|
||||||
|
}
|
||||||
|
},
|
||||||
{"$sort": {"_id": -1}},
|
{"$sort": {"_id": -1}},
|
||||||
{"$group": {
|
{
|
||||||
"_id": "$post_id",
|
"$group": {
|
||||||
"doc": {"$first": "$$ROOT"}
|
"_id": "$post_id",
|
||||||
}},
|
"doc": {"$first": "$$ROOT"},
|
||||||
|
}
|
||||||
|
},
|
||||||
{"$replaceRoot": {"newRoot": "$doc"}},
|
{"$replaceRoot": {"newRoot": "$doc"}},
|
||||||
{"$sort": {"_id": -1}},
|
{"$sort": {"_id": -1}},
|
||||||
])
|
])
|
||||||
if _status:
|
|
||||||
return (doc async for doc in cursor if doc.get('status') == _status)
|
if _status is None:
|
||||||
else:
|
|
||||||
return cursor
|
return cursor
|
||||||
|
|
||||||
|
statuses = {_status} if isinstance(_status, str) else set(_status)
|
||||||
|
|
||||||
|
async def filtered_cursor() -> AsyncIterator[dict[str, Any]]:
|
||||||
|
async for doc in cursor:
|
||||||
|
if doc.get("status") in statuses:
|
||||||
|
yield doc
|
||||||
|
|
||||||
|
return filtered_cursor()
|
||||||
|
|
||||||
|
|
||||||
async def get_latest_news() -> List[dict]:
|
async def get_latest_news() -> List[dict]:
|
||||||
cursor = news_collection.find({'publish_on_platform': True}).sort("_id", -1).limit(30)
|
cursor = news_collection.find({'publish_on_platform': True}).sort("_id", -1).limit(30)
|
||||||
@ -797,6 +816,13 @@ async def login_token(token: str, request: Request, response: Response):
|
|||||||
return RedirectResponse(next_url,headers=response.headers)
|
return RedirectResponse(next_url,headers=response.headers)
|
||||||
|
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
def sha256(text: str) -> str:
|
||||||
|
"""Server-side SHA-256 (hex)"""
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/login")
|
@app.post("/api/login")
|
||||||
async def login(
|
async def login(
|
||||||
request: Request,
|
request: Request,
|
||||||
@ -807,57 +833,86 @@ async def login(
|
|||||||
await check_bruteforce(identifier)
|
await check_bruteforce(identifier)
|
||||||
|
|
||||||
or_conditions = [
|
or_conditions = [
|
||||||
{"email": identifier},
|
{"email": identifier},
|
||||||
{"email": identifier.lower()},
|
{"email": identifier.lower()},
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
normalized_phone = normalize_phone(identifier)
|
normalized_phone = normalize_phone(identifier)
|
||||||
if normalized_phone:
|
if normalized_phone:
|
||||||
or_conditions.append({"phone": normalized_phone})
|
or_conditions.append({"phone": normalized_phone})
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
users = [doc async for doc in users_collection.find({"$or": or_conditions})]
|
|
||||||
|
|
||||||
|
users = [doc async for doc in users_collection.find({"$or": or_conditions})]
|
||||||
|
|
||||||
if not users:
|
if not users:
|
||||||
await increment_failed_attempt(identifier)
|
await increment_failed_attempt(identifier)
|
||||||
raise HTTPException(401, "Incorrect credentials")
|
raise HTTPException(status_code=401, detail="Incorrect credentials")
|
||||||
|
|
||||||
# Track if a valid password was found
|
matched_user = None
|
||||||
valid_user_found = False
|
new_hash = None
|
||||||
|
is_hash_login = login_data.client_id == "web_hash"
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
stored = user.get("hashed_password")
|
stored = user.get("hashed_password")
|
||||||
|
|
||||||
if isinstance(stored, bytes):
|
if isinstance(stored, bytes):
|
||||||
stored = stored.decode()
|
stored = stored.decode()
|
||||||
|
|
||||||
# Check if the password is hashed with Argon2
|
if not stored:
|
||||||
if stored.startswith("$argon2"):
|
continue
|
||||||
ok = verify_password(login_data.password, stored)
|
|
||||||
if ok:
|
|
||||||
valid_user_found = True
|
|
||||||
break # Password is valid, exit the loop
|
|
||||||
|
|
||||||
# Check if the password is hashed with bcrypt
|
|
||||||
elif stored.startswith("$2y$"):
|
|
||||||
stored = stored.replace("$2y$", "$2b$", 1)
|
|
||||||
if bcrypt.checkpw(login_data.password.encode(), stored.encode()):
|
|
||||||
valid_user_found = True
|
|
||||||
new_hash = get_password_hash(login_data.password)
|
|
||||||
break # Password is valid, exit the loop
|
|
||||||
|
|
||||||
# If no valid user was found, increment attempts and raise error
|
# ---------- 1. Client sent SHA-256 hash ----------
|
||||||
if not valid_user_found:
|
if is_hash_login:
|
||||||
|
# Stored value is expected to be Argon2(SHA256(password))
|
||||||
|
if stored.startswith("$argon2"):
|
||||||
|
if verify_password(login_data.password, stored):
|
||||||
|
matched_user = user
|
||||||
|
break
|
||||||
|
|
||||||
|
# ---------- 2. Client sent plaintext ----------
|
||||||
|
else:
|
||||||
|
# Modern: Argon2(SHA256(plain))
|
||||||
|
if stored.startswith("$argon2"):
|
||||||
|
# Try verifying as if stored is Argon2(SHA256(plain))
|
||||||
|
hashed_input = sha256(login_data.password)
|
||||||
|
if verify_password(hashed_input, stored):
|
||||||
|
matched_user = user
|
||||||
|
break
|
||||||
|
|
||||||
|
# Also try classic Argon2(plain) for very old accounts
|
||||||
|
if verify_password(login_data.password, stored):
|
||||||
|
matched_user = user
|
||||||
|
# Force migration to Argon2(SHA256)
|
||||||
|
new_hash = get_password_hash(sha256(login_data.password))
|
||||||
|
break
|
||||||
|
|
||||||
|
# Legacy bcrypt(plain)
|
||||||
|
elif stored.startswith(("$2y$", "$2b$", "$2a$")):
|
||||||
|
stored_bcrypt = stored.replace("$2y$", "$2b$", 1)
|
||||||
|
try:
|
||||||
|
if bcrypt.checkpw(
|
||||||
|
login_data.password.encode("utf-8"),
|
||||||
|
stored_bcrypt.encode("utf-8")
|
||||||
|
):
|
||||||
|
matched_user = user
|
||||||
|
# Migrate to Argon2(SHA256(plain))
|
||||||
|
new_hash = get_password_hash(sha256(login_data.password))
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not matched_user:
|
||||||
await increment_failed_attempt(identifier)
|
await increment_failed_attempt(identifier)
|
||||||
raise HTTPException(401, "Incorrect credentials")
|
raise HTTPException(status_code=401, detail="Incorrect credentials")
|
||||||
|
|
||||||
# If we reach this point, authentication succeeded
|
# Apply migration if needed
|
||||||
# Proceed with further logic such as generating tokens or returning user details
|
if new_hash:
|
||||||
|
await users_collection.update_one(
|
||||||
|
{"_id": matched_user["_id"]},
|
||||||
|
{"$set": {"hashed_password": new_hash}}
|
||||||
|
)
|
||||||
|
|
||||||
# await users_collection.update_one({"_id": user["_id"]}, {"$set": {"hashed_password": new_hash}})
|
|
||||||
|
|
||||||
await reset_failed_attempts(identifier)
|
await reset_failed_attempts(identifier)
|
||||||
|
|
||||||
user_id_str = str(user["_id"])
|
user_id_str = str(user["_id"])
|
||||||
@ -1224,8 +1279,10 @@ async def exhibition_members(
|
|||||||
):
|
):
|
||||||
exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})
|
exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})
|
||||||
if exhibition:
|
if exhibition:
|
||||||
posts = [doc async for doc in get_delivery_aggregate({"exhibition_id": exhibition_id}, 'На выставке')]
|
posts = [doc async for doc in get_delivery_aggregate({"exhibition_id": exhibition_id}, ['Получено', 'Получено галерей', 'Продано', 'На выставке'])]
|
||||||
|
for i in posts:
|
||||||
|
if (post_id:=i.get('post_id')) and 'post' not in i:
|
||||||
|
i['post'] = await posts_collection.find_one({'_id': ObjectId(post_id)})
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request, name="exhibition/index.html", context={"id": 1, "user": user, "exhibition": exhibition, "posts": posts, 'now': datetime.now()}
|
request=request, name="exhibition/index.html", context={"id": 1, "user": user, "exhibition": exhibition, "posts": posts, 'now': datetime.now()}
|
||||||
)
|
)
|
||||||
@ -1432,6 +1489,43 @@ async def restore_page(request: Request, token: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/reset-hash-password")
|
||||||
|
async def reset_hash_password_user(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
data: ResetPasswordRequest = Body(...),
|
||||||
|
current_user: Dict = Security(get_current_user)
|
||||||
|
):
|
||||||
|
user_id_str = current_user["id"]
|
||||||
|
|
||||||
|
session_key = f"user_sessions:{user_id_str}"
|
||||||
|
tokens = await redis_client.smembers(session_key)
|
||||||
|
for t in tokens:
|
||||||
|
await redis_client.delete(t)
|
||||||
|
await redis_client.delete(session_key)
|
||||||
|
|
||||||
|
for cookie_name in request.cookies.keys():
|
||||||
|
if cookie_name in ['zoom']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for dom in [None,'.kultmost.ru','www.kultmost.ru','avtopost.kultmost.ru','avtopost.kultmost.ru','kultmost.ru']:
|
||||||
|
response.delete_cookie(
|
||||||
|
key=cookie_name,
|
||||||
|
domain=dom,
|
||||||
|
path="/",
|
||||||
|
secure=False,
|
||||||
|
httponly=False,
|
||||||
|
)
|
||||||
|
hashed_password = get_password_hash(data.new_password)
|
||||||
|
|
||||||
|
await users_collection.update_one(
|
||||||
|
{"_id": ObjectId(user_id_str)},
|
||||||
|
{"$set": {"hashed_password": hashed_password}}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"ok": True, "message": "Пароль успешно изменён", "next": '/profile'}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/reset-password")
|
@router.post("/api/reset-password")
|
||||||
async def reset_password(
|
async def reset_password(
|
||||||
data: ResetPasswordRequest = Body(...),
|
data: ResetPasswordRequest = Body(...),
|
||||||
@ -2845,6 +2939,8 @@ async def put_competition(
|
|||||||
update_doc["status"] = -1
|
update_doc["status"] = -1
|
||||||
if payload.date_finish is not None:
|
if payload.date_finish is not None:
|
||||||
update_doc["date_finish"] = payload.date_finish
|
update_doc["date_finish"] = payload.date_finish
|
||||||
|
if datetime.now(timezone.utc) + timedelta(hours=3) > payload.datetime_start and datetime.now(timezone.utc) + timedelta(hours=3) < payload.date_finish:
|
||||||
|
update_doc["status"] = 0
|
||||||
if payload.address is not None:
|
if payload.address is not None:
|
||||||
await users_collection.update_one(
|
await users_collection.update_one(
|
||||||
{ "_id": ObjectId(current_user["_id"]) },
|
{ "_id": ObjectId(current_user["_id"]) },
|
||||||
@ -3122,6 +3218,45 @@ async def get_avatar(user_id: str):
|
|||||||
return StreamingResponse(iterator(), media_type=content_type, headers=headers)
|
return StreamingResponse(iterator(), media_type=content_type, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/b/{bucket_name}/{file_id}/{new_w}/{quality}/{GaussianBlur_radius}")
|
||||||
|
async def get_file_with_config(
|
||||||
|
bucket_name: str,
|
||||||
|
file_id: str,
|
||||||
|
new_w: int,
|
||||||
|
quality: int,
|
||||||
|
GaussianBlur_radius: int,
|
||||||
|
):
|
||||||
|
res = await stream_gridfs_file(file_id, bucket_name)
|
||||||
|
if res is None:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
filename, content_type, file_length, iterator = res
|
||||||
|
|
||||||
|
# Собираем все байты файла
|
||||||
|
data = b"".join([chunk async for chunk in iterator()])
|
||||||
|
|
||||||
|
# Обрабатываем
|
||||||
|
processed = await build_composite(
|
||||||
|
data,
|
||||||
|
new_w=new_w,
|
||||||
|
quality=quality,
|
||||||
|
GaussianBlur_radius=GaussianBlur_radius,
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": content_disposition_header(filename, inline=True),
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
"ETag": f'"{bucket_name}:{file_id}:{new_w}:{quality}:{GaussianBlur_radius}"',
|
||||||
|
"Vary": "Accept-Encoding",
|
||||||
|
"Content-Length": str(len(processed.getvalue())),
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
processed,
|
||||||
|
media_type="image/webp",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
async def unsubscribe_user(
|
async def unsubscribe_user(
|
||||||
email: str,
|
email: str,
|
||||||
token: Optional[str] = None
|
token: Optional[str] = None
|
||||||
@ -3178,6 +3313,22 @@ async def unsubscribe(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/delete-user")
|
||||||
|
async def delete_user(
|
||||||
|
payload: DeleteUserRequest,
|
||||||
|
user: Dict = Security(get_current_user, scopes=["admin"]),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
result = users_collection.delete_one({"_id": ObjectId(payload._id)})
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Неверный ID пользователя")
|
||||||
|
|
||||||
|
if result.deleted_count == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/profile/admin/users", response_class=HTMLResponse)
|
@app.get("/profile/admin/users", response_class=HTMLResponse)
|
||||||
async def profile_admin_statistics(
|
async def profile_admin_statistics(
|
||||||
request: Request,
|
request: Request,
|
||||||
@ -3186,7 +3337,7 @@ async def profile_admin_statistics(
|
|||||||
users = [doc async for doc in users_collection.find().sort({'_id':-1})]
|
users = [doc async for doc in users_collection.find().sort({'_id':-1})]
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request, name="profile/admin/users.html", context={"user": user,
|
request=request, name="profile/admin/users.html", context={"user": user,
|
||||||
'users': users
|
'users': users
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -3688,8 +3839,8 @@ async def profile_delivery(
|
|||||||
|
|
||||||
data = [doc async for doc in get_delivery_aggregate(_match)]
|
data = [doc async for doc in get_delivery_aggregate(_match)]
|
||||||
|
|
||||||
exhibitions = await exhibition_collection.find({'_id': {'$in': [ObjectId(i.get('exhibition_id')) for i in data if i.get('exhibition_id')]}}).to_list()
|
exhibitions = await exhibition_collection.find({'_id': {'$in': [ObjectId(i.get('exhibition_id')) for i in data if i.get('exhibition_id')]}}).sort("_id", -1).to_list()
|
||||||
competitions = await competitions_col.find({'_id': {'$in': [i.get('competition_id') for i in data if i.get('competition_id')]}}).to_list()
|
competitions = await competitions_col.find({'_id': {'$in': [i.get('competition_id') for i in data if i.get('competition_id')]}}).sort("_id", -1).to_list()
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="profile/delivery.html",
|
name="profile/delivery.html",
|
||||||
|
|||||||
@ -70,6 +70,10 @@ class UserLoginForm(OAuth2PasswordRequestForm):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteUserRequest(BaseModel):
|
||||||
|
_id: str
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
identifier: str
|
identifier: str
|
||||||
password: str
|
password: str
|
||||||
|
|||||||
@ -38,3 +38,4 @@ celery[redis]==5.6.3
|
|||||||
pika==1.3.2
|
pika==1.3.2
|
||||||
pillow==12.1.1
|
pillow==12.1.1
|
||||||
pytz
|
pytz
|
||||||
|
python-gnupg
|
||||||
|
|||||||
@ -439,6 +439,7 @@ document.getElementById('preview_poster').src = `/b/competition/${competition.po
|
|||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
|
const competition = JSON.parse({{competition|tojson}})
|
||||||
var type = competition.type_;
|
var type = competition.type_;
|
||||||
|
|
||||||
function toggleType(clickedId) {
|
function toggleType(clickedId) {
|
||||||
|
|||||||
@ -214,7 +214,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
/* ---------- Utility helpers (removed) ---------- */
|
/* ---------- Utility helpers (removed) ---------- */
|
||||||
|
|
||||||
/* Toggle visibility of a block (flex / none) */
|
/* Toggle visibility of a block (flex / none) */
|
||||||
@ -359,7 +360,8 @@ document.getElementById('address').value = '{{ user.send_address[-1] }}'
|
|||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
function setTomorrowAtNoon() {
|
function setTomorrowAtNoon() {
|
||||||
const input = document.getElementById('datetime_start');
|
const input = document.getElementById('datetime_start');
|
||||||
const date_finish = document.getElementById('date_finish');
|
const date_finish = document.getElementById('date_finish');
|
||||||
|
|||||||
@ -97,7 +97,7 @@
|
|||||||
<div class="b_1tx4m">
|
<div class="b_1tx4m">
|
||||||
<picture data-link="https://kultmost.ru/artists/{{post.user._id}}" data-label="Все картины автора и стоимость">
|
<picture data-link="https://kultmost.ru/artists/{{post.user._id}}" data-label="Все картины автора и стоимость">
|
||||||
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m"
|
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m"
|
||||||
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}">
|
src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
|
||||||
</picture>
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
<figcaption class="c_fzh03 b_bp0ws">
|
<figcaption class="c_fzh03 b_bp0ws">
|
||||||
|
|||||||
@ -73,7 +73,7 @@
|
|||||||
<div class="b_1tx4m">
|
<div class="b_1tx4m">
|
||||||
<picture>
|
<picture>
|
||||||
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m"
|
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m"
|
||||||
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}">
|
src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
|
||||||
</picture>
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
<figcaption class="c_fzh03 b_bp0ws">
|
<figcaption class="c_fzh03 b_bp0ws">
|
||||||
@ -107,7 +107,7 @@
|
|||||||
<div class="b_1tx4m">
|
<div class="b_1tx4m">
|
||||||
<picture>
|
<picture>
|
||||||
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m"
|
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m"
|
||||||
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}">
|
src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
|
||||||
</picture>
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
<figcaption class="c_fzh03 b_bp0ws">
|
<figcaption class="c_fzh03 b_bp0ws">
|
||||||
|
|||||||
@ -54,7 +54,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/logout', {
|
const resp = await fetch('/logout', {
|
||||||
@ -73,7 +74,8 @@
|
|||||||
}
|
}
|
||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
async function switchProfile() {
|
async function switchProfile() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/switch-profile', {
|
const resp = await fetch('/api/switch-profile', {
|
||||||
@ -89,7 +91,8 @@
|
|||||||
}
|
}
|
||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const avatarInput = document.getElementById('avatar');
|
const avatarInput = document.getElementById('avatar');
|
||||||
const preview = document.getElementById('preview');
|
const preview = document.getElementById('preview');
|
||||||
|
|||||||
@ -58,16 +58,14 @@
|
|||||||
<h2>{{ data.post.title }}</h2>
|
<h2>{{ data.post.title }}</h2>
|
||||||
<h2>{{ data.post.author }}</h2>
|
<h2>{{ data.post.author }}</h2>
|
||||||
<h2>{{ data.post.grade }}</h2>
|
<h2>{{ data.post.grade }}</h2>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|
||||||
<div class="gallery">
|
<div class="gallery">
|
||||||
{% for img in data.post.medias %}
|
{% for img in data.post.medias %}
|
||||||
<img src="https://avtopost.kultmost.ru/b/posts/{{ img }}" alt="Gallery Image" crossorigin="anonymous" fetchpriority="high" loading="eager">
|
<img src="https://avtopost.kultmost.ru/b/posts/{{ img }}" alt="Gallery Image" crossorigin="anonymous" fetchpriority="high" loading="eager">
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<span>На Культмост добавлена новая картина 🖼</span>
|
<span>На Культмост добавлена новая картина 🖼</span>
|
||||||
<!-- : <a href="https://kultmost.ru/post/{{ data.post.id }}">Посмотреть</a> -->
|
: <a href="https://kultmost.ru/post/{{ data.post._id }}">Посмотреть</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
|
|||||||
@ -92,11 +92,12 @@
|
|||||||
<div class="c_cnedr">
|
<div class="c_cnedr">
|
||||||
{% for doc in posts %}
|
{% for doc in posts %}
|
||||||
{% set post=doc.get('post')%}
|
{% set post=doc.get('post')%}
|
||||||
|
{% if post %}
|
||||||
<figure class="c_g5532 b_wcxt0">
|
<figure class="c_g5532 b_wcxt0">
|
||||||
<div class="b_1tx4m">
|
<div class="b_1tx4m">
|
||||||
<picture data-link="https://kultmost.ru/artists/{{post.user._id}}" data-label="Все картины автора и стоимость">
|
<picture data-link="https://kultmost.ru/artists/{{post.get('user',{}).get('_id')}}" data-label="Все картины автора и стоимость">
|
||||||
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m"
|
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1tx4m"
|
||||||
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}">
|
src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
|
||||||
</picture>
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
<figcaption class="c_fzh03 b_bp0ws">
|
<figcaption class="c_fzh03 b_bp0ws">
|
||||||
@ -123,6 +124,7 @@
|
|||||||
<p class="c_tn4fi b_vjrwu">{{ (post._id.generation_time.strftime('%d.%m.%Y')) if post._id else ''}}</p>
|
<p class="c_tn4fi b_vjrwu">{{ (post._id.generation_time.strftime('%d.%m.%Y')) if post._id else ''}}</p>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -215,7 +215,8 @@ document.getElementById('place').value = '{{ user.places[-1] }}'
|
|||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
/* ---------- Utility helpers (removed) ---------- */
|
/* ---------- Utility helpers (removed) ---------- */
|
||||||
|
|
||||||
/* Toggle visibility of a block (flex / none) */
|
/* Toggle visibility of a block (flex / none) */
|
||||||
@ -370,7 +371,8 @@ function bindLinkValidation({
|
|||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
function setTomorrowAtNoon() {
|
function setTomorrowAtNoon() {
|
||||||
const input = document.getElementById('datetime_start');
|
const input = document.getElementById('datetime_start');
|
||||||
const date_finish = document.getElementById('date_finish');
|
const date_finish = document.getElementById('date_finish');
|
||||||
@ -391,7 +393,8 @@ function bindLinkValidation({
|
|||||||
setTomorrowAtNoon();
|
setTomorrowAtNoon();
|
||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
|
|
||||||
const titleInput = document.getElementById('title');
|
const titleInput = document.getElementById('title');
|
||||||
const topicInput = document.getElementById('topic');
|
const topicInput = document.getElementById('topic');
|
||||||
|
|||||||
@ -166,7 +166,8 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
function toggleBlock(id, show) {
|
function toggleBlock(id, show) {
|
||||||
document.querySelector(id).style.display = show ? 'flex' : 'none';
|
document.querySelector(id).style.display = show ? 'flex' : 'none';
|
||||||
document.querySelector(id).querySelector('input').required = show ? true : false;
|
document.querySelector(id).querySelector('input').required = show ? true : false;
|
||||||
@ -295,7 +296,8 @@ bindLinkValidation({
|
|||||||
});
|
});
|
||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
function setTomorrowAtNoon() {
|
function setTomorrowAtNoon() {
|
||||||
const input = document.getElementById('datetime_start');
|
const input = document.getElementById('datetime_start');
|
||||||
const date_finish = document.getElementById('date_finish');
|
const date_finish = document.getElementById('date_finish');
|
||||||
@ -316,7 +318,8 @@ bindLinkValidation({
|
|||||||
setTomorrowAtNoon();
|
setTomorrowAtNoon();
|
||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
|
|
||||||
const exhibition = JSON.parse({{exhibition|tojson}})
|
const exhibition = JSON.parse({{exhibition|tojson}})
|
||||||
const exhibitionId = exhibition._id
|
const exhibitionId = exhibition._id
|
||||||
|
|||||||
@ -27,7 +27,8 @@
|
|||||||
{{ articles.section(news) }}
|
{{ articles.section(news) }}
|
||||||
{{ footer.section(user) }}
|
{{ footer.section(user) }}
|
||||||
{% if pwa %}
|
{% if pwa %}
|
||||||
<script async>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
fetch('/api/me')
|
fetch('/api/me')
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
@ -72,6 +72,24 @@ function sendToNextPage(e) {
|
|||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
|
async function getSHA256Hash(message) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const msgBuffer = encoder.encode(message);
|
||||||
|
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
||||||
|
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
|
||||||
|
const hashHex = hashArray
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return hashHex;
|
||||||
|
}
|
||||||
|
// @license-end
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const emailInput = document.getElementById('email');
|
const emailInput = document.getElementById('email');
|
||||||
@ -114,46 +132,84 @@ function sendToNextPage(e) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No client-side validation errors — send request
|
const identifier = emailInput ? emailInput.value.trim() : '';
|
||||||
try {
|
const plainPassword = passwordInput ? passwordInput.value : '';
|
||||||
const payload = {
|
|
||||||
identifier: emailInput ? emailInput.value : '',
|
|
||||||
password: passwordInput ? passwordInput.value : '',
|
|
||||||
client_id: 'web'
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch('/api/login', {
|
if (!identifier || !plainPassword) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. First attempt: send SHA-256 hash with client_id = 'web_hash'
|
||||||
|
const hash = await getSHA256Hash(plainPassword);
|
||||||
|
console.log('Trying with hash:', hash); // for debug, remove later
|
||||||
|
|
||||||
|
let res = await fetch('/api/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
identifier: identifier,
|
||||||
|
password: hash,
|
||||||
|
client_id: 'web_hash'
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2. If hash attempt failed → try plain password with client_id = 'web'
|
||||||
|
if (!res.ok && res.status === 401) {
|
||||||
|
console.log('Hash login failed, trying plain password...');
|
||||||
|
res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
identifier: identifier,
|
||||||
|
password: plainPassword,
|
||||||
|
client_id: 'web'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Handle final result
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
// success (HTTP 2xx). Redirect to /form
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const next = params.get('next') || '/profile';
|
const next = params.get('next') || '/profile';
|
||||||
window.location.href = next;
|
window.location.href = next;
|
||||||
|
return;
|
||||||
} else {
|
|
||||||
const allFields = document.querySelectorAll('.floating-group');
|
|
||||||
let msg;
|
|
||||||
if (res.status === 403) {
|
|
||||||
// Аккаунт заблокирован — поля ввода ни при чём, показываем причину
|
|
||||||
msg = 'Ваш аккаунт заблокирован администратором.';
|
|
||||||
try { const d = await res.json(); if (d && typeof d.detail === 'string') msg = d.detail; } catch (e) {}
|
|
||||||
allFields.forEach(g => g.classList.remove('error'));
|
|
||||||
} else {
|
|
||||||
msg = 'Неверный логин или пароль';
|
|
||||||
allFields.forEach(g => g.classList.add('error'));
|
|
||||||
}
|
|
||||||
if (typeof showToast === 'function') showToast({ message: msg, status: 'error', duration: 9000 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
const allFields = document.querySelectorAll('.floating-group');
|
||||||
|
let msg;
|
||||||
|
|
||||||
|
if (res.status === 403) {
|
||||||
|
msg = 'Ваш аккаунт заблокирован администратором.';
|
||||||
|
try {
|
||||||
|
const d = await res.json();
|
||||||
|
if (d && typeof d.detail === 'string') msg = d.detail;
|
||||||
|
} catch (_) {}
|
||||||
|
allFields.forEach(g => g.classList.remove('error'));
|
||||||
|
} else {
|
||||||
|
msg = 'Неверный логин или пароль';
|
||||||
|
allFields.forEach(g => g.classList.add('error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast({ message: msg, status: 'error', duration: 9000 });
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Network or other unexpected error — mark fields as error
|
console.error('Request failed', err);
|
||||||
const allFields = document.querySelectorAll('.floating-group');
|
const allFields = document.querySelectorAll('.floating-group');
|
||||||
allFields.forEach(g => g.classList.add('error'));
|
allFields.forEach(g => g.classList.add('error'));
|
||||||
console.error('Request failed', err);
|
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast({
|
||||||
|
message: 'Ошибка сети. Попробуйте позже.',
|
||||||
|
status: 'error',
|
||||||
|
duration: 9000
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Убираем красную подсветку сразу при исправлении пользователем
|
// Убираем красную подсветку сразу при исправлении пользователем
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<a href="/paintings/{{post._id}}" class="b_kbsvf">
|
<a href="/paintings/{{post._id}}" class="b_kbsvf">
|
||||||
<picture>
|
<picture>
|
||||||
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_ya0t5"
|
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_ya0t5"
|
||||||
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}">
|
src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -84,7 +84,7 @@ picture>img{ width: 100%; object-fit: scale-down; }
|
|||||||
.b_rhbo8 { color: #5F1798; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
.b_rhbo8 { color: #5F1798; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
.b_tbfd3 { color: rgba(58, 58, 58, 0.75); font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; text-align: right; }
|
.b_tbfd3 { color: rgba(58, 58, 58, 0.75); font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; text-align: right; }
|
||||||
.b_gagrr { align-items: center; align-self: stretch; display: flex; justify-content: space-between; }
|
.b_gagrr { align-items: center; align-self: stretch; display: flex; justify-content: space-between; }
|
||||||
.b_kbsvf { align-items: flex-start; align-self: stretch; display: flex; flex-direction: column; gap: 10px; justify-content: center; }
|
.b_kbsvf { align-items: center; align-self: stretch; display: flex; flex-direction: column; gap: 10px; justify-content: center; }
|
||||||
.b_h57rq { align-items: flex-start; background: #FDFAFF; border-radius: 5px; border: 1px solid #5F1798; box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.25), 0 4px 20px 0 rgba(0, 0, 0, 0.20); display: flex; flex-direction: column; width: 335px; }
|
.b_h57rq { align-items: flex-start; background: #FDFAFF; border-radius: 5px; border: 1px solid #5F1798; box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.25), 0 4px 20px 0 rgba(0, 0, 0, 0.20); display: flex; flex-direction: column; width: 335px; }
|
||||||
.c_jy9j2{padding: 10px 20px; height: 226px;}
|
.c_jy9j2{padding: 10px 20px; height: 226px;}
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
{% block title %}Пользователи{% endblock %}
|
{% block title %}Пользователи{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ header.section(user) }}
|
{{ header.section(user) }}
|
||||||
<section class="c_dg3k4">
|
<section class="c_dg3k4">
|
||||||
@ -16,22 +15,7 @@
|
|||||||
<div class="c_ru432">
|
<div class="c_ru432">
|
||||||
{{ profile_nav.profile_nav(user, 'users') }}
|
{{ profile_nav.profile_nav(user, 'users') }}
|
||||||
<div class="c_tihtk" id="main">
|
<div class="c_tihtk" id="main">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<style>
|
|
||||||
input { padding: 8px; width: 300px; margin-bottom: 10px; border-bottom: 1px solid #5f1798;position: sticky;
|
|
||||||
left: 10px;}
|
|
||||||
table { width: 100%; border-collapse: collapse; }
|
|
||||||
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
|
|
||||||
th { background-color: #f4f4f4; }
|
|
||||||
tr.hidden { display: none; }
|
|
||||||
.highlight { background-color: #ffeb3b; padding: 2px 4px; border-radius: 3px; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
<input type="text" id="searchInput" placeholder="Поиск ..." onkeyup="searchTable()">
|
<input type="text" id="searchInput" placeholder="Поиск ..." onkeyup="searchTable()">
|
||||||
|
|
||||||
<table id="dataTable">
|
<table id="dataTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -42,6 +26,7 @@
|
|||||||
<th>Телефон</th>
|
<th>Телефон</th>
|
||||||
<th>Место проживания</th>
|
<th>Место проживания</th>
|
||||||
<th>Дата рождения</th>
|
<th>Дата рождения</th>
|
||||||
|
<th>...</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -54,12 +39,37 @@
|
|||||||
<td>{{doc.phone or ""}}</td>
|
<td>{{doc.phone or ""}}</td>
|
||||||
<td>{{doc.living_place or ""}}</td>
|
<td>{{doc.living_place or ""}}</td>
|
||||||
<td>{{doc.birthday or ""}}</td>
|
<td>{{doc.birthday or ""}}</td>
|
||||||
|
<td><button class="ba" onclick="deleteUser('{{doc._id}}')">Удалить</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
|
async function deleteUser(params) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/delete-user', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
_id: params,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
<script>
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast({ message: "Пользователь удален", status: 'success' });
|
||||||
|
} else {
|
||||||
|
showToast({ message: data.detail || "Ошибка", status: 'error' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast({ message: "Ошибка соединения с сервером", status: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// @license-end
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
function normalizeText(str) {
|
function normalizeText(str) {
|
||||||
return str
|
return str
|
||||||
@ -108,15 +118,10 @@ function normalizeText(str) {
|
|||||||
}
|
}
|
||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{ footer.section(user) }}
|
{{ footer.section(user) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -124,6 +129,42 @@ function normalizeText(str) {
|
|||||||
{{ header.style() }}
|
{{ header.style() }}
|
||||||
{{ profile_nav.style() }}
|
{{ profile_nav.style() }}
|
||||||
{{ footer.style() }}
|
{{ footer.style() }}
|
||||||
|
<style>
|
||||||
|
input {
|
||||||
|
padding: 8px;
|
||||||
|
width: 300px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #5f1798;
|
||||||
|
position: sticky;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
background-color: #ffeb3b;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<style>
|
<style>
|
||||||
@media (max-width: 699px) {
|
@media (max-width: 699px) {
|
||||||
.c_tihtk {
|
.c_tihtk {
|
||||||
@ -131,5 +172,14 @@ function normalizeText(str) {
|
|||||||
justify-content: unset;
|
justify-content: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
td>button{padding: 13px 15px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #5F1798;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 13px;}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -139,7 +139,8 @@ document.querySelectorAll('.post').forEach(postEl => {
|
|||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
const min_paintings = parseInt('{{competition.min_paintings}}')
|
const min_paintings = parseInt('{{competition.min_paintings}}')
|
||||||
const max_paintings = parseInt('{{competition.max_paintings}}')
|
const max_paintings = parseInt('{{competition.max_paintings}}')
|
||||||
const competition_id = '{{competition._id}}'
|
const competition_id = '{{competition._id}}'
|
||||||
@ -373,7 +374,8 @@ function loadAndClick() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
loadAndClick();
|
loadAndClick();
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const avatarInput = document.getElementById('avatar');
|
const avatarInput = document.getElementById('avatar');
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<div class="b_1vs97">
|
<div class="b_1vs97">
|
||||||
<picture>
|
<picture>
|
||||||
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1vs97"
|
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1vs97"
|
||||||
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}">
|
src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
|
||||||
</picture>
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
<figcaption class="c_dvi1x b_is70q">
|
<figcaption class="c_dvi1x b_is70q">
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
<div class="b_thlt1">
|
<div class="b_thlt1">
|
||||||
<span class="b_5dr47">{{doc.status}}</span>
|
<span class="b_5dr47">{{doc.status}}</span>
|
||||||
</div>
|
</div>
|
||||||
{% elif doc.status == 'Получено' %}
|
{% elif 'Получено' in (doc.status or '') %}
|
||||||
<div class="b_thlt1">
|
<div class="b_thlt1">
|
||||||
<span class="b_5dr47">{{doc.status}}</span>
|
<span class="b_5dr47">{{doc.status}}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -101,99 +101,115 @@
|
|||||||
<div class="c_ru432">
|
<div class="c_ru432">
|
||||||
{{ profile_nav.profile_nav(user, 'delivery') }}
|
{{ profile_nav.profile_nav(user, 'delivery') }}
|
||||||
<div class="c_tihtk" id="main">
|
<div class="c_tihtk" id="main">
|
||||||
{% for competition in competitions %}
|
<!-- <details>
|
||||||
<article class="c_9fgdh b_ax8vz" id="l{{competition._id}}">
|
<summary>Конкурсы</summary>
|
||||||
<a href="/profile/delivery/competition/{{competition._id}}#main">
|
<div class="c_tihtk"> -->
|
||||||
<div class="b_ornr9">
|
{% for competition in competitions %}
|
||||||
<picture>
|
<article class="c_9fgdh b_ax8vz" id="l{{competition._id}}">
|
||||||
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ competition.title }}" class="b_ornr9" src="/b/competition/{{ competition.poster_file_id }}">
|
<a href="/profile/delivery/competition/{{competition._id}}#main">
|
||||||
</picture>
|
<div class="b_ornr9">
|
||||||
</div>
|
<picture>
|
||||||
</a>
|
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ competition.title }}" class="b_ornr9"
|
||||||
|
src="/b/competition/{{ competition.poster_file_id }}">
|
||||||
|
</picture>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
<div class="c_z3dpx b_73p10">
|
<div class="c_z3dpx b_73p10">
|
||||||
<a href="/profile/delivery/competition/{{competition._id}}#main" class="b_73p10">
|
<a href="/profile/delivery/competition/{{competition._id}}#main" class="b_73p10">
|
||||||
<div class="b_vabj9_2">
|
<div class="b_vabj9_2">
|
||||||
<span class="b_0jst1_2">Конкурс</span>
|
<span class="b_0jst1_2">Конкурс</span>
|
||||||
</div>
|
</div>
|
||||||
{% if competition.get('status',-1) == 2 %}
|
{% if competition.get('status',-1) == 2 %}
|
||||||
<div class="b_vabj9_2">
|
<div class="b_vabj9_2">
|
||||||
<span class="b_0jst1_2">Закончился</span>
|
<span class="b_0jst1_2">Закончился</span>
|
||||||
</div>
|
</div>
|
||||||
{% elif competition.get('status',-1) == 1 %}
|
{% elif competition.get('status',-1) == 1 %}
|
||||||
<div class="b_vabj9_2">
|
<div class="b_vabj9_2">
|
||||||
<span class="b_0jst1_2">Идет отбор</span>
|
<span class="b_0jst1_2">Идет отбор</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="b_vabj9">
|
<div class="b_vabj9">
|
||||||
<span class="b_0jst1">{{ {-1:'Формируется',0:'Прием заявок',1:'Идет отбор',2:'Закончился'}[competition.get('status',-1)] }}</span>
|
<span class="b_0jst1">{{ {-1:'Формируется',0:'Прием заявок',1:'Идет
|
||||||
|
отбор',2:'Закончился'}[competition.get('status',-1)] }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<header class="c_m876y b_9xtz9">
|
<header class="c_m876y b_9xtz9">
|
||||||
<h3 class="c_hysjg b_8f3uh" style="color: #222;">{{ competition.title }}</h3>
|
<h3 class="c_hysjg b_8f3uh" style="color: #222;">{{ competition.title }}</h3>
|
||||||
</header>
|
</header>
|
||||||
<p class="c_2fr39 b_tfnz2">{{ competition.topic }}</p>
|
<p class="c_2fr39 b_tfnz2">{{ competition.topic }}</p>
|
||||||
{% if profile %}
|
{% if profile %}
|
||||||
<button class="c_aq9et">
|
<button class="c_aq9et">
|
||||||
<span class="c_som9s">Посмотреть заявки</span>
|
<span class="c_som9s">Посмотреть заявки</span>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set user = competition.get('user') %}
|
{% set user = competition.get('user') %}
|
||||||
<div class="c_dbbe4 b_otttb">
|
<div class="c_dbbe4 b_otttb">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<img class="c_pm80w b_a68fq" src="/a/{{user._id}}" alt="{{ user.username | e }}" crossorigin="anonymous" fetchpriority="high" loading="eager">
|
<img class="c_pm80w b_a68fq" src="/a/{{user._id}}" alt="{{ user.username | e }}" crossorigin="anonymous"
|
||||||
<div class="c_4402p b_ntazj">
|
fetchpriority="high" loading="eager">
|
||||||
<p class="c_kvah4 b_ghosy">{{ user.username | e }}</p>
|
<div class="c_4402p b_ntazj">
|
||||||
</div>
|
<p class="c_kvah4 b_ghosy">{{ user.username | e }}</p>
|
||||||
{% endif %}
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="c_2fr39 b_tfnz2">Подробнее</p>
|
<p class="c_2fr39 b_tfnz2">Подробнее</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
<p class="c_drpqj b_mp1wb">{{ (competition.datetime_start.strftime('%d.%m.%Y')) if competition.datetime_start else ''}}</p>
|
<p class="c_drpqj b_mp1wb">{{ (competition.datetime_start.strftime('%d.%m.%Y')) if competition.datetime_start else
|
||||||
|
''}}</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for i in exhibitions %}
|
<!-- </div>
|
||||||
<article class="c_9fgdh b_ax8vz">
|
</details>
|
||||||
<a href="/profile/delivery/exhibition/{{i._id}}#main">
|
<details open>
|
||||||
<div class="b_ornr9">
|
<summary>Выставки</summary>
|
||||||
<picture>
|
<div class="c_tihtk"> -->
|
||||||
<img src="/b/exhibition/{{i.poster_file_id}}" data-src="/b/exhibition/{{i.poster_orig_id}}" id="preview_poster" crossorigin="anonymous" fetchpriority="high" loading="eager" alt="Загрузите афишу" class="b_ornr9">
|
{% for i in exhibitions %}
|
||||||
</picture>
|
<article class="c_9fgdh b_ax8vz">
|
||||||
</div>
|
<a href="/profile/delivery/exhibition/{{i._id}}#main">
|
||||||
</a>
|
<div class="b_ornr9">
|
||||||
<div class="c_z3dpx b_73p10">
|
<picture>
|
||||||
<a class="b_73p10" href="/profile/delivery/exhibition/{{i._id}}#main">
|
<img src="/b/exhibition/{{i.poster_file_id}}" data-src="/b/exhibition/{{i.poster_orig_id}}" id="preview_poster"
|
||||||
<div class="b_vabj9_2">
|
crossorigin="anonymous" fetchpriority="high" loading="eager" alt="Загрузите афишу" class="b_ornr9">
|
||||||
<span class="b_0jst1_2">Выставка</span>
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
<div class="c_c4wam b_vabj9">
|
</a>
|
||||||
{% if now > i.date_finish %}
|
<div class="c_z3dpx b_73p10">
|
||||||
<span class="c_95csi b_0jst1">Закончилась</span>
|
<a class="b_73p10" href="/profile/delivery/exhibition/{{i._id}}#main">
|
||||||
{% elif now > i.datetime_start %}
|
<div class="b_vabj9_2">
|
||||||
<span class="c_95csi b_0jst1">Началась</span>
|
<span class="b_0jst1_2">Выставка</span>
|
||||||
{% else %}
|
</div>
|
||||||
<span class="c_95csi b_0jst1">Формируется</span>
|
<div class="c_c4wam b_vabj9">
|
||||||
{% endif %}
|
{% if now > i.date_finish %}
|
||||||
</div>
|
<span class="c_95csi b_0jst1">Закончилась</span>
|
||||||
<header class="c_m876y b_9xtz9">
|
{% elif now > i.datetime_start %}
|
||||||
<h3 class="c_hysjg b_8f3uh" id="preview_title" style="color: #222;">{{i.title}}</h3>
|
<span class="c_95csi b_0jst1">Началась</span>
|
||||||
</header>
|
{% else %}
|
||||||
<p class="c_2fr39 b_tfnz2" id="preview_topic">{{i.topic}}</p>
|
<span class="c_95csi b_0jst1">Формируется</span>
|
||||||
<div class="c_dbbe4 b_otttb">
|
{% endif %}
|
||||||
{% if user %}
|
</div>
|
||||||
<img crossorigin="anonymous" fetchpriority="high" loading="eager" class="c_pm80w b_a68fq" src="/a/{{i.user._id}}" alt="{{ i.user.username | e }}">
|
<header class="c_m876y b_9xtz9">
|
||||||
<div class="c_4402p b_ntazj">
|
<h3 class="c_hysjg b_8f3uh" id="preview_title" style="color: #222;">{{i.title}}</h3>
|
||||||
<p class="c_kvah4 b_ghosy">{{ i.user.username }}</p>
|
</header>
|
||||||
</div>
|
<p class="c_2fr39 b_tfnz2" id="preview_topic">{{i.topic}}</p>
|
||||||
{% endif %}
|
<div class="c_dbbe4 b_otttb">
|
||||||
</div>
|
{% if user %}
|
||||||
<p class="c_2fr39 b_tfnz2">Подробнее</p>
|
<img crossorigin="anonymous" fetchpriority="high" loading="eager" class="c_pm80w b_a68fq"
|
||||||
</a>
|
src="/a/{{i.user._id}}" alt="{{ i.user.username | e }}">
|
||||||
<p class="c_drpqj b_mp1wb">{{ ( i._id.generation_time.strftime('%d.%m.%Y')) if i._id else ''}}</p>
|
<div class="c_4402p b_ntazj">
|
||||||
</div>
|
<p class="c_kvah4 b_ghosy">{{ i.user.username }}</p>
|
||||||
</article>
|
</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="c_2fr39 b_tfnz2">Подробнее</p>
|
||||||
|
</a>
|
||||||
|
<p class="c_drpqj b_mp1wb">{{ ( i._id.generation_time.strftime('%d.%m.%Y')) if i._id else ''}}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
<!-- </div>
|
||||||
|
</details> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -208,6 +224,7 @@
|
|||||||
{{ profile_nav.style() }}
|
{{ profile_nav.style() }}
|
||||||
{{card.style()}}
|
{{card.style()}}
|
||||||
<style>
|
<style>
|
||||||
|
summary{ text-align: center; font-size: 24px; color: #5f1798; font-weight: bold; width: 100%; }
|
||||||
/* Base (common) styles */
|
/* Base (common) styles */
|
||||||
.c_aihm6{width: 100%;}
|
.c_aihm6{width: 100%;}
|
||||||
.mw{margin: 0 20px;
|
.mw{margin: 0 20px;
|
||||||
@ -228,7 +245,7 @@ picture > img {
|
|||||||
}
|
}
|
||||||
.b_kbsvf {
|
.b_kbsvf {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@ -33,7 +33,7 @@
|
|||||||
<div class="b_thlt1">
|
<div class="b_thlt1">
|
||||||
<span class="b_5dr47">Запрос на отправку</span>
|
<span class="b_5dr47">Запрос на отправку</span>
|
||||||
</div>
|
</div>
|
||||||
{% elif doc.status == 'Получено' %}
|
{% elif 'Получено' in (doc.status or '') %}
|
||||||
<div class="b_thlt1">
|
<div class="b_thlt1">
|
||||||
<span class="b_5dr47">{{doc.status}}</span>
|
<span class="b_5dr47">{{doc.status}}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -150,7 +150,7 @@ picture > img {
|
|||||||
}
|
}
|
||||||
.b_kbsvf {
|
.b_kbsvf {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
<div class="b_thlt1">
|
<div class="b_thlt1">
|
||||||
<span class="b_5dr47">{{doc.status}}</span>
|
<span class="b_5dr47">{{doc.status}}</span>
|
||||||
</div>
|
</div>
|
||||||
{% elif doc.status == 'Получено' %}
|
{% elif 'Получено' in (doc.status or '') %}
|
||||||
<div class="b_thlt1">
|
<div class="b_thlt1">
|
||||||
<span class="b_5dr47">{{doc.status}}</span>
|
<span class="b_5dr47">{{doc.status}}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -539,7 +539,8 @@
|
|||||||
});
|
});
|
||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
function toggleTrackField() {
|
function toggleTrackField() {
|
||||||
const deliveryType = document.getElementById('delivery_type');
|
const deliveryType = document.getElementById('delivery_type');
|
||||||
const trackGroup = document.getElementById('track-group');
|
const trackGroup = document.getElementById('track-group');
|
||||||
@ -643,7 +644,7 @@ picture > img {
|
|||||||
}
|
}
|
||||||
.b_kbsvf {
|
.b_kbsvf {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@ -7,39 +7,37 @@
|
|||||||
{% block title %}Анкета{% endblock %}
|
{% block title %}Анкета{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ header.section(user) }}
|
{{ header.section(user) }}
|
||||||
<section class="c_dg3k4">
|
<section class="c_dg3k4">
|
||||||
<div class="c_gtoe9">
|
<div class="c_gtoe9">
|
||||||
{{ profile_card.profile_card(user) }}
|
{{ profile_card.profile_card(user) }}
|
||||||
<div class="c_ru432">
|
<div class="c_ru432">
|
||||||
{{ profile_nav.profile_nav(user, 'form') }}
|
{{ profile_nav.profile_nav(user, 'form') }}
|
||||||
<div class="c_tihtk" id="main">
|
<div class="c_tihtk" id="main">
|
||||||
|
|
||||||
<div class="c_aihm6 floating-group">
|
<div class="c_aihm6 floating-group">
|
||||||
<label for="female_name_lastname" class="c_z3ybu floating-label">ФИО<span style="color:red">*</span></label>
|
<label for="female_name_lastname" class="c_z3ybu floating-label">ФИО<span style="color:red">*</span></label>
|
||||||
<input name="female_name_lastname" id="female_name_lastname" class="c_xhd5k" required />
|
<input name="female_name_lastname" id="female_name_lastname" class="c_xhd5k" required />
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label for="birthday" class="c_z3ybu floating-label">Дата рождения<span style="color:red">*</span></label>
|
||||||
<label for="birthday" class="c_z3ybu floating-label">Дата рождения<span style="color:red">*</span></label>
|
|
||||||
|
|
||||||
<div class="c_aihm6 floating-group birthday">
|
<div class="c_aihm6 floating-group birthday">
|
||||||
<select id="day">
|
<select id="day">
|
||||||
<option>1</option>
|
<option>1</option>
|
||||||
<option>2</option>
|
<option>2</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="month">
|
<select id="month">
|
||||||
<option>Январь</option>
|
<option>Январь</option>
|
||||||
<option>Февраль</option>
|
<option>Февраль</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="year">
|
<select id="year">
|
||||||
<option>2000</option>
|
<option>2000</option>
|
||||||
<option>2001</option>
|
<option>2001</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="date" id="birthday" name="birthday" class="c_xhd5k" required data-toast="Поле Дата рождения должно быть заполнено">
|
<input type="date" id="birthday" name="birthday" class="c_xhd5k" required data-toast="Поле Дата рождения должно быть заполнено">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="c_aihm6 floating-group">
|
<div class="c_aihm6 floating-group">
|
||||||
<label for="living_place" class="c_z3ybu floating-label">Место проживания<span style="color:red">*</span></label>
|
<label for="living_place" class="c_z3ybu floating-label">Место проживания<span style="color:red">*</span></label>
|
||||||
@ -65,6 +63,12 @@
|
|||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="c_nb6gy floating-group">
|
||||||
|
<label for="gnupg" class="c_z3ybu floating-label">GnuPG</label>
|
||||||
|
<textarea type="text" id="gnupg" name="gnupg" class="c_4ykpn" ></textarea>
|
||||||
|
<span>Самозащита электронной почты</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="c_aihm6 floating-group">
|
<div class="c_aihm6 floating-group">
|
||||||
<label for="phone" class="c_z3ybu floating-label">Телефон<span style="color:red">*</span></label>
|
<label for="phone" class="c_z3ybu floating-label">Телефон<span style="color:red">*</span></label>
|
||||||
<input name="phone" id="phone" class="c_xhd5k" required />
|
<input name="phone" id="phone" class="c_xhd5k" required />
|
||||||
@ -224,6 +228,7 @@ document.getElementById('living_place').value = `{% if user.get('living_place')
|
|||||||
document.getElementById('social_media').value = `{% if user.get('social_media') is not none %}{{user.get('social_media','') }}{% endif %}`
|
document.getElementById('social_media').value = `{% if user.get('social_media') is not none %}{{user.get('social_media','') }}{% endif %}`
|
||||||
document.getElementById('marketplaces').value = `{% if user.get('marketplaces') is not none %}{{user.get('marketplaces','') }}{% endif %}`
|
document.getElementById('marketplaces').value = `{% if user.get('marketplaces') is not none %}{{user.get('marketplaces','') }}{% endif %}`
|
||||||
document.getElementById('email').value = `{% if user.get('email') is not none %}{{user.get('email','') }}{% endif %}`
|
document.getElementById('email').value = `{% if user.get('email') is not none %}{{user.get('email','') }}{% endif %}`
|
||||||
|
document.getElementById('gnupg').value = `{% if user.get('gnupg') is not none %}{{user.get('gnupg','') }}{% endif %}`
|
||||||
document.getElementById('phone').value = `{% if user.get('phone') is not none %}{{user.get('phone','') }}{% endif %}`
|
document.getElementById('phone').value = `{% if user.get('phone') is not none %}{{user.get('phone','') }}{% endif %}`
|
||||||
document.getElementById('messengers').value = `{% if user.get('messengers') is not none %}{{user.get('messengers','') }}{% endif %}`
|
document.getElementById('messengers').value = `{% if user.get('messengers') is not none %}{{user.get('messengers','') }}{% endif %}`
|
||||||
document.getElementById('specialization').value = `{% if user.get('specialization') is not none %}{{user.get('specialization','') }}{% endif %}`
|
document.getElementById('specialization').value = `{% if user.get('specialization') is not none %}{{user.get('specialization','') }}{% endif %}`
|
||||||
@ -258,82 +263,78 @@ document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const createBtn = document.getElementById('createBtn');
|
const createBtn = document.getElementById('createBtn');
|
||||||
|
|
||||||
createBtn.addEventListener('click', async (e) => {
|
createBtn.addEventListener('click', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'female_name_lastname': document.getElementById('female_name_lastname').value,
|
'female_name_lastname': document.getElementById('female_name_lastname').value,
|
||||||
'birthday': document.getElementById('birthday').value,
|
'birthday': document.getElementById('birthday').value,
|
||||||
'living_place': document.getElementById('living_place').value,
|
'living_place': document.getElementById('living_place').value,
|
||||||
'social_media': document.getElementById('social_media').value,
|
'social_media': document.getElementById('social_media').value,
|
||||||
'marketplaces': document.getElementById('marketplaces').value,
|
'marketplaces': document.getElementById('marketplaces').value,
|
||||||
'email': document.getElementById('email').value,
|
'email': document.getElementById('email').value,
|
||||||
'phone': document.getElementById('phone').value,
|
'gnupg': document.getElementById('gnupg').value,
|
||||||
'messengers': document.getElementById('messengers').value,
|
'phone': document.getElementById('phone').value,
|
||||||
'specialization': document.getElementById('specialization').value,
|
'messengers': document.getElementById('messengers').value,
|
||||||
'grade': document.getElementById('grade').value,
|
'specialization': document.getElementById('specialization').value,
|
||||||
'style': document.getElementById('style').value,
|
'grade': document.getElementById('grade').value,
|
||||||
'art_types': document.getElementById('art_types').value,
|
'style': document.getElementById('style').value,
|
||||||
'genres': document.getElementById('genres').value,
|
'art_types': document.getElementById('art_types').value,
|
||||||
'education': document.getElementById('education').value,
|
'genres': document.getElementById('genres').value,
|
||||||
'unions': document.getElementById('unions').value,
|
'education': document.getElementById('education').value,
|
||||||
'rewards': document.getElementById('rewards').value,
|
'unions': document.getElementById('unions').value,
|
||||||
'projects': document.getElementById('projects').value,
|
'rewards': document.getElementById('rewards').value,
|
||||||
'publications': document.getElementById('publications').value,
|
'projects': document.getElementById('projects').value,
|
||||||
'site': document.getElementById('site').value,
|
'publications': document.getElementById('publications').value,
|
||||||
'material_manufacturer': document.getElementById('material_manufacturer').value,
|
'site': document.getElementById('site').value,
|
||||||
'material_use': document.getElementById('material_use').value,
|
'material_manufacturer': document.getElementById('material_manufacturer').value,
|
||||||
'buying_materials': document.getElementById('buying_materials').value,
|
'material_use': document.getElementById('material_use').value,
|
||||||
'collaborate': document.getElementById('collaborate').value,
|
'buying_materials': document.getElementById('buying_materials').value,
|
||||||
'meta_data.wishes': document.getElementById('meta_data.wishes').value,
|
'collaborate': document.getElementById('collaborate').value,
|
||||||
'master_classes': document.getElementById('master_classes').value,
|
'meta_data.wishes': document.getElementById('meta_data.wishes').value,
|
||||||
'materials_use_in_master_classes': document.getElementById('materials_use_in_master_classes').value,
|
'master_classes': document.getElementById('master_classes').value,
|
||||||
'print_merch': document.getElementById('print_merch').value,
|
'materials_use_in_master_classes': document.getElementById('materials_use_in_master_classes').value,
|
||||||
'own_workshop': document.getElementById('own_workshop').value,
|
'print_merch': document.getElementById('print_merch').value,
|
||||||
'workshop_tour': document.getElementById('workshop_tour').value,
|
'own_workshop': document.getElementById('own_workshop').value,
|
||||||
'biography': document.getElementById('biography').value}
|
'workshop_tour': document.getElementById('workshop_tour').value,
|
||||||
|
'biography': document.getElementById('biography').value
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/user/form`, {
|
const res = await fetch(`/api/user/form`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
// headers:{
|
// headers:{
|
||||||
|
|
||||||
// }
|
// }
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast({message: 'Анкета сохранена!', duration:2500});
|
showToast({ message: 'Анкета сохранена!', duration: 2500 });
|
||||||
{% if notify %}
|
{% if notify %}
|
||||||
const targetHash = window.location.hash;
|
const targetHash = window.location.hash;
|
||||||
if (targetHash) {
|
if (targetHash) {
|
||||||
const el = document.querySelector(targetHash);
|
const el = document.querySelector(targetHash);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: 'smooth' });
|
el.scrollIntoView({ behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
{% endif %}
|
{% endif %}
|
||||||
} else {
|
} else {
|
||||||
showToast({message: 'Ошибка при сохранение!', status: 'error', duration:2500});
|
showToast({ message: 'Ошибка при сохранение!', status: 'error', duration: 2500 });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
showToast({message: 'Ошибка соединения с сервером!', status: 'error', duration:2500});
|
showToast({ message: 'Ошибка соединения с сервером!', status: 'error', duration: 2500 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
// @license-end
|
||||||
|
|
||||||
// @license-end
|
|
||||||
</script>
|
</script>
|
||||||
{{ footer.section(user) }}
|
{{ footer.section(user) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -12,21 +12,21 @@
|
|||||||
<figure class="c_4b1hy b_uu2ub" data-exhibition_id="{{doc.get('exhibition_id')}}" data-delivery-id="{{doc._id}}" data-sender="{{doc.sender._id}}" data-receiver="{{doc.receiver._id}}">
|
<figure class="c_4b1hy b_uu2ub" data-exhibition_id="{{doc.get('exhibition_id')}}" data-delivery-id="{{doc._id}}" data-sender="{{doc.sender._id}}" data-receiver="{{doc.receiver._id}}">
|
||||||
<img class="b_1fvkt" src="/b/posts/{{doc.img}}">
|
<img class="b_1fvkt" src="/b/posts/{{doc.img}}">
|
||||||
<figcaption class="c_3g8gh b_pvw5a" data-id="{{doc._id}}">
|
<figcaption class="c_3g8gh b_pvw5a" data-id="{{doc._id}}">
|
||||||
{% if doc.status == 'Ожидает отправки' %}
|
{% if (doc.status or '') == 'Ожидает отправки' %}
|
||||||
<div class="b_thlt1">
|
<div class="b_thlt1">
|
||||||
<span class="b_5dr47">{{doc.status}}</span>
|
<span class="b_5dr47">{{(doc.status or '')}}</span>
|
||||||
</div>
|
</div>
|
||||||
{% elif doc.status == 'Получено' %}
|
{% elif 'Получено' in (doc.status or '') %}
|
||||||
<div class="b_thlt1">
|
<div class="b_thlt1">
|
||||||
<span class="b_5dr47">{{doc.status}}</span>
|
<span class="b_5dr47">{{(doc.status or '')}}</span>
|
||||||
</div>
|
</div>
|
||||||
{% elif doc.status == 'Ожидает получения' %}
|
{% elif (doc.status or '') == 'Ожидает получения' %}
|
||||||
<div class="b_thlt1_2">
|
<div class="b_thlt1_2">
|
||||||
<span class="b_5dr47_2">{{doc.status}}</span>
|
<span class="b_5dr47_2">{{(doc.status or '')}}</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="b_thlt1_2">
|
<div class="b_thlt1_2">
|
||||||
<span class="b_5dr47_2">{{doc.status}}</span>
|
<span class="b_5dr47_2">{{(doc.status or '')}}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="b_6spaf" href="/profile/delivery/{{doc.post_id}}#main">Посмотреть историю</a>
|
<a class="b_6spaf" href="/profile/delivery/{{doc.post_id}}#main">Посмотреть историю</a>
|
||||||
@ -790,7 +790,7 @@ picture > img {
|
|||||||
}
|
}
|
||||||
.b_kbsvf {
|
.b_kbsvf {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@ -173,7 +173,7 @@ picture > img {
|
|||||||
}
|
}
|
||||||
.b_kbsvf {
|
.b_kbsvf {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@ -75,7 +75,7 @@ picture>img{ width: 100%; object-fit: scale-down; }
|
|||||||
.b_rhbo8 { color: #5F1798; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
.b_rhbo8 { color: #5F1798; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
.b_tbfd3 { color: rgba(58, 58, 58, 0.75); font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; text-align: right; }
|
.b_tbfd3 { color: rgba(58, 58, 58, 0.75); font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; text-align: right; }
|
||||||
.b_gagrr { align-items: center; align-self: stretch; display: flex; justify-content: space-between; }
|
.b_gagrr { align-items: center; align-self: stretch; display: flex; justify-content: space-between; }
|
||||||
.b_kbsvf { align-items: flex-start; align-self: stretch; display: flex; flex-direction: column; gap: 10px; justify-content: center; }
|
.b_kbsvf { align-items: center; align-self: stretch; display: flex; flex-direction: column; gap: 10px; justify-content: center; }
|
||||||
.b_h57rq { align-items: flex-start; background: #FDFAFF; border-radius: 5px; border: 1px solid #5F1798; box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.25), 0 4px 20px 0 rgba(0, 0, 0, 0.20); display: flex; flex-direction: column; width: 335px; }
|
.b_h57rq { align-items: flex-start; background: #FDFAFF; border-radius: 5px; border: 1px solid #5F1798; box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.25), 0 4px 20px 0 rgba(0, 0, 0, 0.20); display: flex; flex-direction: column; width: 335px; }
|
||||||
.c_jy9j2{padding: 10px 20px; height: 226px;}
|
.c_jy9j2{padding: 10px 20px; height: 226px;}
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
|
|||||||
@ -148,7 +148,7 @@
|
|||||||
<div class="b_1fvkt">
|
<div class="b_1fvkt">
|
||||||
<picture>
|
<picture>
|
||||||
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}"
|
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}"
|
||||||
class="b_1fvkt" src="/b/posts/{{ post.medias[0] }}"
|
class="b_1fvkt" src="/b/posts/{{ post.medias_original[0] }}/500/90/80"
|
||||||
data-src="/b/posts/{{ post.medias_original[0] }}">
|
data-src="/b/posts/{{ post.medias_original[0] }}">
|
||||||
</picture>
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
@ -206,7 +206,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
document.querySelectorAll('.c_6cezu.b_6spaf').forEach(toggle => {
|
document.querySelectorAll('.c_6cezu.b_6spaf').forEach(toggle => {
|
||||||
toggle.addEventListener('click', () => {
|
toggle.addEventListener('click', () => {
|
||||||
const parent = toggle.parentElement; // ваш parent
|
const parent = toggle.parentElement; // ваш parent
|
||||||
|
|||||||
@ -176,7 +176,7 @@ failBtn.addEventListener('click', () => selectMember('fail'));
|
|||||||
<div class="b_1fvkt">
|
<div class="b_1fvkt">
|
||||||
<picture>
|
<picture>
|
||||||
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1fvkt"
|
<img crossorigin="anonymous" fetchpriority="high" loading="eager" alt="{{ post.title }}" class="b_1fvkt"
|
||||||
src="/b/posts/{{ post.medias[0] }}" data-src="/b/posts/{{ post.medias_original[0] }}">
|
src="/b/posts/{{ post.medias_original[0] }}/500/90/80" data-src="/b/posts/{{ post.medias_original[0] }}">
|
||||||
</picture>
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
<figcaption class="c_3g8gh b_pvw5a">
|
<figcaption class="c_3g8gh b_pvw5a">
|
||||||
@ -208,7 +208,8 @@ failBtn.addEventListener('click', () => selectMember('fail'));
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
document.querySelectorAll('.c_6cezu.b_6spaf').forEach(toggle => {
|
document.querySelectorAll('.c_6cezu.b_6spaf').forEach(toggle => {
|
||||||
toggle.addEventListener('click', () => {
|
toggle.addEventListener('click', () => {
|
||||||
const parent = toggle.parentElement; // ваш parent
|
const parent = toggle.parentElement; // ваш parent
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
{% import "competition/list.html" as competition %}
|
{% import "competition/list.html" as competition %}
|
||||||
{% import "components/label.html" as label %}
|
{% import "components/label.html" as label %}
|
||||||
|
|
||||||
{% block title %}Каталог картин{% endblock %}
|
{% block title %}Каталог картин | Выставка {{exhibition.title}}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ header.section(user) }}
|
{{ header.section(user) }}
|
||||||
@ -17,7 +17,7 @@
|
|||||||
{{ profile_nav.profile_nav(user, 'exhibitions') }}
|
{{ profile_nav.profile_nav(user, 'exhibitions') }}
|
||||||
<div class="c_3ihvi" id="main">
|
<div class="c_3ihvi" id="main">
|
||||||
<a class="c_ctfod b_289i1 ba" href="/profile/partner/exhibition/{{exhibition._id}}#main">
|
<a class="c_ctfod b_289i1 ba" href="/profile/partner/exhibition/{{exhibition._id}}#main">
|
||||||
<span class="c_d2l0b b_al8e7">Отобранные картины</span>
|
<span class="c_d2l0b b_al8e7">Отобранные картины (<span id="lenPosts2">{{ exhibition.posts | length }}</span>)</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="c_tihtk">
|
<div class="c_tihtk">
|
||||||
@ -88,7 +88,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
const galerists = [
|
const galerists = [
|
||||||
{% for p in galerists %}
|
{% for p in galerists %}
|
||||||
{
|
{
|
||||||
@ -100,7 +101,8 @@
|
|||||||
document.getElementById('filter-galerists').dataset.list=JSON.stringify(galerists)
|
document.getElementById('filter-galerists').dataset.list=JSON.stringify(galerists)
|
||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
<script defer>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
const living_places = [
|
const living_places = [
|
||||||
{% for p in living_places %}
|
{% for p in living_places %}
|
||||||
{
|
{
|
||||||
@ -112,7 +114,8 @@ document.getElementById('filter-galerists').dataset.list=JSON.stringify(galerist
|
|||||||
document.getElementById('filter-city').dataset.list=JSON.stringify(living_places)
|
document.getElementById('filter-city').dataset.list=JSON.stringify(living_places)
|
||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
<script async>
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const genres_count = {{genres|tojson}};
|
const genres_count = {{genres|tojson}};
|
||||||
let genres = [
|
let genres = [
|
||||||
@ -473,6 +476,8 @@ genre = genres.map(g => {
|
|||||||
// Обновляем счётчик
|
// Обновляем счётчик
|
||||||
const lenEl = document.getElementById('lenPosts');
|
const lenEl = document.getElementById('lenPosts');
|
||||||
if (lenEl) lenEl.textContent = exhibition.posts.length;
|
if (lenEl) lenEl.textContent = exhibition.posts.length;
|
||||||
|
const lenEl2 = document.getElementById('lenPosts2');
|
||||||
|
if (lenEl2) lenEl2.textContent = exhibition.posts.length;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
el.checked = !isAdding;
|
el.checked = !isAdding;
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
{% import "components/label.html" as label %}
|
{% import "components/label.html" as label %}
|
||||||
{% import "/components/contacts.html" as contacts %}
|
{% import "/components/contacts.html" as contacts %}
|
||||||
|
|
||||||
{% block title %}Отобранные картины | Выставка {{exhibition.title}}{% endblock %}
|
{% block title %}Отправить приглашения | Выставка {{exhibition.title}}{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@ -16,40 +16,386 @@
|
|||||||
<div class="c_ru432">
|
<div class="c_ru432">
|
||||||
{{ profile_nav.profile_nav(user, 'password') }}
|
{{ profile_nav.profile_nav(user, 'password') }}
|
||||||
<div class="c_tihtk" id="main">
|
<div class="c_tihtk" id="main">
|
||||||
|
<section class="c_sbrxk">
|
||||||
|
<div class="c_dtjoh">
|
||||||
|
<h2 style="text-align: center; margin-bottom: 24px; color: #5F1798;">Придумайте новый пароль</h2>
|
||||||
|
|
||||||
|
<div class="c_aihm6 floating-group">
|
||||||
|
<input type="password" id="new_password" class="c_xhd5k" required minlength="6">
|
||||||
|
<label for="new_password" class="c_z3ybu floating-label">Новый пароль</label>
|
||||||
|
<div type="button" class="password-toggle" aria-label="Toggle password visibility">
|
||||||
|
<svg class="eye eye-open" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15.1875 14.6875C16.0625 13.8125 16.5 12.75 16.5 11.5C16.5 10.25 16.0625 9.1875 15.1875 8.3125C14.3125 7.4375 13.25 7 12 7C10.75 7 9.6875 7.4375 8.8125 8.3125C7.9375 9.1875 7.5 10.25 7.5 11.5C7.5 12.75 7.9375 13.8125 8.8125 14.6875C9.6875 15.5625 10.75 16 12 16C13.25 16 14.3125 15.5625 15.1875 14.6875ZM10.0875 13.4125C9.5625 12.8875 9.3 12.25 9.3 11.5C9.3 10.75 9.5625 10.1125 10.0875 9.5875C10.6125 9.0625 11.25 8.8 12 8.8C12.75 8.8 13.3875 9.0625 13.9125 9.5875C14.4375 10.1125 14.7 10.75 14.7 11.5C14.7 12.25 14.4375 12.8875 13.9125 13.4125C13.3875 13.9375 12.75 14.2 12 14.2C11.25 14.2 10.6125 13.9375 10.0875 13.4125ZM5.35 16.9625C3.35 15.6042 1.9 13.7833 1 11.5C1.9 9.21667 3.35 7.39583 5.35 6.0375C7.35 4.67917 9.56667 4 12 4C14.4333 4 16.65 4.67917 18.65 6.0375C20.65 7.39583 22.1 9.21667 23 11.5C22.1 13.7833 20.65 15.6042 18.65 16.9625C16.65 18.3208 14.4333 19 12 19C9.56667 19 7.35 18.3208 5.35 16.9625ZM17.1875 15.5125C18.7625 14.5208 19.9667 13.1833 20.8 11.5C19.9667 9.81667 18.7625 8.47917 17.1875 7.4875C15.6125 6.49583 13.8833 6 12 6C10.1167 6 8.3875 6.49583 6.8125 7.4875C5.2375 8.47917 4.03333 9.81667 3.2 11.5C4.03333 13.1833 5.2375 14.5208 6.8125 15.5125C8.3875 16.5042 10.1167 17 12 17C13.8833 17 15.6125 16.5042 17.1875 15.5125Z" fill="#5F1798"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="eye eye-closed" style="display:none" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5F1798">
|
||||||
|
<path d="m644-428-58-58q9-47-27-88t-93-32l-58-58q17-8 34.5-12t37.5-4q75 0 127.5 52.5T660-500q0 20-4 37.5T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c_aihm6 floating-group password-group" style="margin-bottom: 20px;">
|
||||||
|
<input type="password" id="confirm_password" class="c_xhd5k" required minlength="6">
|
||||||
|
<label for="confirm_password" class="c_z3ybu floating-label">Подтвердите пароль</label>
|
||||||
|
<div type="button" class="password-toggle" aria-label="Toggle password visibility">
|
||||||
|
<svg class="eye eye-open" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15.1875 14.6875C16.0625 13.8125 16.5 12.75 16.5 11.5C16.5 10.25 16.0625 9.1875 15.1875 8.3125C14.3125 7.4375 13.25 7 12 7C10.75 7 9.6875 7.4375 8.8125 8.3125C7.9375 9.1875 7.5 10.25 7.5 11.5C7.5 12.75 7.9375 13.8125 8.8125 14.6875C9.6875 15.5625 10.75 16 12 16C13.25 16 14.3125 15.5625 15.1875 14.6875ZM10.0875 13.4125C9.5625 12.8875 9.3 12.25 9.3 11.5C9.3 10.75 9.5625 10.1125 10.0875 9.5875C10.6125 9.0625 11.25 8.8 12 8.8C12.75 8.8 13.3875 9.0625 13.9125 9.5875C14.4375 10.1125 14.7 10.75 14.7 11.5C14.7 12.25 14.4375 12.8875 13.9125 13.4125C13.3875 13.9375 12.75 14.2 12 14.2C11.25 14.2 10.6125 13.9375 10.0875 13.4125ZM5.35 16.9625C3.35 15.6042 1.9 13.7833 1 11.5C1.9 9.21667 3.35 7.39583 5.35 6.0375C7.35 4.67917 9.56667 4 12 4C14.4333 4 16.65 4.67917 18.65 6.0375C20.65 7.39583 22.1 9.21667 23 11.5C22.1 13.7833 20.65 15.6042 18.65 16.9625C16.65 18.3208 14.4333 19 12 19C9.56667 19 7.35 18.3208 5.35 16.9625ZM17.1875 15.5125C18.7625 14.5208 19.9667 13.1833 20.8 11.5C19.9667 9.81667 18.7625 8.47917 17.1875 7.4875C15.6125 6.49583 13.8833 6 12 6C10.1167 6 8.3875 6.49583 6.8125 7.4875C5.2375 8.47917 4.03333 9.81667 3.2 11.5C4.03333 13.1833 5.2375 14.5208 6.8125 15.5125C8.3875 16.5042 10.1167 17 12 17C13.8833 17 15.6125 16.5042 17.1875 15.5125Z" fill="#5F1798"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="eye eye-closed" style="display:none" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5F1798">
|
||||||
|
<path d="m644-428-58-58q9-47-27-88t-93-32l-58-58q17-8 34.5-12t37.5-4q75 0 127.5 52.5T660-500q0 20-4 37.5T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="c_aq9et" id="resetBtn" style="margin-bottom: 16px;">
|
||||||
|
<span class="c_som9s">Сменить пароль</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
|
async function getSHA256Hash(message) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const msgBuffer = encoder.encode(message);
|
||||||
|
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
||||||
|
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
|
||||||
|
const hashHex = hashArray
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return hashHex;
|
||||||
|
}
|
||||||
|
// @license-end
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
|
// Password toggle functionality (same as login)
|
||||||
|
document.querySelectorAll('.password-toggle').forEach(toggle => {
|
||||||
|
const input = toggle.parentElement.querySelector('input');
|
||||||
|
const eyeOpen = toggle.querySelector('.eye-open');
|
||||||
|
const eyeClosed = toggle.querySelector('.eye-closed');
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
if (input.type === 'password') {
|
||||||
|
input.type = 'text';
|
||||||
|
eyeOpen.style.display = 'none';
|
||||||
|
eyeClosed.style.display = 'inline';
|
||||||
|
} else {
|
||||||
|
input.type = 'password';
|
||||||
|
eyeOpen.style.display = 'inline';
|
||||||
|
eyeClosed.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form submit
|
||||||
|
document.getElementById('resetBtn').addEventListener('click', async () => {
|
||||||
|
const newPass = document.getElementById('new_password').value;
|
||||||
|
const confirmPass = document.getElementById('confirm_password').value;
|
||||||
|
|
||||||
|
if (newPass.length < 6) {
|
||||||
|
showToast({ message: "Пароль должен быть не менее 6 символов", status: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPass !== confirmPass) {
|
||||||
|
showToast({ message: "Пароли не совпадают", status: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hash = await getSHA256Hash(newPass);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/reset-hash-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
new_password: hash,
|
||||||
|
token: 'access_token'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast({ message: "Пароль успешно изменён!", status: 'success' });
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/login?next=${data.next}`;
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
showToast({ message: data.detail || "Ошибка при смене пароля", status: 'error' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast({ message: "Ошибка соединения с сервером", status: 'error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// @license-end
|
||||||
|
</script>
|
||||||
{{ footer.section(user) }}
|
{{ footer.section(user) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ header.style() }}
|
{{ header.style() }}
|
||||||
{{ profile_nav.style() }}
|
{{ profile_nav.style() }}
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
@media (max-width: 699px) {
|
@media (max-width: 699px) {
|
||||||
.c_mrl59 { align-items: center; background: #5F1798; border-radius: 25px; bottom: -15px; display: flex; height: 50px; justify-content: center; position: absolute; right: -15px; width: 50px; }
|
.c_mrl59 { align-items: center; background: #5F1798; border-radius: 25px; bottom: -15px; display: flex; height: 50px; justify-content: center; position: absolute; right: -15px; width: 50px; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet */
|
/* Tablet */
|
||||||
@media (min-width: 700px) and (max-width: 900px) {
|
@media (min-width: 700px) and (max-width: 900px) {
|
||||||
.c_iiehk { align-items: center; border-radius: 5px; display: flex; flex-direction: column; gap: 20px; width: 335px; }
|
.c_iiehk { align-items: center; border-radius: 5px; display: flex; flex-direction: column; gap: 20px; width: 335px; }
|
||||||
.c_nnsh6>a>img{display: none;}
|
.c_nnsh6>a>img{display: none;}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop */
|
/* Desktop */
|
||||||
@media (min-width: 901px) {
|
@media (min-width: 901px) {
|
||||||
.c_iiehk { align-items: flex-end; border-radius: 5px; display: flex; flex-direction: column; gap: 20px; width: 310px; }
|
.c_iiehk { align-items: flex-end; border-radius: 5px; display: flex; flex-direction: column; gap: 20px; width: 310px; }
|
||||||
.c_nnsh6>a>img{display: none;}
|
.c_nnsh6>a>img{display: none;}
|
||||||
|
|
||||||
}</style>
|
}</style>
|
||||||
|
<style>
|
||||||
|
.poster-group {position: relative;}
|
||||||
|
.password-group { position: relative; }
|
||||||
|
a{color: #5F1798;}
|
||||||
|
.password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* remove default button outline but keep focus visible */
|
||||||
|
.password-toggle:focus { outline: 2px solid rgba(95,23,152,0.25); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ensure svg size fits */
|
||||||
|
.password-toggle .eye { width: 24px; height: 24px; display: block; }
|
||||||
|
|
||||||
|
.file-name-display {width: 100%;padding: 16px 50px 16px 16px;border: 2px solid #ddd;border-radius: 8px;font-size: 16px;color: #555;background: #fff;min-height: 56px;display: flex;align-items: center;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;transition: all 0.3s ease;cursor: pointer;}
|
||||||
|
|
||||||
|
/* Hover эффект */
|
||||||
|
.c_aihm6:hover input, .c_nb6gy:hover textarea, .poster-group:hover .file-name-display {border-color: #5F1798;}
|
||||||
|
|
||||||
|
/* Когда файл выбран — делаем текст чуть темнее */
|
||||||
|
.poster-group.has-file .file-name-display {color: #222;font-weight: 500;}
|
||||||
|
|
||||||
|
/* Скрытый input file */
|
||||||
|
.hidden-file {position: absolute;inset: 0;opacity: 0;cursor: pointer;z-index: 2;}
|
||||||
|
|
||||||
|
/* Плавающий лейбл */
|
||||||
|
.poster-group .floating-label {position: absolute;left: 16px;top: 16px;font-size: 16px;color: #666;background: #fff;padding: 0 6px;z-index: 3;transition: all 0.3s ease;pointer-events: none;}
|
||||||
|
|
||||||
|
/* Когда лейбл должен подняться */
|
||||||
|
.poster-group input:focus + .floating-label,
|
||||||
|
.poster-group.has-file .floating-label {top: -8px;font-size: 13px;color: #5F1798;}
|
||||||
|
|
||||||
|
/* Иконка справа */
|
||||||
|
.file-icon {position: absolute;right: 16px;top: 50%;transform: translateY(-50%);z-index: 3;pointer-events: none;}
|
||||||
|
|
||||||
|
/* Красная ошибка */
|
||||||
|
.poster-group.error .file-name-display {border-color: #e74c3c !important;box-shadow: 0 0 0 3px rgba(231, 76, 60, 0.15);}
|
||||||
|
|
||||||
|
.poster-group.error .floating-label {color: #e74c3c !important;}
|
||||||
|
/* ====================== FLOATING LABELS ====================== */
|
||||||
|
.floating-group {position: relative;margin-bottom: 1.5rem;}
|
||||||
|
|
||||||
|
.floating-label {position: absolute;left: 16px;top: 16px;font-size: 16px;color: #666;transition: all 0.3s ease;pointer-events: none;background: #fff;padding: 0 6px;z-index: 2;}
|
||||||
|
|
||||||
|
/* Плавающий лейбл */
|
||||||
|
.floating-group input:focus + .floating-label, .floating-group textarea:focus + .floating-label,
|
||||||
|
.floating-group input:not(:placeholder-shown) + .floating-label, .floating-group textarea:not(:placeholder-shown) + .floating-label {top: -8px;font-size: 15px;color: #5F1798;}
|
||||||
|
|
||||||
|
/* Красная подсветка — ТОЛЬКО после попытки отправки */
|
||||||
|
.floating-group.error input, .floating-group.error textarea,
|
||||||
|
.floating-group.error input[type="file"] {border-color: #e74c3c !important;box-shadow: 0 0 0 3px rgba(231, 76, 60, 0.38);}
|
||||||
|
|
||||||
|
.floating-group.error .floating-label {color: #e74c3c !important;}
|
||||||
|
|
||||||
|
/* Основные стили полей */
|
||||||
|
.c_xhd5k, .c_4ykpn {width: 100%;padding: 16px 16px;border: 2px solid #ddd;border-radius: 8px;font-size: 16px;transition: all 0.3s ease;background: #fff;}
|
||||||
|
|
||||||
|
.c_xhd5k:focus, .c_4ykpn:focus {border-color: #5F1798;outline: none;box-shadow: 0 0 0 3px rgba(95, 23, 152, 0.15);}
|
||||||
|
|
||||||
|
/* Файловые поля */
|
||||||
|
.file-group input[type="file"] {height: 43px;}
|
||||||
|
|
||||||
|
|
||||||
|
.file-group .floating-label {top: 12px;}
|
||||||
|
/* ====================== СКРЫТЬ СТРЕЛОЧКИ У INPUT TYPE=NUMBER ====================== */
|
||||||
|
|
||||||
|
/* Chrome, Edge, Safari, Opera */
|
||||||
|
input[type="number"]::-webkit-outer-spin-button,
|
||||||
|
input[type="number"]::-webkit-inner-spin-button {-webkit-appearance: none;margin: 0;display: none;}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
input[type="number"] {-moz-appearance: textfield;}
|
||||||
|
|
||||||
|
/* Дополнительно: чтобы стрелочки не появлялись при фокусе (на всякий случай) */
|
||||||
|
input[type="number"]:hover::-webkit-outer-spin-button,
|
||||||
|
input[type="number"]:focus::-webkit-outer-spin-button,
|
||||||
|
input[type="number"]:hover::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]:focus::-webkit-inner-spin-button {display: none;}
|
||||||
|
.c_xc07u {white-space: pre;}
|
||||||
|
.c_5qh93{cursor: pointer;}
|
||||||
|
.c_965ia{object-fit: cover;}
|
||||||
|
@media (min-width: 901px) {
|
||||||
|
.c_abq53 { color: #0ED646; font-family: Inter; font-size: 12px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_ydot9 { display: flex; padding: 1.5px 10px; justify-content: center; align-items: center; border-radius: 30px; background: rgba(14, 214, 70, 0.20); }
|
||||||
|
.c_w5koo { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; flex: 1 0 0; overflow: hidden; color: #000; text-overflow: ellipsis; font-family: Inter; font-size: 24px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_fudgr { display: flex; justify-content: center; align-items: center; align-self: stretch; }
|
||||||
|
.c_n5lj4 { align-self: stretch; color: #5F1798; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_bffhf { width: 50px; height: 50px; aspect-ratio: 1/1; border-radius: 50px; }
|
||||||
|
.c_sya8w { align-self: stretch; color: #000; font-family: Inter; font-size: 16px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_o5cc2 { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; flex: 1 0 0; }
|
||||||
|
.c_zxyme { display: flex; height: 50px; align-items: center; gap: 13px; align-self: stretch; }
|
||||||
|
.c_rq5iq { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; align-self: stretch; overflow: hidden; color: rgba(58, 58, 58, 0.75); text-overflow: ellipsis; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_sa311 { align-self: stretch; color: rgba(58, 58, 58, 0.75); text-align: right; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_yhst8 { display: flex; padding: 10px 20px; flex-direction: column; justify-content: center; align-items: flex-start; gap: 10px; align-self: stretch; border-radius: 10px; background: #FFF; }
|
||||||
|
.c_jz1u6 { display: flex; width: 335px; height: 418px; padding: 120px 20px 20px 20px; flex-direction: column; align-items: flex-start; border-radius: 5px; border: 1px solid #5F1798; box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.30), 0 4px 20px 0 rgba(0, 0, 0, 0.20); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 700px) and (max-width: 900px) {
|
||||||
|
.c_abq53 { color: #0ED646; font-family: Inter; font-size: 12px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_ydot9 { display: flex; padding: 1.5px 10px; justify-content: center; align-items: center; border-radius: 30px; background: rgba(14, 214, 70, 0.20); }
|
||||||
|
.c_w5koo { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; flex: 1 0 0; overflow: hidden; color: #000; text-overflow: ellipsis; font-family: Inter; font-size: 24px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_fudgr { display: flex; justify-content: center; align-items: center; align-self: stretch; }
|
||||||
|
.c_n5lj4 { align-self: stretch; color: #5F1798; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_bffhf { width: 50px; height: 50px; aspect-ratio: 1/1; border-radius: 50px; }
|
||||||
|
.c_sya8w { align-self: stretch; color: #000; font-family: Inter; font-size: 16px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_o5cc2 { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; flex: 1 0 0; }
|
||||||
|
.c_zxyme { display: flex; height: 50px; align-items: center; gap: 13px; align-self: stretch; }
|
||||||
|
.c_rq5iq { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; align-self: stretch; overflow: hidden; color: rgba(58, 58, 58, 0.75); text-overflow: ellipsis; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_sa311 { align-self: stretch; color: rgba(58, 58, 58, 0.75); text-align: right; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_yhst8 { display: flex; padding: 10px 20px; flex-direction: column; justify-content: center; align-items: flex-start; gap: 10px; align-self: stretch; border-radius: 10px; background: #FFF; }
|
||||||
|
.c_jz1u6 { display: flex; width: calc(50% - 10px); height: 418px; padding: 120px 20px 20px 20px; flex-direction: column; align-items: flex-start; flex-shrink: 0; border-radius: 5px; border: 1px solid #5F1798; box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.30), 0 4px 20px 0 rgba(0, 0, 0, 0.20); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 699px) {
|
||||||
|
.c_abq53 { color: #0ED646; font-family: Inter; font-size: 12px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_ydot9 { display: flex; padding: 1.5px 10px; justify-content: center; align-items: center; border-radius: 30px; background: rgba(14, 214, 70, 0.20); }
|
||||||
|
.c_w5koo { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; flex: 1 0 0; overflow: hidden; color: #000; text-overflow: ellipsis; font-family: Inter; font-size: 24px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_fudgr { display: flex; justify-content: center; align-items: center; align-self: stretch; }
|
||||||
|
.c_n5lj4 { align-self: stretch; color: #5F1798; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_bffhf { width: 50px; height: 50px; aspect-ratio: 1/1; border-radius: 50px; }
|
||||||
|
.c_sya8w { align-self: stretch; color: #000; font-family: Inter; font-size: 16px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_o5cc2 { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; flex: 1 0 0; }
|
||||||
|
.c_zxyme { display: flex; height: 50px; align-items: center; gap: 13px; align-self: stretch; }
|
||||||
|
.c_rq5iq { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; align-self: stretch; overflow: hidden; color: rgba(58, 58, 58, 0.75); text-overflow: ellipsis; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_sa311 { align-self: stretch; color: rgba(58, 58, 58, 0.75); text-align: right; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_yhst8 { display: flex; padding: 10px 20px; flex-direction: column; justify-content: center; align-items: flex-start; gap: 10px; align-self: stretch; border-radius: 10px; background: #FFF; }
|
||||||
|
.c_jz1u6 { display: flex; width: 335px; padding: 120px 20px 20px 20px; flex-direction: column; align-items: flex-start; border-radius: 5px; border: 1px solid #5F1798; box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.30), 0 4px 20px 0 rgba(0, 0, 0, 0.20); }
|
||||||
|
}
|
||||||
|
@media (min-width: 901px) {
|
||||||
|
.c_xhd5k { display: flex; height: 42px; padding: 5px 10px; align-items: center; flex: 1 0 0; }
|
||||||
|
.c_z3ybu { position: absolute; left: 13px; top: 12px; color: #000; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_vphtp { width: 18px; height: 18px; fill: #5F1798; }
|
||||||
|
.c_ffjwp { width: 24px; height: 24px; flex-shrink: 0; }
|
||||||
|
.c_aihm6 { display: flex; width: 323px; justify-content: center; align-items: center; border-radius: 5px; background: #FFF; }
|
||||||
|
.c_4ykpn { display: flex; height: 104px; padding: 10px 10px; align-items: center; flex: 1 0 0; }
|
||||||
|
.c_nb6gy { display: flex; width: 323px; justify-content: center; align-items: center; border-radius: 5px; background: #FFF; }
|
||||||
|
.c_q00x6 { min-width: 230px; flex: 1 0 0; color: #000; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_mkcow { color: #000; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_uklyi { width: 20px; color: #000; text-align: center; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_l8kil {display: flex;padding: 5px 00px;align-items: center;border-radius: 5px;border: 1px solid rgba(95, 23, 152, 0.20);background: #FDFAFF;width: 35px;text-align: center;}
|
||||||
|
.c_5qh93 { display: flex; height: 7px; justify-content: center; align-items: center; flex: 1 0 0; border-radius: 5px; background: rgba(95, 23, 152, 0.20); }
|
||||||
|
.c_jups4 { display: flex; width: 323px; padding: 10px; align-items: center; align-content: center; gap: 10px; flex-wrap: wrap; border-radius: 5px; background: #FFF; border: 1px solid #5F1798; }
|
||||||
|
.c_som9s { color: #FFF; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_aq9et { display: flex; padding: 19px 33px; justify-content: center; align-items: center; align-self: stretch; border-radius: 5px; background: #5F1798; }
|
||||||
|
.c_dtjoh { display: flex; width: 323px; flex-direction: column; align-items: flex-start; gap: 12px; flex-shrink: 0; }
|
||||||
|
.c_1gai0 { align-self: stretch; color: #000; text-align: center; font-family: Inter; font-size: 24px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_med8n { color: #5F1798; font-family: Inter; font-size: 12px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_g8o3y { display: flex; padding: 1.5px 10px; justify-content: center; align-items: center; border-radius: 30px; background: rgba(95, 23, 152, 0.20); }
|
||||||
|
.c_1pdx0 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; flex: 1 0 0; overflow: hidden; color: #000; text-overflow: ellipsis; font-family: Inter; font-size: 24px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_r5tzr { display: flex; justify-content: center; align-items: center; align-self: stretch; }
|
||||||
|
.c_965ia { width: 50px; height: 50px; aspect-ratio: 1/1; border-radius: 50px; }
|
||||||
|
.c_t9q2z { width: 189px; color: #000; font-family: Inter; font-size: 16px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_zruk7 { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }
|
||||||
|
.c_k3nyf { display: flex; height: 50px; align-items: center; gap: 13px; align-self: stretch; }
|
||||||
|
.c_xc07u { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; align-self: stretch; overflow: hidden; color: rgba(58, 58, 58, 0.75); text-overflow: ellipsis; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_hni7n { align-self: stretch; color: #5F1798; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_qfcvm { align-self: stretch; color: rgba(58, 58, 58, 0.75); text-align: right; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_rtopc { display: flex; width: 355px; flex-direction: column; justify-content: center; align-items: flex-start; gap: 20px; flex-shrink: 0; }
|
||||||
|
.c_sbrxk { padding: 50px 0px 100px; display: flex; box-sizing: unset; justify-content: center; align-items: flex-start; align-content: flex-start; gap: 200px; flex-wrap: wrap; background: #FFF; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 700px) and (max-width: 900px) {
|
||||||
|
.c_xhd5k { display: flex; height: 42px; padding: 5px 10px; align-items: center; flex: 1 0 0; }
|
||||||
|
.c_z3ybu { position: absolute; left: 13px; top: 12px; color: #000; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_vphtp { width: 18px; height: 18px; fill: #5F1798; }
|
||||||
|
.c_ffjwp { width: 24px; height: 24px; flex-shrink: 0; }
|
||||||
|
.c_aihm6 { display: flex; width: 323px; justify-content: center; align-items: center; border-radius: 5px; background: #FFF; }
|
||||||
|
.c_4ykpn { display: flex; height: 104px; padding: 10px 10px; align-items: center; flex: 1 0 0; }
|
||||||
|
.c_nb6gy { display: flex; width: 323px; justify-content: center; align-items: center; border-radius: 5px; background: #FFF; }
|
||||||
|
.c_q00x6 { min-width: 230px; flex: 1 0 0; color: #000; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_mkcow { color: #000; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_uklyi { width: 20px; color: #000; text-align: center; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_l8kil {display: flex;padding: 5px 00px;align-items: center;border-radius: 5px;border: 1px solid rgba(95, 23, 152, 0.20);background: #FDFAFF;width: 35px;text-align: center;}
|
||||||
|
.c_5qh93 { display: flex; height: 7px; justify-content: center; align-items: center; flex: 1 0 0; border-radius: 5px; background: rgba(95, 23, 152, 0.20); }
|
||||||
|
.c_jups4 { display: flex; width: 323px; padding: 10px; align-items: center; align-content: center; gap: 10px; flex-wrap: wrap; border-radius: 5px; background: #FFF; border: 1px solid #5F1798; }
|
||||||
|
.c_som9s { color: #FFF; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_aq9et { display: flex; padding: 19px 33px; justify-content: center; align-items: center; align-self: stretch; border-radius: 5px; background: #5F1798; }
|
||||||
|
.c_dtjoh { display: flex; width: 323px; flex-direction: column; align-items: flex-start; gap: 12px; flex-shrink: 0; }
|
||||||
|
.c_1gai0 { align-self: stretch; color: #000; text-align: center; font-family: Inter; font-size: 24px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_med8n { color: #5F1798; font-family: Inter; font-size: 12px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_g8o3y { display: flex; padding: 1.5px 10px; justify-content: center; align-items: center; border-radius: 30px; background: rgba(95, 23, 152, 0.20); }
|
||||||
|
.c_1pdx0 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; flex: 1 0 0; overflow: hidden; color: #000; text-overflow: ellipsis; font-family: Inter; font-size: 24px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_r5tzr { display: flex; justify-content: center; align-items: center; align-self: stretch; }
|
||||||
|
.c_965ia { width: 50px; height: 50px; aspect-ratio: 1/1; border-radius: 50px; }
|
||||||
|
.c_t9q2z { width: 189px; color: #000; font-family: Inter; font-size: 16px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_zruk7 { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }
|
||||||
|
.c_k3nyf { display: flex; height: 50px; align-items: center; gap: 13px; align-self: stretch; }
|
||||||
|
.c_xc07u { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; align-self: stretch; overflow: hidden; color: rgba(58, 58, 58, 0.75); text-overflow: ellipsis; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_hni7n { align-self: stretch; color: #5F1798; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_qfcvm { align-self: stretch; color: rgba(58, 58, 58, 0.75); text-align: right; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_rtopc { display: flex; width: 355px; flex-direction: column; justify-content: center; align-items: flex-start; gap: 20px; flex-shrink: 0; }
|
||||||
|
.c_sbrxk { padding: 50px 0px 100px; display: flex; box-sizing: unset; justify-content: center; align-items: flex-start; gap: 50px; background: #FFF; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 699px) {
|
||||||
|
.c_xhd5k { display: flex; height: 42px; padding: 5px 10px; align-items: center; flex: 1 0 0; }
|
||||||
|
.c_z3ybu { position: absolute; left: 13px; top: 12px; color: #000; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_vphtp { width: 18px; height: 18px; fill: #5F1798; }
|
||||||
|
.c_ffjwp { width: 24px; height: 24px; }
|
||||||
|
.c_aihm6 { display: flex; justify-content: center; align-items: center; align-self: stretch; border-radius: 5px; background: #FFF; }
|
||||||
|
.c_4ykpn { display: flex; height: 104px; padding: 10px 10px; align-items: center; flex: 1 0 0; }
|
||||||
|
.c_nb6gy { display: flex; justify-content: center; align-items: center; align-self: stretch; border-radius: 5px; background: #FFF; }
|
||||||
|
.c_q00x6 { min-width: 230px; flex: 1 0 0; color: #000; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_mkcow { color: #000; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_uklyi { width: 20px; color: #000; text-align: center; font-family: Inter; font-size: 15px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_l8kil {display: flex;padding: 5px 00px;align-items: center;border-radius: 5px;border: 1px solid rgba(95, 23, 152, 0.20);background: #FDFAFF;width: 35px;text-align: center;}
|
||||||
|
.c_5qh93 { height: 7px; flex: 1 0 0; fill: rgba(95, 23, 152, 0.20); background: rgba(95, 23, 152, 0.20);}
|
||||||
|
.c_jups4 { display: flex; padding: 10px; align-items: center; align-content: center; gap: 10px; align-self: stretch; flex-wrap: wrap; border-radius: 5px; background: #FFF; border: 1px solid #5F1798; }
|
||||||
|
.c_som9s { color: #FFF; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_aq9et { display: flex; padding: 19px 33px; justify-content: center; align-items: center; align-self: stretch; border-radius: 5px; background: #5F1798; }
|
||||||
|
.c_dtjoh { display: flex; width: 323px; flex-direction: column; align-items: flex-start; gap: 12px; flex-shrink: 0; }
|
||||||
|
.c_1gai0 { align-self: stretch; color: #000; text-align: center; font-family: Inter; font-size: 24px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_med8n { color: #5F1798; font-family: Inter; font-size: 12px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_g8o3y { display: flex; padding: 1.5px 10px; justify-content: center; align-items: center; border-radius: 30px; background: rgba(95, 23, 152, 0.20); }
|
||||||
|
.c_1pdx0 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; flex: 1 0 0; overflow: hidden; color: #000; text-overflow: ellipsis; font-family: Inter; font-size: 24px; font-style: normal; font-weight: 700; line-height: normal; }
|
||||||
|
.c_r5tzr { display: flex; justify-content: center; align-items: center; align-self: stretch; }
|
||||||
|
.c_965ia { width: 50px; height: 50px; aspect-ratio: 1/1; border-radius: 50px; }
|
||||||
|
.c_t9q2z { width: 189px; color: #000; font-family: Inter; font-size: 16px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_zruk7 { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }
|
||||||
|
.c_k3nyf { display: flex; height: 50px; align-items: center; gap: 13px; align-self: stretch; }
|
||||||
|
.c_xc07u { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; align-self: stretch; overflow: hidden; color: rgba(58, 58, 58, 0.75); text-overflow: ellipsis; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_hni7n { align-self: stretch; color: #5F1798; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_qfcvm { align-self: stretch; color: rgba(58, 58, 58, 0.75); text-align: right; font-family: Inter; font-size: 14px; font-style: normal; font-weight: 400; line-height: normal; }
|
||||||
|
.c_rtopc { display: flex; width: 335px; flex-direction: column; justify-content: center; align-items: flex-start; gap: 20px; flex-shrink: 0; }
|
||||||
|
.c_sbrxk { padding: 50px 0px 100px; display: flex; box-sizing: unset; justify-content: center; align-items: flex-start; align-content: flex-start; gap: 50px 0; flex-wrap: wrap; background: #FFF; }
|
||||||
|
}
|
||||||
|
.c_sbrxk{z-index: 1000;}
|
||||||
|
#preview_poster{width: 335px;}
|
||||||
|
</style>
|
||||||
{{ footer.style() }}
|
{{ footer.style() }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -143,6 +143,24 @@ document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelector("
|
|||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
|
async function getSHA256Hash(message) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const msgBuffer = encoder.encode(message);
|
||||||
|
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
||||||
|
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
|
||||||
|
const hashHex = hashArray
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return hashHex;
|
||||||
|
}
|
||||||
|
// @license-end
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
function showOfferModal(offerHtml = '<p>Текст оферты...</p>') {
|
function showOfferModal(offerHtml = '<p>Текст оферты...</p>') {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const backdrop = document.createElement('div');
|
const backdrop = document.createElement('div');
|
||||||
@ -284,11 +302,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// No client-side validation errors — send request
|
// No client-side validation errors — send request
|
||||||
try {
|
try {
|
||||||
|
const hash = await getSHA256Hash(document.getElementById('password').value);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('img', document.getElementById('avatar').files[0]);
|
formData.append('img', document.getElementById('avatar').files[0]);
|
||||||
formData.append('living_place', document.getElementById('living_place').value);
|
formData.append('living_place', document.getElementById('living_place').value);
|
||||||
formData.append('Female_Name_Lastame', document.getElementById('fio').value);
|
formData.append('Female_Name_Lastame', document.getElementById('fio').value);
|
||||||
formData.append('password', document.getElementById('password').value);
|
formData.append('password', hash);
|
||||||
formData.append('email', document.getElementById('email').value);
|
formData.append('email', document.getElementById('email').value);
|
||||||
formData.append('phone', document.getElementById('phone').value);
|
formData.append('phone', document.getElementById('phone').value);
|
||||||
|
|
||||||
|
|||||||
@ -49,7 +49,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<script>
|
||||||
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
|
async function getSHA256Hash(message) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const msgBuffer = encoder.encode(message);
|
||||||
|
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
||||||
|
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
|
||||||
|
const hashHex = hashArray
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return hashHex;
|
||||||
|
}
|
||||||
|
// @license-end
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
// Password toggle functionality (same as login)
|
// Password toggle functionality (same as login)
|
||||||
@ -85,14 +102,14 @@
|
|||||||
showToast({ message: "Пароли не совпадают", status: 'error' });
|
showToast({ message: "Пароли не совпадают", status: 'error' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const hash = await getSHA256Hash(newPass);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/reset-password', {
|
const res = await fetch('/api/reset-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
token: "{{ token }}",
|
token: "{{ token }}",
|
||||||
new_password: newPass
|
new_password: hash
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
{% import "/components/header.html" as header %}
|
{% import "/components/header.html" as header %}
|
||||||
{% import "/components/footer.html" as footer %}
|
{% import "/components/footer.html" as footer %}
|
||||||
{% import "posts/profile_card.html" as card %}
|
{% import "posts/profile_card.html" as card %}
|
||||||
|
{% import "/components/contacts.html" as contacts %}
|
||||||
|
|
||||||
|
|
||||||
{% block title %}Посты{% endblock %}
|
{% block title %}Посты{% endblock %}
|
||||||
@ -14,7 +15,6 @@
|
|||||||
<div class="c_gtoe9">
|
<div class="c_gtoe9">
|
||||||
<div class="c_iiehk">
|
<div class="c_iiehk">
|
||||||
<div class="c_8xkk0 b_2rmrs">
|
<div class="c_8xkk0 b_2rmrs">
|
||||||
|
|
||||||
<div class="b_hjteq">
|
<div class="b_hjteq">
|
||||||
<div class="b_zo7wz" for="avatar">
|
<div class="b_zo7wz" for="avatar">
|
||||||
<img class="b_rhmvx" src="/a/{{find._id}}" alt="{{find.username}}" id="preview" crossorigin="anonymous" fetchpriority="high" loading="eager">
|
<img class="b_rhmvx" src="/a/{{find._id}}" alt="{{find.username}}" id="preview" crossorigin="anonymous" fetchpriority="high" loading="eager">
|
||||||
@ -22,10 +22,35 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="c_kpaav b_xsvl1">{{find.username}}</p>
|
<p class="c_kpaav b_xsvl1">{{find.username}}</p>
|
||||||
{% if 'admin' in user.get('roles', []) %}
|
{% if 'admin' in user.get('roles', []) %}
|
||||||
<div class="c_ctfod b_289i1" id="switch-user">
|
<div class="c_ctfod b_289i1" id="switch-user">
|
||||||
<button class="c_d2l0b b_al8e7">Переключиться на пользователя</button>
|
<button class="c_d2l0b b_al8e7">Переключиться на пользователя</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'admin' in user.get('roles', []) or 'partner' in user.get('roles', []) %}
|
||||||
|
<!-- <div class="c_ctfod b_289i1"><span class="c_d2l0b b_al8e7">Написать пользователю</span></div> -->
|
||||||
|
{{ contacts.details(find) }}
|
||||||
|
{% endif %}
|
||||||
|
<div >На платформе с {{ (find._id.generation_time.strftime('%Y')) if find._id else ''}}</div>
|
||||||
|
{% if find.get('grade') is not none%}
|
||||||
|
<p class="it o">Звание:</p>
|
||||||
|
<p class="it">{{find.grade}}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if find.get('biography') is not none%}
|
||||||
|
<p class="it o">Биография:</p>
|
||||||
|
<p class="it">{{find.biography}}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>👁 {{total_watch_count}} (+{{today_watch_count}} Сегодня)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="c_ru432">
|
||||||
|
<div class="c_tihtk">
|
||||||
|
{% for i in cards %}
|
||||||
|
{{ card.section(i, loop.index, user) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<script>
|
<script>
|
||||||
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
const userId = '{{find._id}}';
|
const userId = '{{find._id}}';
|
||||||
@ -50,33 +75,6 @@ document.getElementById('switch-user').addEventListener('click', async () => {
|
|||||||
});
|
});
|
||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
|
||||||
{% if 'admin' in user.get('roles', []) %}
|
|
||||||
<div class="c_ctfod b_289i1"><span class="c_d2l0b b_al8e7">Написать пользователю</span></div>
|
|
||||||
{% endif %}
|
|
||||||
<div >На платформе с {{ (find._id.generation_time.strftime('%Y')) if find._id else ''}}</div>
|
|
||||||
{% if find.get('grade') is not none%}
|
|
||||||
<p class="it o">Звание:</p>
|
|
||||||
<p class="it">{{find.grade}}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if find.get('biography') is not none%}
|
|
||||||
<p class="it o">Биография:</p>
|
|
||||||
<p class="it">{{find.biography}}</p>
|
|
||||||
{% endif %}
|
|
||||||
<p>👁 {{total_watch_count}} (+{{today_watch_count}} Сегодня)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="c_ru432">
|
|
||||||
<div class="c_tihtk">
|
|
||||||
{% for i in cards %}
|
|
||||||
{{ card.section(i, loop.index, user) }}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<script>
|
<script>
|
||||||
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@ -101,6 +99,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
{% block head %}
|
{% block head %}
|
||||||
{{ header.style() }}
|
{{ header.style() }}
|
||||||
{{ card.style() }}
|
{{ card.style() }}
|
||||||
|
{{ contacts.style() }}
|
||||||
<style>
|
<style>
|
||||||
.o{font-style: italic;color: #777;}
|
.o{font-style: italic;color: #777;}
|
||||||
.it{padding: 0px 15px;}
|
.it{padding: 0px 15px;}
|
||||||
|
|||||||
59
utils.py
59
utils.py
@ -352,81 +352,72 @@ def is_safe_redirect(next_url: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def build_composite(data, scale: float = 1.5, new_w: int = 500, quality: int = 90, GaussianBlur_radius: int = 80):
|
async def build_composite(
|
||||||
src_img = Image.open(BytesIO(data)).convert('RGB')
|
data: bytes,
|
||||||
|
scale: float = 1.5,
|
||||||
|
new_w: int = 500,
|
||||||
|
quality: int = 90,
|
||||||
|
GaussianBlur_radius: int = 80,
|
||||||
|
) -> BytesIO:
|
||||||
|
src_img = Image.open(BytesIO(data)).convert("RGB")
|
||||||
|
|
||||||
# --------------------------------------------------------------
|
# --------------------------------------------------------------
|
||||||
# 2️⃣ Параметры конечного результата
|
# 2️⃣ Параметры конечного результата
|
||||||
# --------------------------------------------------------------
|
# --------------------------------------------------------------
|
||||||
new_h = int(new_w / scale) # высота для соотношения 3:2 (≈333)
|
new_h = int(new_w / scale) # высота для соотношения 3:2 (≈333)
|
||||||
canvas_sz = (new_w, new_h) # размер «фонового» холста
|
canvas_sz = (new_w, new_h)
|
||||||
|
|
||||||
# --------------------------------------------------------------
|
|
||||||
# 3️⃣ Фоновое изображение – cover + blur(3)
|
|
||||||
# --------------------------------------------------------------
|
|
||||||
# - «cover» = заполняем весь холст, обрезая лишнее,
|
|
||||||
# сохраняем пропорции оригинала.
|
|
||||||
# - Затем делаем размытие радиусом 3.
|
|
||||||
# --------------------------------------------------------------
|
|
||||||
# Сначала масштабируем так, чтобы одна из сторон полностью покрывала холст
|
|
||||||
src_ratio = src_img.width / src_img.height
|
src_ratio = src_img.width / src_img.height
|
||||||
canvas_ratio = new_w / new_h
|
canvas_ratio = new_w / new_h
|
||||||
|
|
||||||
if src_ratio > canvas_ratio: # изображение более «широкое»
|
# --------------------------------------------------------------
|
||||||
# масштабируем по высоте, ширина будет больше холста → обрезаем по горизонтали
|
# 3️⃣ Фоновое изображение – cover + blur (optional)
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
if src_ratio > canvas_ratio:
|
||||||
scale_h = new_h
|
scale_h = new_h
|
||||||
scale_w = int(scale_h * src_ratio)
|
scale_w = int(scale_h * src_ratio)
|
||||||
else: # изображение более «высокое» или точное соотношение
|
else:
|
||||||
scale_w = new_w
|
scale_w = new_w
|
||||||
scale_h = int(scale_w / src_ratio)
|
scale_h = int(scale_w / src_ratio)
|
||||||
|
|
||||||
# масштабируем
|
|
||||||
bg_resized = src_img.resize((scale_w, scale_h), Image.LANCZOS)
|
bg_resized = src_img.resize((scale_w, scale_h), Image.LANCZOS)
|
||||||
|
|
||||||
# обрезаем центр, чтобы получить точно new_w × new_h
|
|
||||||
left = (scale_w - new_w) // 2
|
left = (scale_w - new_w) // 2
|
||||||
top = (scale_h - new_h) // 2
|
top = (scale_h - new_h) // 2
|
||||||
right = left + new_w
|
right = left + new_w
|
||||||
bottom = top + new_h
|
bottom = top + new_h
|
||||||
bg_cropped = bg_resized.crop((left, top, right, bottom))
|
bg_cropped = bg_resized.crop((left, top, right, bottom))
|
||||||
|
|
||||||
# размытие
|
|
||||||
bg_blurred = bg_cropped.filter(ImageFilter.GaussianBlur(radius=GaussianBlur_radius))
|
|
||||||
|
|
||||||
# --------------------------------------------------------------
|
# --------------------------------------------------------------
|
||||||
# 4️⃣ Переднее изображение – contain + центрировать
|
# 4️⃣ Переднее изображение – contain + центрировать
|
||||||
# --------------------------------------------------------------
|
# --------------------------------------------------------------
|
||||||
# - «contain» = вписать изображение в границы canvas,
|
|
||||||
# полностью показывая его, оставляя пустые поля (прозрачные)
|
|
||||||
# --------------------------------------------------------------
|
|
||||||
# Находим масштаб, чтобы изображение полностью поместилось
|
|
||||||
if src_ratio > canvas_ratio:
|
if src_ratio > canvas_ratio:
|
||||||
# ограничиваем ширину
|
|
||||||
fore_w = new_w
|
fore_w = new_w
|
||||||
fore_h = int(fore_w / src_ratio)
|
fore_h = int(fore_w / src_ratio)
|
||||||
else:
|
else:
|
||||||
# ограничиваем высоту
|
|
||||||
fore_h = new_h
|
fore_h = new_h
|
||||||
fore_w = int(fore_h * src_ratio)
|
fore_w = int(fore_h * src_ratio)
|
||||||
|
|
||||||
fore_resized = src_img.resize((fore_w, fore_h), Image.LANCZOS)
|
fore_resized = src_img.resize((fore_w, fore_h), Image.LANCZOS)
|
||||||
|
|
||||||
# Создаём прозрачный холст того же размера, что и фон
|
# --------------------------------------------------------------
|
||||||
final_img = Image.new('RGB', canvas_sz, (0, 0, 0))
|
# 5️⃣ Сборка
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
final_img = Image.new("RGB", canvas_sz, (255, 255, 255))
|
||||||
|
|
||||||
# Сначала помещаем размытый фон
|
if GaussianBlur_radius:
|
||||||
final_img.paste(bg_blurred, (0, 0))
|
bg_blurred = bg_cropped.filter(ImageFilter.GaussianBlur(radius=GaussianBlur_radius))
|
||||||
|
final_img.paste(bg_blurred, (0, 0))
|
||||||
|
# else: оставляем чёрный фон (или можно вставить bg_cropped без blur)
|
||||||
|
|
||||||
# Затем — переднее изображение, центрированное
|
|
||||||
offset_x = (new_w - fore_w) // 2
|
offset_x = (new_w - fore_w) // 2
|
||||||
offset_y = (new_h - fore_h) // 2
|
offset_y = (new_h - fore_h) // 2
|
||||||
final_img.paste(fore_resized, (offset_x, offset_y))
|
final_img.paste(fore_resized, (offset_x, offset_y))
|
||||||
|
|
||||||
# --------------------------------------------------------------
|
# --------------------------------------------------------------
|
||||||
# 5️⃣ Возврат / сохранение
|
# 6️⃣ Возврат
|
||||||
# --------------------------------------------------------------
|
# --------------------------------------------------------------
|
||||||
out_bytes = BytesIO()
|
out_bytes = BytesIO()
|
||||||
# final_img.save(out_bytes, format='JPEG', quality=85)
|
|
||||||
final_img.save(out_bytes, format="WEBP", quality=quality, method=6)
|
final_img.save(out_bytes, format="WEBP", quality=quality, method=6)
|
||||||
out_bytes.seek(0)
|
out_bytes.seek(0)
|
||||||
return out_bytes # JPEG‑байты готового композитного изображения
|
return out_bytes
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user