From 132056273407e0bdd31503734eee59dc1317d4f7 Mon Sep 17 00:00:00 2001 From: Egor Deev Date: Fri, 2 Oct 2026 11:59:07 +0000 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0?= =?UTF-8?q?=D1=81=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=B8=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - render_tech_badge: format_html вместо mark_safe с непроверенной подстановкой (XSS); - превью ссылок статьи: только публичные http(s)-адреса, без запросов во внутреннюю сеть сервера (SSRF); ошибки логируются вместо except: pass; - автопостинг в Telegram переведён на parse_mode=HTML с html.escape (раньше в канал уходили видимые обратные слэши), включается переменной SOCIAL_AUTOPOST; - убран ping Google (эндпоинт отключён в 2023 году); - ошибка отправки письма с формы обратной связи больше не глушится; - whitenoise и DJANGO_SERVE_MEDIA для запуска без nginx; - импорты упорядочены, неиспользуемые удалены (ruff). --- dspace/settings.py | 13 +- dspace/urls.py | 10 +- main/admin.py | 20 ++- main/context_processors.py | 2 +- main/forms.py | 12 +- main/management/commands/load_initial_data.py | 10 +- main/management/commands/populate_demo.py | 18 ++- main/models.py | 35 ++++- main/signals.py | 139 +++++++----------- main/sitemaps.py | 3 +- main/templatetags/custom_filters.py | 8 +- main/tests.py | 1 - main/urls.py | 3 +- main/views.py | 59 ++++---- 14 files changed, 180 insertions(+), 153 deletions(-) diff --git a/dspace/settings.py b/dspace/settings.py index 478ba9f..bc2c17b 100644 --- a/dspace/settings.py +++ b/dspace/settings.py @@ -1,5 +1,6 @@ import os from pathlib import Path + from django.core.exceptions import ImproperlyConfigured from dotenv import load_dotenv @@ -34,6 +35,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -102,7 +104,9 @@ if not DEBUG: STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'media' +MEDIA_ROOT = Path(os.environ.get('DJANGO_MEDIA_ROOT', BASE_DIR / 'media')) +# Отдавать media самим Django (для запуска в Docker без nginx); в бою media отдаёт nginx +SERVE_MEDIA = os.environ.get('DJANGO_SERVE_MEDIA', 'False') == 'True' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' @@ -111,6 +115,9 @@ SMARTCAPTCHA_CLIENT_KEY = os.environ.get('SMARTCAPTCHA_CLIENT_KEY', '') SMARTCAPTCHA_SERVER_KEY = os.environ.get('SMARTCAPTCHA_SERVER_KEY', '') +# Автопостинг новых статей в Telegram-канал и группу VK (выключен по умолчанию) +SOCIAL_AUTOPOST = os.environ.get('SOCIAL_AUTOPOST', 'False') == 'True' + # Telegram Bot Settings TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '') TELEGRAM_CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '') @@ -126,7 +133,7 @@ VK_GROUP_ID = os.environ.get('VK_GROUP_ID', '') # Email Settings EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.yandex.ru') -EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 465)) +EMAIL_PORT = int(os.environ.get('EMAIL_PORT', '465')) EMAIL_USE_SSL = True EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '') EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '') @@ -187,4 +194,4 @@ LOGGING = { } # Create logs directory -(BASE_DIR / 'logs').mkdir(exist_ok=True) \ No newline at end of file +(BASE_DIR / 'logs').mkdir(exist_ok=True) diff --git a/dspace/urls.py b/dspace/urls.py index a3bf7a7..a9a9ce1 100644 --- a/dspace/urls.py +++ b/dspace/urls.py @@ -1,8 +1,9 @@ -from django.contrib import admin -from django.urls import path, include from django.conf import settings from django.conf.urls.static import static +from django.contrib import admin from django.contrib.sitemaps.views import sitemap +from django.urls import include, path + from main.sitemaps import ArticleSitemap, ProjectSitemap, StaticViewSitemap sitemaps = { @@ -17,9 +18,10 @@ urlpatterns = [ path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='sitemap'), ] -if settings.DEBUG: +if settings.DEBUG or settings.SERVE_MEDIA: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +if settings.DEBUG: urlpatterns += static(settings.STATIC_URL, document_root=settings.STATICFILES_DIRS[0]) handler404 = 'main.views.handler404' -handler500 = 'main.views.handler500' \ No newline at end of file +handler500 = 'main.views.handler500' diff --git a/main/admin.py b/main/admin.py index 11a63a7..a2fc78a 100644 --- a/main/admin.py +++ b/main/admin.py @@ -4,10 +4,24 @@ import json from django.contrib import admin from django.http import HttpResponse from django.utils.html import format_html + from .models import ( - CustomUser, Category, Article, ArticleImage, ArticleFile, ArticleLink, - Project, ProjectStatus, Skill, Comment, ArticleLike, CommentLike, - ContactMessage, Experience, Education, SiteSettings + Article, + ArticleFile, + ArticleImage, + ArticleLike, + ArticleLink, + Category, + Comment, + CommentLike, + ContactMessage, + CustomUser, + Education, + Experience, + Project, + ProjectStatus, + SiteSettings, + Skill, ) diff --git a/main/context_processors.py b/main/context_processors.py index 20883f9..1817230 100644 --- a/main/context_processors.py +++ b/main/context_processors.py @@ -5,4 +5,4 @@ def site_settings(request): """Глобальный контекст с настройками сайта.""" return { 'global_settings': SiteSettings.load() - } \ No newline at end of file + } diff --git a/main/forms.py b/main/forms.py index b86cd02..602dbce 100644 --- a/main/forms.py +++ b/main/forms.py @@ -1,11 +1,11 @@ -from django import forms -from django.contrib.auth.forms import AuthenticationForm -from django.core.validators import RegexValidator -from django.conf import settings import bleach import requests +from django import forms +from django.conf import settings +from django.contrib.auth.forms import AuthenticationForm +from django.core.validators import RegexValidator -from .models import CustomUser, Comment, ContactMessage +from .models import Comment, ContactMessage, CustomUser class SmartCaptchaField(forms.CharField): @@ -248,4 +248,4 @@ class ContactForm(forms.ModelForm): def clean_message(self): message = self.cleaned_data.get('message') message = bleach.clean(message, tags=[], strip=True) - return message \ No newline at end of file + return message diff --git a/main/management/commands/load_initial_data.py b/main/management/commands/load_initial_data.py index 6e9344f..3b30dbe 100644 --- a/main/management/commands/load_initial_data.py +++ b/main/management/commands/load_initial_data.py @@ -1,9 +1,9 @@ -from django.core.management.base import BaseCommand -from main.models import ( - Skill, Experience, Education, Project, Category, SiteSettings, Article -) from datetime import date +from django.core.management.base import BaseCommand + +from main.models import Article, Category, Education, Experience, Project, SiteSettings, Skill + class Command(BaseCommand): help = 'Загрузка начальных данных из резюме Деева Е.В.' @@ -363,4 +363,4 @@ class Command(BaseCommand): defaults=ach_data ) - self.stdout.write(self.style.SUCCESS(f' OK ({len(achievements_data)} достижений)')) \ No newline at end of file + self.stdout.write(self.style.SUCCESS(f' OK ({len(achievements_data)} достижений)')) diff --git a/main/management/commands/populate_demo.py b/main/management/commands/populate_demo.py index 5467b4e..830ca02 100644 --- a/main/management/commands/populate_demo.py +++ b/main/management/commands/populate_demo.py @@ -3,14 +3,20 @@ Использование: python manage.py populate_demo """ -from django.core.management.base import BaseCommand +from datetime import date + from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + from main.models import ( - SiteSettings, Skill, Project, Article, ArticleCategory, - Experience, Education, Achievement, Comment + Article, + ArticleCategory, + Education, + Experience, + Project, + SiteSettings, + Skill, ) -from datetime import date, timedelta -import random User = get_user_model() @@ -147,4 +153,4 @@ class Command(BaseCommand): } ) - self.stdout.write(self.style.SUCCESS('Демо-данные успешно созданы!')) \ No newline at end of file + self.stdout.write(self.style.SUCCESS('Демо-данные успешно созданы!')) diff --git a/main/models.py b/main/models.py index 93ada4c..986271a 100644 --- a/main/models.py +++ b/main/models.py @@ -1,11 +1,29 @@ -from django.db import models +import ipaddress +import logging +import socket +from urllib.parse import urlparse + +import requests +from bs4 import BeautifulSoup from django.contrib.auth.models import AbstractUser +from django.db import models from django.urls import reverse from django.utils.text import slugify from unidecode import unidecode -import requests -from bs4 import BeautifulSoup -from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + + +def is_public_http_url(url): + """Проверка, что ссылка ведёт на публичный http(s)-адрес, а не во внутреннюю сеть сервера.""" + parsed = urlparse(url) + if parsed.scheme not in ('http', 'https') or not parsed.hostname: + return False + try: + infos = socket.getaddrinfo(parsed.hostname, parsed.port or 443) + except OSError: + return False + return all(ipaddress.ip_address(info[4][0]).is_global for info in infos) class CustomUser(AbstractUser): @@ -235,6 +253,9 @@ class ArticleLink(models.Model): def fetch_preview(self): """Автоматическое получение метаданных ссылки.""" + if not is_public_http_url(self.url): + logger.warning('Превью не загружено: адрес %s не публичный', self.url) + return try: headers = {'User-Agent': 'Mozilla/5.0 (compatible; ArticleBot/1.0)'} response = requests.get(self.url, headers=headers, timeout=10) @@ -262,8 +283,8 @@ class ArticleLink(models.Model): if og_image: self.preview_image = og_image.get('content', '') - except Exception: - pass + except (requests.RequestException, AttributeError, TypeError) as e: + logger.warning('Не удалось получить превью %s: %s', self.url, e) def save(self, *args, **kwargs): if not self.title: @@ -653,4 +674,4 @@ class SiteSettings(models.Model): @classmethod def load(cls): obj, created = cls.objects.get_or_create(pk=1) - return obj \ No newline at end of file + return obj diff --git a/main/signals.py b/main/signals.py index 0831c0a..f9da6e7 100644 --- a/main/signals.py +++ b/main/signals.py @@ -1,79 +1,72 @@ +import html +import logging + +import requests +from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver -from django.conf import settings -import requests -import logging from .models import Article logger = logging.getLogger(__name__) +SITE_URL = 'https://deev.space' + @receiver(post_save, sender=Article) def publish_to_social_media(sender, instance, created, **kwargs): """Автоматическая публикация новой статьи в социальные сети.""" - if not created: + if not created or not instance.is_published or instance.is_achievement: return - if not instance.is_published: - return + if settings.SOCIAL_AUTOPOST: + if settings.TELEGRAM_BOT_TOKEN and settings.TELEGRAM_CHANNEL_ID: + try: + publish_to_telegram(instance) + logger.info('Статья "%s" опубликована в Telegram', instance.title) + except requests.RequestException: + logger.exception('Ошибка публикации в Telegram') - if instance.is_achievement: - return + if settings.VK_ACCESS_TOKEN and settings.VK_GROUP_ID: + try: + publish_to_vk(instance) + logger.info('Статья "%s" опубликована в VK', instance.title) + except requests.RequestException: + logger.exception('Ошибка публикации в VK') - ''' - # Telegram - if settings.TELEGRAM_BOT_TOKEN and settings.TELEGRAM_CHANNEL_ID: - try: - publish_to_telegram(instance) - logger.info(f'Статья "{instance.title}" опубликована в Telegram') - except Exception as e: - logger.error(f'Ошибка публикации в Telegram: {e}') + ping_search_engines() - # VK - if settings.VK_ACCESS_TOKEN and settings.VK_GROUP_ID: - try: - publish_to_vk(instance) - logger.info(f'Статья "{instance.title}" опубликована в VK') - except Exception as e: - logger.error(f'Ошибка публикации в VK: {e}') - ''' - try: - ping_search_engines(instance) - logger.info(f'Поисковые системы уведомлены о статье "{instance.title}"') - except Exception as e: - logger.error(f'Ошибка уведомления поисковых систем: {e}') +def build_telegram_text(article): + """Текст поста для Telegram в режиме parse_mode=HTML.""" + article_url = f'{SITE_URL}{article.get_absolute_url()}' + text = f'📝 {html.escape(article.title)}\n\n' + if article.excerpt: + excerpt = article.excerpt[:200] + '...' if len(article.excerpt) > 200 else article.excerpt + text += f'{html.escape(excerpt)}\n\n' + text += f'Читать полностью' + return text def publish_to_telegram(article): """Публикация статьи в Telegram-канал.""" - bot_token = settings.TELEGRAM_BOT_TOKEN - channel_id = settings.TELEGRAM_CHANNEL_ID - - article_url = f"https://deev.space{article.get_absolute_url()}" - - text = f"📝 *{escape_markdown(article.title)}*\n\n" - if article.excerpt: - excerpt = article.excerpt[:200] + '...' if len(article.excerpt) > 200 else article.excerpt - text += f"{escape_markdown(excerpt)}\n\n" - text += f"[Читать полностью]({article_url})" + api = f'https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}' + text = build_telegram_text(article) if article.img: - url = f"https://api.telegram.org/bot{bot_token}/sendPhoto" + url = f'{api}/sendPhoto' data = { - 'chat_id': channel_id, - 'photo': f"https://deev.space{article.img.url}", + 'chat_id': settings.TELEGRAM_CHANNEL_ID, + 'photo': f'{SITE_URL}{article.img.url}', 'caption': text, - 'parse_mode': 'Markdown' + 'parse_mode': 'HTML', } else: - url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + url = f'{api}/sendMessage' data = { - 'chat_id': channel_id, + 'chat_id': settings.TELEGRAM_CHANNEL_ID, 'text': text, - 'parse_mode': 'Markdown', - 'disable_web_page_preview': False + 'parse_mode': 'HTML', } response = requests.post(url, data=data, timeout=10) @@ -82,55 +75,31 @@ def publish_to_telegram(article): def publish_to_vk(article): """Публикация статьи в группу VKontakte.""" - access_token = settings.VK_ACCESS_TOKEN - group_id = settings.VK_GROUP_ID + article_url = f'{SITE_URL}{article.get_absolute_url()}' - article_url = f"https://deev.space{article.get_absolute_url()}" - - message = f"📝 {article.title}\n\n" + message = f'📝 {article.title}\n\n' if article.excerpt: excerpt = article.excerpt[:300] + '...' if len(article.excerpt) > 300 else article.excerpt - message += f"{excerpt}\n\n" - message += f"🔗 Читать: {article_url}" + message += f'{excerpt}\n\n' + message += f'🔗 Читать: {article_url}' - url = "https://api.vk.com/method/wall.post" params = { - 'owner_id': f'-{group_id}', + 'owner_id': f'-{settings.VK_GROUP_ID}', 'message': message, 'from_group': 1, - 'access_token': access_token, - 'v': '5.131' + 'access_token': settings.VK_ACCESS_TOKEN, + 'v': '5.131', } - if article.img: - params['attachments'] = f"https://deev.space{article.img.url}" + params['attachments'] = f'{SITE_URL}{article.img.url}' - response = requests.post(url, params=params, timeout=10) + response = requests.post('https://api.vk.com/method/wall.post', params=params, timeout=10) response.raise_for_status() -def escape_markdown(text): - """Экранирование специальных символов Markdown.""" - escape_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'] - for char in escape_chars: - text = text.replace(char, f'\\{char}') - return text - - -def ping_search_engines(article): - """Уведомление поисковых систем о новой статье.""" - article_url = f"https://deev.space{article.get_absolute_url()}" - - # Google +def ping_search_engines(): + """Уведомление Яндекса об обновлении sitemap (Google ping-эндпоинт отключил в 2023 году).""" try: - google_ping_url = f"https://www.google.com/ping?sitemap=https://deev.space/sitemap.xml" - requests.get(google_ping_url, timeout=5) - except Exception as e: - logger.error(f'Ошибка ping Google: {e}') - - # Яндекс - try: - yandex_ping_url = f"https://webmaster.yandex.ru/ping?sitemap=https://deev.space/sitemap.xml" - requests.get(yandex_ping_url, timeout=5) - except Exception as e: - logger.error(f'Ошибка ping Яндекс: {e}') \ No newline at end of file + requests.get(f'https://webmaster.yandex.ru/ping?sitemap={SITE_URL}/sitemap.xml', timeout=5) + except requests.RequestException as e: + logger.warning('Ошибка ping Яндекса: %s', e) diff --git a/main/sitemaps.py b/main/sitemaps.py index 6b497ee..2f38b93 100644 --- a/main/sitemaps.py +++ b/main/sitemaps.py @@ -1,5 +1,6 @@ from django.contrib.sitemaps import Sitemap from django.urls import reverse + from .models import Article, Project @@ -36,4 +37,4 @@ class StaticViewSitemap(Sitemap): return ['index', 'about', 'projects', 'blog', 'achievements', 'contacts'] def location(self, item): - return reverse(item) \ No newline at end of file + return reverse(item) diff --git a/main/templatetags/custom_filters.py b/main/templatetags/custom_filters.py index 91b7938..85377ea 100644 --- a/main/templatetags/custom_filters.py +++ b/main/templatetags/custom_filters.py @@ -1,6 +1,7 @@ +import math + from django import template -from django.utils.safestring import mark_safe -import re, math +from django.utils.html import format_html register = template.Library() @@ -113,8 +114,7 @@ def status_label(status): def render_tech_badge(tech_name): """Рендерит HTML для бейджа технологии.""" icon_class = tech_icon(tech_name) - html = f' {tech_name}' - return mark_safe(html) + return format_html(' {}', icon_class, tech_name) @register.filter diff --git a/main/tests.py b/main/tests.py index 7ce503c..4929020 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/main/urls.py b/main/urls.py index d216d67..acd24d0 100644 --- a/main/urls.py +++ b/main/urls.py @@ -1,4 +1,5 @@ from django.urls import path + from . import views urlpatterns = [ @@ -23,4 +24,4 @@ urlpatterns = [ path('api/article//comment/', views.add_comment, name='add_comment'), path('api/article//like/', views.toggle_article_like, name='toggle_article_like'), path('api/comment//like/', views.toggle_comment_like, name='toggle_comment_like'), -] \ No newline at end of file +] diff --git a/main/views.py b/main/views.py index 40efcf5..b7c7cd7 100644 --- a/main/views.py +++ b/main/views.py @@ -1,30 +1,38 @@ -from django.shortcuts import render, get_object_or_404, redirect -from django.http import JsonResponse -from django.views.generic import ListView, DetailView, TemplateView -from django.contrib.auth import login, authenticate, logout -from django.contrib.auth.decorators import login_required -from django.contrib import messages -from django.core.paginator import Paginator -from django.core.mail import send_mail -from django.conf import settings -from django.db.models import Q, Count -from django.views.decorators.http import require_POST, require_GET -from django.utils import timezone -from django.utils.html import escape -from django.utils.decorators import method_decorator -from django.views.decorators.csrf import ensure_csrf_cookie -from django.db.models import Sum -from datetime import timedelta -from .middleware import client_ip_hash, is_bot import json import logging -import requests +from datetime import timedelta +import requests +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import login, logout +from django.contrib.auth.decorators import login_required +from django.core.mail import send_mail +from django.db.models import Count, Q, Sum +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.utils.html import escape +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.decorators.http import require_POST +from django.views.generic import DetailView, ListView, TemplateView + +from .forms import CommentForm, ContactForm, LoginForm, RegisterForm +from .middleware import client_ip_hash, is_bot from .models import ( - Article, Project, ProjectStatus, Skill, Comment, ArticleLike, CommentLike, ArticleView, - ContactMessage, Experience, Education, Category, SiteSettings + Article, + ArticleLike, + ArticleView, + Category, + Comment, + CommentLike, + Education, + Experience, + Project, + SiteSettings, + Skill, ) -from .forms import RegisterForm, LoginForm, CommentForm, ContactForm logger = logging.getLogger(__name__) @@ -398,10 +406,9 @@ class ContactsView(TemplateView): message=f'От: {message.name} ({message.email})\n\nТема: {message.subject}\n\n{message.message}', from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[settings.CONTACT_EMAIL], - fail_silently=True, ) - except Exception as e: - logger.error(f'Ошибка отправки email: {e}') + except Exception: + logger.exception('Ошибка отправки email') try: notify_alertbot_contact_message(message) @@ -595,4 +602,4 @@ def handler404(request, exception): def handler500(request): """Обработчик ошибки 500.""" - return render(request, 'errors/500.html', status=500) \ No newline at end of file + return render(request, 'errors/500.html', status=500)