1
0
Fork 0
mirror of https://github.com/EDeev/deev.space.git synced 2026-10-08 04:59:31 +03:00

Исправления безопасности и чистка кода

- 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).
This commit is contained in:
Деев Егор Викторович 2026-10-02 11:59:07 +00:00
parent 312ce5a24a
commit 1320562734
14 changed files with 180 additions and 153 deletions

View file

@ -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', '')

View file

@ -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,8 +18,9 @@ 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'

View file

@ -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,
)

View file

@ -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):

View file

@ -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 = 'Загрузка начальных данных из резюме Деева Е.В.'

View file

@ -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()

View file

@ -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:

View file

@ -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 instance.is_achievement:
return
'''
# Telegram
if settings.SOCIAL_AUTOPOST:
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}')
logger.info('Статья "%s" опубликована в Telegram', instance.title)
except requests.RequestException:
logger.exception('Ошибка публикации в Telegram')
# 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}')
'''
logger.info('Статья "%s" опубликована в VK', instance.title)
except requests.RequestException:
logger.exception('Ошибка публикации в VK')
try:
ping_search_engines(instance)
logger.info(f'Поисковые системы уведомлены о статье "{instance.title}"')
except Exception as e:
logger.error(f'Ошибка уведомления поисковых систем: {e}')
ping_search_engines()
def build_telegram_text(article):
"""Текст поста для Telegram в режиме parse_mode=HTML."""
article_url = f'{SITE_URL}{article.get_absolute_url()}'
text = f'📝 <b>{html.escape(article.title)}</b>\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'<a href="{html.escape(article_url, quote=True)}">Читать полностью</a>'
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}')
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)

View file

@ -1,5 +1,6 @@
from django.contrib.sitemaps import Sitemap
from django.urls import reverse
from .models import Article, Project

View file

@ -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'<span class="tech-badge"><i class="{icon_class}"></i> {tech_name}</span>'
return mark_safe(html)
return format_html('<span class="tech-badge"><i class="{}"></i> {}</span>', icon_class, tech_name)
@register.filter

View file

@ -1,3 +1,2 @@
from django.test import TestCase
# Create your tests here.

View file

@ -1,4 +1,5 @@
from django.urls import path
from . import views
urlpatterns = [

View file

@ -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)