diff --git a/src/.coveragerc b/src/.coveragerc new file mode 100644 index 0000000..9757484 --- /dev/null +++ b/src/.coveragerc @@ -0,0 +1,10 @@ +[run] +source = . +include = *.py +omit = + *migrations* + *tests* + *.html + *whoosh_cn_backend* + *settings.py* + *venv* diff --git a/src/.dockerignore b/src/.dockerignore index bd68a58..2818c38 100644 --- a/src/.dockerignore +++ b/src/.dockerignore @@ -8,5 +8,4 @@ settings_production.py *.md docs/ logs/ -static/ -.github/ +static/ \ No newline at end of file diff --git a/src/.gitignore b/src/.gitignore index 76302b1..3015816 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -62,6 +62,7 @@ target/ # http://www.jetbrains.com/pycharm/webhelp/project.html .idea .iml +static/ # virtualenv venv/ diff --git a/src/accounts/__init__.py b/src/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/admin.py b/src/accounts/admin.py new file mode 100644 index 0000000..32e483c --- /dev/null +++ b/src/accounts/admin.py @@ -0,0 +1,59 @@ +from django import forms +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.forms import UserChangeForm +from django.contrib.auth.forms import UsernameField +from django.utils.translation import gettext_lazy as _ + +# Register your models here. +from .models import BlogUser + + +class BlogUserCreationForm(forms.ModelForm): + password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) + password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) + + class Meta: + model = BlogUser + fields = ('email',) + + def clean_password2(self): + # Check that the two password entries match + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + raise forms.ValidationError(_("passwords do not match")) + return password2 + + def save(self, commit=True): + # Save the provided password in hashed format + user = super().save(commit=False) + user.set_password(self.cleaned_data["password1"]) + if commit: + user.source = 'adminsite' + user.save() + return user + + +class BlogUserChangeForm(UserChangeForm): + class Meta: + model = BlogUser + fields = '__all__' + field_classes = {'username': UsernameField} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class BlogUserAdmin(UserAdmin): + form = BlogUserChangeForm + add_form = BlogUserCreationForm + list_display = ( + 'id', + 'nickname', + 'username', + 'email', + 'last_login', + 'date_joined', + 'source') + list_display_links = ('id', 'username') + ordering = ('-id',) diff --git a/src/accounts/apps.py b/src/accounts/apps.py new file mode 100644 index 0000000..9b3fc5a --- /dev/null +++ b/src/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/src/accounts/forms.py b/src/accounts/forms.py new file mode 100644 index 0000000..fce4137 --- /dev/null +++ b/src/accounts/forms.py @@ -0,0 +1,117 @@ +from django import forms +from django.contrib.auth import get_user_model, password_validation +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +from django.core.exceptions import ValidationError +from django.forms import widgets +from django.utils.translation import gettext_lazy as _ +from . import utils +from .models import BlogUser + + +class LoginForm(AuthenticationForm): + def __init__(self, *args, **kwargs): + super(LoginForm, self).__init__(*args, **kwargs) + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + self.fields['password'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + + +class RegisterForm(UserCreationForm): + def __init__(self, *args, **kwargs): + super(RegisterForm, self).__init__(*args, **kwargs) + + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + self.fields['email'].widget = widgets.EmailInput( + attrs={'placeholder': "email", "class": "form-control"}) + self.fields['password1'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + self.fields['password2'].widget = widgets.PasswordInput( + attrs={'placeholder': "repeat password", "class": "form-control"}) + + def clean_email(self): + email = self.cleaned_data['email'] + if get_user_model().objects.filter(email=email).exists(): + raise ValidationError(_("email already exists")) + return email + + class Meta: + model = get_user_model() + fields = ("username", "email") + + +class ForgetPasswordForm(forms.Form): + new_password1 = forms.CharField( + label=_("New password"), + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("New password") + } + ), + ) + + new_password2 = forms.CharField( + label="确认密码", + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("Confirm password") + } + ), + ) + + email = forms.EmailField( + label='邮箱', + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Email") + } + ), + ) + + code = forms.CharField( + label=_('Code'), + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Code") + } + ), + ) + + def clean_new_password2(self): + password1 = self.data.get("new_password1") + password2 = self.data.get("new_password2") + if password1 and password2 and password1 != password2: + raise ValidationError(_("passwords do not match")) + password_validation.validate_password(password2) + + return password2 + + def clean_email(self): + user_email = self.cleaned_data.get("email") + if not BlogUser.objects.filter( + email=user_email + ).exists(): + # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改 + raise ValidationError(_("email does not exist")) + return user_email + + def clean_code(self): + code = self.cleaned_data.get("code") + error = utils.verify( + email=self.cleaned_data.get("email"), + code=code, + ) + if error: + raise ValidationError(error) + return code + + +class ForgetPasswordCodeForm(forms.Form): + email = forms.EmailField( + label=_('Email'), + ) diff --git a/src/accounts/migrations/0001_initial.py b/src/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..d2fbcab --- /dev/null +++ b/src/accounts/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 4.1.7 on 2023-03-02 07:14 + +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='BlogUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': '用户', + 'verbose_name_plural': '用户', + 'ordering': ['-id'], + 'get_latest_by': 'id', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/src/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/src/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py new file mode 100644 index 0000000..1a9f509 --- /dev/null +++ b/src/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.5 on 2023-09-06 13:13 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='bloguser', + options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'}, + ), + migrations.RemoveField( + model_name='bloguser', + name='created_time', + ), + migrations.RemoveField( + model_name='bloguser', + name='last_mod_time', + ), + migrations.AddField( + model_name='bloguser', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='bloguser', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + ), + migrations.AlterField( + model_name='bloguser', + name='nickname', + field=models.CharField(blank=True, max_length=100, verbose_name='nick name'), + ), + migrations.AlterField( + model_name='bloguser', + name='source', + field=models.CharField(blank=True, max_length=100, verbose_name='create source'), + ), + ] diff --git a/src/accounts/migrations/__init__.py b/src/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/models.py b/src/accounts/models.py new file mode 100644 index 0000000..3baddbb --- /dev/null +++ b/src/accounts/models.py @@ -0,0 +1,35 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.urls import reverse +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from djangoblog.utils import get_current_site + + +# Create your models here. + +class BlogUser(AbstractUser): + nickname = models.CharField(_('nick name'), max_length=100, blank=True) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_modify_time = models.DateTimeField(_('last modify time'), default=now) + source = models.CharField(_('create source'), max_length=100, blank=True) + + def get_absolute_url(self): + return reverse( + 'blog:author_detail', kwargs={ + 'author_name': self.username}) + + def __str__(self): + return self.email + + def get_full_url(self): + site = get_current_site().domain + url = "https://{site}{path}".format(site=site, + path=self.get_absolute_url()) + return url + + class Meta: + ordering = ['-id'] + verbose_name = _('user') + verbose_name_plural = verbose_name + get_latest_by = 'id' diff --git a/src/accounts/templatetags/__init__.py b/src/accounts/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/tests.py b/src/accounts/tests.py new file mode 100644 index 0000000..6893411 --- /dev/null +++ b/src/accounts/tests.py @@ -0,0 +1,207 @@ +from django.test import Client, RequestFactory, TestCase +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from accounts.models import BlogUser +from blog.models import Article, Category +from djangoblog.utils import * +from . import utils + + +# Create your tests here. + +class AccountTest(TestCase): + def setUp(self): + self.client = Client() + self.factory = RequestFactory() + self.blog_user = BlogUser.objects.create_user( + username="test", + email="admin@admin.com", + password="12345678" + ) + self.new_test = "xxx123--=" + + def test_validate_account(self): + site = get_current_site().domain + user = BlogUser.objects.create_superuser( + email="liangliangyy1@gmail.com", + username="liangliangyy1", + password="qwer!@#$ggg") + testuser = BlogUser.objects.get(username='liangliangyy1') + + loginresult = self.client.login( + username='liangliangyy1', + password='qwer!@#$ggg') + self.assertEqual(loginresult, True) + response = self.client.get('/admin/') + self.assertEqual(response.status_code, 200) + + category = Category() + category.name = "categoryaaa" + category.creation_time = timezone.now() + category.last_modify_time = timezone.now() + category.save() + + article = Article() + article.title = "nicetitleaaa" + article.body = "nicecontentaaa" + article.author = user + article.category = category + article.type = 'a' + article.status = 'p' + article.save() + + response = self.client.get(article.get_admin_url()) + self.assertEqual(response.status_code, 200) + + def test_validate_register(self): + self.assertEquals( + 0, len( + BlogUser.objects.filter( + email='user123@user.com'))) + response = self.client.post(reverse('account:register'), { + 'username': 'user1233', + 'email': 'user123@user.com', + 'password1': 'password123!q@wE#R$T', + 'password2': 'password123!q@wE#R$T', + }) + self.assertEquals( + 1, len( + BlogUser.objects.filter( + email='user123@user.com'))) + user = BlogUser.objects.filter(email='user123@user.com')[0] + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + path = reverse('accounts:result') + url = '{path}?type=validation&id={id}&sign={sign}'.format( + path=path, id=user.id, sign=sign) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self.client.login(username='user1233', password='password123!q@wE#R$T') + user = BlogUser.objects.filter(email='user123@user.com')[0] + user.is_superuser = True + user.is_staff = True + user.save() + delete_sidebar_cache() + category = Category() + category.name = "categoryaaa" + category.creation_time = timezone.now() + category.last_modify_time = timezone.now() + category.save() + + article = Article() + article.category = category + article.title = "nicetitle333" + article.body = "nicecontentttt" + article.author = user + + article.type = 'a' + article.status = 'p' + article.save() + + response = self.client.get(article.get_admin_url()) + self.assertEqual(response.status_code, 200) + + response = self.client.get(reverse('account:logout')) + self.assertIn(response.status_code, [301, 302, 200]) + + response = self.client.get(article.get_admin_url()) + self.assertIn(response.status_code, [301, 302, 200]) + + response = self.client.post(reverse('account:login'), { + 'username': 'user1233', + 'password': 'password123' + }) + self.assertIn(response.status_code, [301, 302, 200]) + + response = self.client.get(article.get_admin_url()) + self.assertIn(response.status_code, [301, 302, 200]) + + def test_verify_email_code(self): + to_email = "admin@admin.com" + code = generate_code() + utils.set_code(to_email, code) + utils.send_verify_email(to_email, code) + + err = utils.verify("admin@admin.com", code) + self.assertEqual(err, None) + + err = utils.verify("admin@123.com", code) + self.assertEqual(type(err), str) + + def test_forget_password_email_code_success(self): + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict(email="admin@admin.com") + ) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content.decode("utf-8"), "ok") + + def test_forget_password_email_code_fail(self): + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict() + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict(email="admin@com") + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + def test_forget_password_email_success(self): + code = generate_code() + utils.set_code(self.blog_user.email, code) + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email=self.blog_user.email, + code=code, + ) + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + self.assertEqual(resp.status_code, 302) + + # 验证用户密码是否修改成功 + blog_user = BlogUser.objects.filter( + email=self.blog_user.email, + ).first() # type: BlogUser + self.assertNotEqual(blog_user, None) + self.assertEqual(blog_user.check_password(data["new_password1"]), True) + + def test_forget_password_email_not_user(self): + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email="123@123.com", + code="123456", + ) + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + + self.assertEqual(resp.status_code, 200) + + + def test_forget_password_email_code_error(self): + code = generate_code() + utils.set_code(self.blog_user.email, code) + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email=self.blog_user.email, + code="111111", + ) + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + + self.assertEqual(resp.status_code, 200) + diff --git a/src/accounts/urls.py b/src/accounts/urls.py new file mode 100644 index 0000000..107a801 --- /dev/null +++ b/src/accounts/urls.py @@ -0,0 +1,28 @@ +from django.urls import path +from django.urls import re_path + +from . import views +from .forms import LoginForm + +app_name = "accounts" + +urlpatterns = [re_path(r'^login/$', + views.LoginView.as_view(success_url='/'), + name='login', + kwargs={'authentication_form': LoginForm}), + re_path(r'^register/$', + views.RegisterView.as_view(success_url="/"), + name='register'), + re_path(r'^logout/$', + views.LogoutView.as_view(), + name='logout'), + path(r'account/result.html', + views.account_result, + name='result'), + re_path(r'^forget_password/$', + views.ForgetPasswordView.as_view(), + name='forget_password'), + re_path(r'^forget_password_code/$', + views.ForgetPasswordEmailCode.as_view(), + name='forget_password_code'), + ] diff --git a/src/accounts/user_login_backend.py b/src/accounts/user_login_backend.py new file mode 100644 index 0000000..73cdca1 --- /dev/null +++ b/src/accounts/user_login_backend.py @@ -0,0 +1,26 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + + +class EmailOrUsernameModelBackend(ModelBackend): + """ + 允许使用用户名或邮箱登录 + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + if '@' in username: + kwargs = {'email': username} + else: + kwargs = {'username': username} + try: + user = get_user_model().objects.get(**kwargs) + if user.check_password(password): + return user + except get_user_model().DoesNotExist: + return None + + def get_user(self, username): + try: + return get_user_model().objects.get(pk=username) + except get_user_model().DoesNotExist: + return None diff --git a/src/accounts/utils.py b/src/accounts/utils.py new file mode 100644 index 0000000..4b94bdf --- /dev/null +++ b/src/accounts/utils.py @@ -0,0 +1,49 @@ +import typing +from datetime import timedelta + +from django.core.cache import cache +from django.utils.translation import gettext +from django.utils.translation import gettext_lazy as _ + +from djangoblog.utils import send_email + +_code_ttl = timedelta(minutes=5) + + +def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): + """发送重设密码验证码 + Args: + to_mail: 接受邮箱 + subject: 邮件主题 + code: 验证码 + """ + html_content = _( + "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it " + "properly") % {'code': code} + send_email([to_mail], subject, html_content) + + +def verify(email: str, code: str) -> typing.Optional[str]: + """验证code是否有效 + Args: + email: 请求邮箱 + code: 验证码 + Return: + 如果有错误就返回错误str + Node: + 这里的错误处理不太合理,应该采用raise抛出 + 否测调用方也需要对error进行处理 + """ + cache_code = get_code(email) + if cache_code != code: + return gettext("Verification code error") + + +def set_code(email: str, code: str): + """设置code""" + cache.set(email, code, _code_ttl.seconds) + + +def get_code(email: str) -> typing.Optional[str]: + """获取code""" + return cache.get(email) diff --git a/src/accounts/views.py b/src/accounts/views.py new file mode 100644 index 0000000..ae67aec --- /dev/null +++ b/src/accounts/views.py @@ -0,0 +1,204 @@ +import logging +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.contrib import auth +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth import get_user_model +from django.contrib.auth import logout +from django.contrib.auth.forms import AuthenticationForm +from django.contrib.auth.hashers import make_password +from django.http import HttpResponseRedirect, HttpResponseForbidden +from django.http.request import HttpRequest +from django.http.response import HttpResponse +from django.shortcuts import get_object_or_404 +from django.shortcuts import render +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.utils.http import url_has_allowed_host_and_scheme +from django.views import View +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.debug import sensitive_post_parameters +from django.views.generic import FormView, RedirectView + +from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache +from . import utils +from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm +from .models import BlogUser + +logger = logging.getLogger(__name__) + + +# Create your views here. + +class RegisterView(FormView): + form_class = RegisterForm + template_name = 'account/registration_form.html' + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + return super(RegisterView, self).dispatch(*args, **kwargs) + + def form_valid(self, form): + if form.is_valid(): + user = form.save(False) + user.is_active = False + user.source = 'Register' + user.save(True) + site = get_current_site().domain + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + + if settings.DEBUG: + site = '127.0.0.1:8000' + path = reverse('account:result') + url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( + site=site, path=path, id=user.id, sign=sign) + + content = """ +

请点击下面链接验证您的邮箱

+ + {url} + + 再次感谢您! +
+ 如果上面链接无法打开,请将此链接复制至浏览器。 + {url} + """.format(url=url) + send_email( + emailto=[ + user.email, + ], + title='验证您的电子邮箱', + content=content) + + url = reverse('accounts:result') + \ + '?type=register&id=' + str(user.id) + return HttpResponseRedirect(url) + else: + return self.render_to_response({ + 'form': form + }) + + +class LogoutView(RedirectView): + url = '/login/' + + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + return super(LogoutView, self).dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + logout(request) + delete_sidebar_cache() + return super(LogoutView, self).get(request, *args, **kwargs) + + +class LoginView(FormView): + form_class = LoginForm + template_name = 'account/login.html' + success_url = '/' + redirect_field_name = REDIRECT_FIELD_NAME + login_ttl = 2626560 # 一个月的时间 + + @method_decorator(sensitive_post_parameters('password')) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + + return super(LoginView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + redirect_to = self.request.GET.get(self.redirect_field_name) + if redirect_to is None: + redirect_to = '/' + kwargs['redirect_to'] = redirect_to + + return super(LoginView, self).get_context_data(**kwargs) + + def form_valid(self, form): + form = AuthenticationForm(data=self.request.POST, request=self.request) + + if form.is_valid(): + delete_sidebar_cache() + logger.info(self.redirect_field_name) + + auth.login(self.request, form.get_user()) + if self.request.POST.get("remember"): + self.request.session.set_expiry(self.login_ttl) + return super(LoginView, self).form_valid(form) + # return HttpResponseRedirect('/') + else: + return self.render_to_response({ + 'form': form + }) + + def get_success_url(self): + + redirect_to = self.request.POST.get(self.redirect_field_name) + if not url_has_allowed_host_and_scheme( + url=redirect_to, allowed_hosts=[ + self.request.get_host()]): + redirect_to = self.success_url + return redirect_to + + +def account_result(request): + type = request.GET.get('type') + id = request.GET.get('id') + + user = get_object_or_404(get_user_model(), id=id) + logger.info(type) + if user.is_active: + return HttpResponseRedirect('/') + if type and type in ['register', 'validation']: + if type == 'register': + content = ''' + 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 + ''' + title = '注册成功' + else: + c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + sign = request.GET.get('sign') + if sign != c_sign: + return HttpResponseForbidden() + user.is_active = True + user.save() + content = ''' + 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 + ''' + title = '验证成功' + return render(request, 'account/result.html', { + 'title': title, + 'content': content + }) + else: + return HttpResponseRedirect('/') + + +class ForgetPasswordView(FormView): + form_class = ForgetPasswordForm + template_name = 'account/forget_password.html' + + def form_valid(self, form): + if form.is_valid(): + blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() + blog_user.password = make_password(form.cleaned_data["new_password2"]) + blog_user.save() + return HttpResponseRedirect('/login/') + else: + return self.render_to_response({'form': form}) + + +class ForgetPasswordEmailCode(View): + + def post(self, request: HttpRequest): + form = ForgetPasswordCodeForm(request.POST) + if not form.is_valid(): + return HttpResponse("错误的邮箱") + to_email = form.cleaned_data["email"] + + code = generate_code() + utils.send_verify_email(to_email, code) + utils.set_code(to_email, code) + + return HttpResponse("ok") diff --git a/src/blog/admin.py b/src/blog/admin.py index 69d7f8e..46c3420 100644 --- a/src/blog/admin.py +++ b/src/blog/admin.py @@ -6,7 +6,7 @@ from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ # Register your models here. -from .models import Article, Category, Tag, Links, SideBar, BlogSettings +from .models import Article class ArticleForm(forms.ModelForm): @@ -55,7 +55,6 @@ class ArticlelAdmin(admin.ModelAdmin): 'article_order') list_display_links = ('id', 'title') list_filter = ('status', 'type', 'category') - date_hierarchy = 'creation_time' filter_horizontal = ('tags',) exclude = ('creation_time', 'last_modify_time') view_on_site = True @@ -64,7 +63,6 @@ class ArticlelAdmin(admin.ModelAdmin): draft_article, close_article_commentstatus, open_article_commentstatus] - raw_id_fields = ('author', 'category',) def link_to_category(self, obj): info = (obj.category._meta.app_label, obj.category._meta.model_name) diff --git a/src/blog/management/__init__.py b/src/blog/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/blog/management/commands/__init__.py b/src/blog/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/blog/management/commands/build_index.py b/src/blog/management/commands/build_index.py new file mode 100644 index 0000000..3c4acd7 --- /dev/null +++ b/src/blog/management/commands/build_index.py @@ -0,0 +1,18 @@ +from django.core.management.base import BaseCommand + +from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \ + ELASTICSEARCH_ENABLED + + +# TODO 参数化 +class Command(BaseCommand): + help = 'build search index' + + def handle(self, *args, **options): + if ELASTICSEARCH_ENABLED: + ElaspedTimeDocumentManager.build_index() + manager = ElapsedTimeDocument() + manager.init() + manager = ArticleDocumentManager() + manager.delete_index() + manager.rebuild() diff --git a/src/blog/management/commands/build_search_words.py b/src/blog/management/commands/build_search_words.py new file mode 100644 index 0000000..cfe7e0d --- /dev/null +++ b/src/blog/management/commands/build_search_words.py @@ -0,0 +1,13 @@ +from django.core.management.base import BaseCommand + +from blog.models import Tag, Category + + +# TODO 参数化 +class Command(BaseCommand): + help = 'build search words' + + def handle(self, *args, **options): + datas = set([t.name for t in Tag.objects.all()] + + [t.name for t in Category.objects.all()]) + print('\n'.join(datas)) diff --git a/src/blog/management/commands/clear_cache.py b/src/blog/management/commands/clear_cache.py new file mode 100644 index 0000000..0d66172 --- /dev/null +++ b/src/blog/management/commands/clear_cache.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand + +from djangoblog.utils import cache + + +class Command(BaseCommand): + help = 'clear the whole cache' + + def handle(self, *args, **options): + cache.clear() + self.stdout.write(self.style.SUCCESS('Cleared cache\n')) diff --git a/src/blog/management/commands/create_testdata.py b/src/blog/management/commands/create_testdata.py new file mode 100644 index 0000000..675d2ba --- /dev/null +++ b/src/blog/management/commands/create_testdata.py @@ -0,0 +1,40 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password +from django.core.management.base import BaseCommand + +from blog.models import Article, Tag, Category + + +class Command(BaseCommand): + help = 'create test datas' + + def handle(self, *args, **options): + user = get_user_model().objects.get_or_create( + email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0] + + pcategory = Category.objects.get_or_create( + name='我是父类目', parent_category=None)[0] + + category = Category.objects.get_or_create( + name='子类目', parent_category=pcategory)[0] + + category.save() + basetag = Tag() + basetag.name = "标签" + basetag.save() + for i in range(1, 20): + article = Article.objects.get_or_create( + category=category, + title='nice title ' + str(i), + body='nice content ' + str(i), + author=user)[0] + tag = Tag() + tag.name = "标签" + str(i) + tag.save() + article.tags.add(tag) + article.tags.add(basetag) + article.save() + + from djangoblog.utils import cache + cache.clear() + self.stdout.write(self.style.SUCCESS('created test datas \n')) diff --git a/src/blog/management/commands/ping_baidu.py b/src/blog/management/commands/ping_baidu.py new file mode 100644 index 0000000..2c7fbdd --- /dev/null +++ b/src/blog/management/commands/ping_baidu.py @@ -0,0 +1,50 @@ +from django.core.management.base import BaseCommand + +from djangoblog.spider_notify import SpiderNotify +from djangoblog.utils import get_current_site +from blog.models import Article, Tag, Category + +site = get_current_site().domain + + +class Command(BaseCommand): + help = 'notify baidu url' + + def add_arguments(self, parser): + parser.add_argument( + 'data_type', + type=str, + choices=[ + 'all', + 'article', + 'tag', + 'category'], + help='article : all article,tag : all tag,category: all category,all: All of these') + + def get_full_url(self, path): + url = "https://{site}{path}".format(site=site, path=path) + return url + + def handle(self, *args, **options): + type = options['data_type'] + self.stdout.write('start get %s' % type) + + urls = [] + if type == 'article' or type == 'all': + for article in Article.objects.filter(status='p'): + urls.append(article.get_full_url()) + if type == 'tag' or type == 'all': + for tag in Tag.objects.all(): + url = tag.get_absolute_url() + urls.append(self.get_full_url(url)) + if type == 'category' or type == 'all': + for category in Category.objects.all(): + url = category.get_absolute_url() + urls.append(self.get_full_url(url)) + + self.stdout.write( + self.style.SUCCESS( + 'start notify %d urls' % + len(urls))) + SpiderNotify.baidu_notify(urls) + self.stdout.write(self.style.SUCCESS('finish notify')) diff --git a/src/blog/management/commands/sync_user_avatar.py b/src/blog/management/commands/sync_user_avatar.py new file mode 100644 index 0000000..d0f4612 --- /dev/null +++ b/src/blog/management/commands/sync_user_avatar.py @@ -0,0 +1,47 @@ +import requests +from django.core.management.base import BaseCommand +from django.templatetags.static import static + +from djangoblog.utils import save_user_avatar +from oauth.models import OAuthUser +from oauth.oauthmanager import get_manager_by_type + + +class Command(BaseCommand): + help = 'sync user avatar' + + def test_picture(self, url): + try: + if requests.get(url, timeout=2).status_code == 200: + return True + except: + pass + + def handle(self, *args, **options): + static_url = static("../") + users = OAuthUser.objects.all() + self.stdout.write(f'开始同步{len(users)}个用户头像') + for u in users: + self.stdout.write(f'开始同步:{u.nickname}') + url = u.picture + if url: + if url.startswith(static_url): + if self.test_picture(url): + continue + else: + if u.metadata: + manage = get_manager_by_type(u.type) + url = manage.get_picture(u.metadata) + url = save_user_avatar(url) + else: + url = static('blog/img/avatar.png') + else: + url = save_user_avatar(url) + else: + url = static('blog/img/avatar.png') + if url: + self.stdout.write( + f'结束同步:{u.nickname}.url:{url}') + u.picture = url + u.save() + self.stdout.write('结束同步') diff --git a/src/blog/static/blog/css/style.css b/src/blog/static/blog/css/style.css index 390bba3..d43f7f3 100644 --- a/src/blog/static/blog/css/style.css +++ b/src/blog/static/blog/css/style.css @@ -2017,7 +2017,12 @@ img#wpstats { width: auto; } - + .commentlist .avatar { + height: 39px; + left: 2.2em; + top: 2.2em; + width: 39px; + } .comments-area article header cite, .comments-area article header time { @@ -2145,70 +2150,17 @@ div { word-break: break-all; } -/* 评论整体布局 - 使用相对定位实现头像左侧布局 */ -.commentlist .comment-body { - position: relative; - padding-left: 60px; /* 为48px头像 + 12px间距留出空间 */ - min-height: 48px; /* 确保有足够高度容纳头像 */ -} - -/* 评论作者信息 - 用户名和时间在同一行 */ -.commentlist .comment-author { - display: inline-block; - margin: 0 10px 5px 0; - font-size: 13px; - position: relative; -} - -.commentlist .comment-meta { - display: inline-block; - margin: 0 0 8px 0; - font-size: 12px; - color: #666; -} - +.commentlist .comment-author, +.commentlist .comment-meta, .commentlist .comment-awaiting-moderation { + float: left; display: block; font-size: 13px; line-height: 22px; } -/* 头像样式 - 绝对定位到左侧 */ -.commentlist .comment-author .avatar { - position: absolute !important; - left: -60px; /* 定位到容器左侧 */ - top: 0; - width: 48px !important; - height: 48px !important; - border-radius: 50%; - display: block; - object-fit: cover; - background-color: #f5f5f5; - border: 1px solid #ddd; -} - -/* 评论作者名称样式 */ -.commentlist .comment-author .fn { - display: inline; - margin: 0; - font-weight: 600; - color: #2e7bb8; - font-size: 13px; -} - -.commentlist .comment-author .fn a { - color: #2e7bb8; - text-decoration: none; -} - -.commentlist .comment-author .fn a:hover { - text-decoration: underline; -} - -/* 评论内容样式 */ -.commentlist .comment-body p { - margin: 5px 0 10px 0; - line-height: 1.5; +.commentlist .comment-author { + margin-right: 6px; } .commentlist .fn, .pinglist .ping-link { @@ -2222,15 +2174,13 @@ div { display: none; } -/* 通用头像样式 */ .commentlist .avatar { - width: 48px !important; - height: 48px !important; - border-radius: 50%; - display: block; - object-fit: cover; - background-color: #f5f5f5; - border: 1px solid #ddd; + position: absolute; + left: -60px; + top: 0; + width: 48px; + height: 48px; + border-radius: 100%; } .commentlist .comment-meta:before, .pinglist .ping-meta:before { @@ -2340,87 +2290,15 @@ div { padding-left: 48px; } -/* 嵌套评论整体布局 */ -.commentlist li li .comment-body { - padding-left: 60px; /* 为48px头像 + 12px间距留出空间 */ - min-height: 48px; /* 确保有足够高度容纳头像 */ -} - -/* 嵌套评论作者信息 */ -.commentlist li li .comment-author { - display: inline-block; - margin: 0 8px 5px 0; - font-size: 12px; /* 稍小一点 */ +.commentlist li li .avatar { + top: 0; + left: -48px; + width: 36px; + height: 36px; } .commentlist li li .comment-meta { - display: inline-block; - margin: 0 0 8px 0; - font-size: 11px; /* 稍小一点 */ - color: #666; -} - -/* 评论容器整体左移 - 使用更高优先级 */ -#comments #commentlist-container.comment-tab { - margin-left: -15px !important; /* 在小屏幕上向左移动15px */ - padding-left: 0 !important; /* 移除左内边距 */ - position: relative !important; /* 确保定位正确 */ -} - -/* 在较大屏幕上进一步左移 */ -@media screen and (min-width: 600px) { - #comments #commentlist-container.comment-tab { - margin-left: -30px !important; /* 在大屏幕上向左移动30px */ - } - - /* 响应式设计下的评论布局 - 保持48px头像 */ - .commentlist .comment-body { - padding-left: 60px !important; /* 为48px头像 + 12px间距留出空间 */ - min-height: 48px !important; - } - - .commentlist .comment-author { - display: inline-block !important; - margin: 0 8px 5px 0 !important; - } - - .commentlist .comment-meta { - display: inline-block !important; - margin: 0 0 8px 0 !important; - } - - /* 响应式设计下头像保持48px */ - .commentlist .comment-author .avatar { - left: -60px !important; - width: 48px !important; - height: 48px !important; - } - - /* 嵌套评论在响应式设计下也保持48px头像 */ - .commentlist li li .comment-body { - padding-left: 60px !important; - min-height: 48px !important; - } - - .commentlist li li .comment-author .avatar { - left: -60px !important; - width: 48px !important; - height: 48px !important; - } -} - -/* 嵌套评论头像 */ -.commentlist li li .comment-author .avatar { - position: absolute !important; - left: -60px; /* 定位到容器左侧 */ - top: 0; - width: 48px !important; - height: 48px !important; - border-radius: 50%; - display: block; - object-fit: cover; - background-color: #f5f5f5; - border: 1px solid #ddd; + left: 70px; } /* comments : nav @@ -2623,206 +2501,4 @@ li #reply-title { height: 1px; border: none; /*border-top: 1px dashed #f5d6d6;*/ -} - -/* ============================================================================= - 评论内容溢出修复样式 - 解决代码块和长文本撑开页面布局的问题 - ============================================================================= */ - -/* 评论容器基础样式 */ -.comment-body { - overflow-wrap: break-word; - word-wrap: break-word; - word-break: break-word; - max-width: 100%; - box-sizing: border-box; -} - -/* 修复评论中的代码块溢出 */ -.comment-content pre, -.comment-body pre { - white-space: pre-wrap !important; - word-wrap: break-word !important; - overflow-wrap: break-word !important; - max-width: 100% !important; - overflow-x: auto; - padding: 10px; - background-color: #f8f8f8; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 12px; - line-height: 1.4; - margin: 10px 0; -} - -/* 修复评论中的行内代码 */ -.comment-content code, -.comment-body code { - word-wrap: break-word !important; - overflow-wrap: break-word !important; - white-space: pre-wrap; - max-width: 100%; - display: inline-block; - vertical-align: top; -} - -/* 修复评论中的长链接 */ -.comment-content a, -.comment-body a { - word-wrap: break-word !important; - overflow-wrap: break-word !important; - word-break: break-all; - max-width: 100%; -} - -/* 修复评论段落 */ -.comment-content p, -.comment-body p { - word-wrap: break-word !important; - overflow-wrap: break-word !important; - max-width: 100%; - margin: 10px 0; -} - -/* 特殊处理代码高亮块 - 关键修复! */ -.comment-content .codehilite, -.comment-body .codehilite { - max-width: 100% !important; - overflow-x: auto; - margin: 10px 0; - background: #f8f8f8 !important; - border: 1px solid #ddd; - border-radius: 4px; - padding: 10px; - font-size: 12px; - line-height: 1.4; - /* 关键:防止内容撑开容器 */ - width: 100%; - box-sizing: border-box; - display: block; -} - -.comment-content .codehilite pre, -.comment-body .codehilite pre { - white-space: pre-wrap !important; - word-wrap: break-word !important; - overflow-wrap: break-word !important; - margin: 0 !important; - padding: 0 !important; - background: transparent !important; - border: none !important; - font-size: inherit; - line-height: inherit; - /* 确保pre标签不会超出父容器 */ - max-width: 100%; - width: 100%; - box-sizing: border-box; -} - -/* 修复代码高亮中的span标签 */ -.comment-content .codehilite span, -.comment-body .codehilite span { - word-wrap: break-word !important; - overflow-wrap: break-word !important; - /* 防止行内元素导致的溢出 */ - display: inline; - max-width: 100%; -} - -/* 针对特定的代码高亮类 */ -.comment-content .codehilite .kt, -.comment-content .codehilite .nf, -.comment-content .codehilite .n, -.comment-content .codehilite .p, -.comment-content .codehilite .w, -.comment-content .codehilite .o, -.comment-body .codehilite .kt, -.comment-body .codehilite .nf, -.comment-body .codehilite .n, -.comment-body .codehilite .p, -.comment-body .codehilite .w, -.comment-body .codehilite .o { - word-break: break-all; - overflow-wrap: break-word; -} - -/* 修复评论列表项 */ -.commentlist li { - max-width: 100%; - overflow: hidden; - box-sizing: border-box; -} - -/* 确保评论内容不超出容器 */ -.commentlist .comment-body { - max-width: calc(100% - 20px); /* 留出一些边距 */ - margin-left: 10px; - margin-right: 10px; - overflow: hidden; /* 防止内容溢出 */ - word-wrap: break-word; -} - -/* 重要:限制评论列表项的最大宽度 */ -.commentlist li[style*="margin-left"] { - max-width: calc(100% - 2rem) !important; - overflow: hidden; - box-sizing: border-box; -} - -/* 特别处理深层嵌套的评论 */ -.commentlist li[style*="margin-left: 3rem"], -.commentlist li[style*="margin-left: 6rem"], -.commentlist li[style*="margin-left: 9rem"] { - max-width: calc(100% - 1rem) !important; -} - -/* 移动端优化 */ -@media (max-width: 768px) { - .comment-content pre, - .comment-body pre { - font-size: 11px; - padding: 8px; - margin: 8px 0; - } - - .commentlist .comment-body { - max-width: calc(100% - 10px); - margin-left: 5px; - margin-right: 5px; - } - - /* 移动端评论缩进调整 */ - .commentlist li[style*="margin-left"] { - margin-left: 1rem !important; - max-margin-left: 2rem !important; - } -} - -/* 防止表格溢出 */ -.comment-content table, -.comment-body table { - max-width: 100%; - overflow-x: auto; - display: block; - white-space: nowrap; -} - -/* 修复图片溢出 */ -.comment-content img, -.comment-body img { - max-width: 100% !important; - height: auto !important; -} - -/* 修复引用块 */ -.comment-content blockquote, -.comment-body blockquote { - max-width: 100%; - overflow-wrap: break-word; - word-wrap: break-word; - padding: 10px 15px; - margin: 10px 0; - border-left: 4px solid #ddd; - background-color: #f9f9f9; } \ No newline at end of file diff --git a/src/blog/templatetags/blog_tags.py b/src/blog/templatetags/blog_tags.py index 1f994bc..d6cd5d5 100644 --- a/src/blog/templatetags/blog_tags.py +++ b/src/blog/templatetags/blog_tags.py @@ -51,75 +51,7 @@ def datetimeformat(data): @register.filter() @stringfilter def custom_markdown(content): - """ - 通用markdown过滤器,应用文章内容插件 - 主要用于文章内容处理 - """ - html_content = CommonMarkdown.get_markdown(content) - - # 然后应用插件过滤器优化HTML - from djangoblog.plugin_manage import hooks - from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME - optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content) - - return mark_safe(optimized_html) - - -@register.filter() -@stringfilter -def sidebar_markdown(content): - html_content = CommonMarkdown.get_markdown(content) - return mark_safe(html_content) - - -@register.simple_tag(takes_context=True) -def render_article_content(context, article, is_summary=False): - """ - 渲染文章内容,包含完整的上下文信息供插件使用 - - Args: - context: 模板上下文 - article: 文章对象 - is_summary: 是否为摘要模式(首页使用) - """ - if not article or not hasattr(article, 'body'): - return '' - - # 先转换Markdown为HTML - html_content = CommonMarkdown.get_markdown(article.body) - - # 如果是摘要模式,先截断内容再应用插件 - if is_summary: - # 截断HTML内容到合适的长度(约300字符) - from django.utils.html import strip_tags - from django.template.defaultfilters import truncatechars - - # 先去除HTML标签,截断纯文本,然后重新转换为HTML - plain_text = strip_tags(html_content) - truncated_text = truncatechars(plain_text, 300) - - # 重新转换截断后的文本为HTML(简化版,避免复杂的插件处理) - html_content = CommonMarkdown.get_markdown(truncated_text) - - # 然后应用插件过滤器,传递完整的上下文 - from djangoblog.plugin_manage import hooks - from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME - - # 获取request对象 - request = context.get('request') - - # 应用所有文章内容相关的插件 - # 注意:摘要模式下某些插件(如版权声明)可能不适用 - optimized_html = hooks.apply_filters( - ARTICLE_CONTENT_HOOK_NAME, - html_content, - article=article, - request=request, - context=context, - is_summary=is_summary # 传递摘要标志,插件可以据此调整行为 - ) - - return mark_safe(optimized_html) + return mark_safe(CommonMarkdown.get_markdown(content)) @register.simple_tag @@ -360,49 +292,38 @@ def load_article_detail(article, isindex, user): } -# 返回用户头像URL -# 模板使用方法: {{ email|gravatar_url:150 }} +# return only the URL of the gravatar +# TEMPLATE USE: {{ email|gravatar_url:150 }} @register.filter def gravatar_url(email, size=40): - """获得用户头像 - 优先使用OAuth头像,否则使用默认头像""" - cachekey = 'avatar/' + email + """获得gravatar头像""" + cachekey = 'gravatat/' + email url = cache.get(cachekey) if url: return url - - # 检查OAuth用户是否有自定义头像 - usermodels = OAuthUser.objects.filter(email=email) - if usermodels: - # 过滤出有头像的用户 - users_with_picture = list(filter(lambda x: x.picture is not None, usermodels)) - if users_with_picture: - # 获取默认头像路径用于比较 - default_avatar_path = static('blog/img/avatar.png') - - # 优先选择非默认头像的用户,否则选择第一个 - non_default_users = [u for u in users_with_picture if u.picture != default_avatar_path and not u.picture.endswith('/avatar.png')] - selected_user = non_default_users[0] if non_default_users else users_with_picture[0] - - url = selected_user.picture - cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 - - avatar_type = 'non-default' if non_default_users else 'default' - logger.info('Using {} OAuth avatar for {} from {}'.format(avatar_type, email, selected_user.type)) - return url - - # 使用默认头像 - url = static('blog/img/avatar.png') - cache.set(cachekey, url, 60 * 60 * 24) # 缓存24小时 - logger.info('Using default avatar for {}'.format(email)) - return url + else: + usermodels = OAuthUser.objects.filter(email=email) + if usermodels: + o = list(filter(lambda x: x.picture is not None, usermodels)) + if o: + return o[0].picture + email = email.encode('utf-8') + + default = static('blog/img/avatar.png') + + url = "https://www.gravatar.com/avatar/%s?%s" % (hashlib.md5( + email.lower()).hexdigest(), urllib.parse.urlencode({'d': default, 's': str(size)})) + cache.set(cachekey, url, 60 * 60 * 10) + logger.info('set gravatar cache.key:{key}'.format(key=cachekey)) + return url @register.filter def gravatar(email, size=40): - """获得用户头像HTML标签""" + """获得gravatar头像""" url = gravatar_url(email, size) return mark_safe( - '用户头像' % + '' % (url, size, size)) diff --git a/src/blog/views.py b/src/blog/views.py index ace9e63..d5dc7ec 100644 --- a/src/blog/views.py +++ b/src/blog/views.py @@ -154,6 +154,10 @@ class ArticleDetailView(DetailView): article = self.object # Action Hook, 通知插件"文章详情已获取" hooks.run_action('after_article_body_get', article=article, request=self.request) + # # Filter Hook, 允许插件修改文章正文 + article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article, + request=self.request) + return context diff --git a/src/comments/admin.py b/src/comments/admin.py index dbde14f..a814f3f 100644 --- a/src/comments/admin.py +++ b/src/comments/admin.py @@ -29,8 +29,6 @@ class CommentAdmin(admin.ModelAdmin): list_filter = ('is_enable',) exclude = ('creation_time', 'last_modify_time') actions = [disable_commentstatus, enable_commentstatus] - raw_id_fields = ('author', 'article') - search_fields = ('body',) def link_to_userinfo(self, obj): info = (obj.author._meta.app_label, obj.author._meta.model_name) diff --git a/src/deploy/k8s/deployment.yaml b/src/deploy/k8s/deployment.yaml index b50c411..414fdcc 100644 --- a/src/deploy/k8s/deployment.yaml +++ b/src/deploy/k8s/deployment.yaml @@ -26,13 +26,13 @@ spec: name: djangoblog-env readinessProbe: httpGet: - path: /health/ + path: / port: 8000 initialDelaySeconds: 10 periodSeconds: 30 livenessProbe: httpGet: - path: /health/ + path: / port: 8000 initialDelaySeconds: 10 periodSeconds: 30 diff --git a/src/djangoblog/settings.py b/src/djangoblog/settings.py index 692409c..d076bb6 100644 --- a/src/djangoblog/settings.py +++ b/src/djangoblog/settings.py @@ -109,13 +109,15 @@ WSGI_APPLICATION = 'djangoblog.wsgi.application' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'djangoblog', - 'USER': 'root', - 'PASSWORD': 'root', - 'HOST': '127.0.0.1', - 'PORT': 3306, - } -} + 'NAME': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog', + 'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root', + 'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or 'root', + 'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1', + 'PORT': int( + os.environ.get('DJANGO_MYSQL_PORT') or 3306), + 'OPTIONS': { + 'charset': 'utf8mb4'}, + }} # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators @@ -316,21 +318,6 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') MEDIA_URL = '/media/' X_FRAME_OPTIONS = 'SAMEORIGIN' -# 安全头部配置 - 防XSS和其他攻击 -SECURE_BROWSER_XSS_FILTER = True -SECURE_CONTENT_TYPE_NOSNIFF = True -SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin' - -# 内容安全策略 (CSP) - 防XSS攻击 -CSP_DEFAULT_SRC = ["'self'"] -CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"] -CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"] -CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"] -CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"] -CSP_CONNECT_SRC = ["'self'"] -CSP_FRAME_SRC = ["'none'"] -CSP_OBJECT_SRC = ["'none'"] - DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): @@ -352,7 +339,5 @@ ACTIVE_PLUGINS = [ 'reading_time', 'external_links', 'view_count', - 'seo_optimizer', - 'image_lazy_loading', -] -DATABASES + 'seo_optimizer' +] \ No newline at end of file diff --git a/src/djangoblog/urls.py b/src/djangoblog/urls.py index 6a9e1de..4aae58a 100644 --- a/src/djangoblog/urls.py +++ b/src/djangoblog/urls.py @@ -20,8 +20,6 @@ from django.contrib.sitemaps.views import sitemap from django.urls import path, include from django.urls import re_path from haystack.views import search_view_factory -from django.http import JsonResponse -import time from blog.views import EsSearchView from djangoblog.admin_site import admin_site @@ -42,20 +40,8 @@ handler404 = 'blog.views.page_not_found_view' handler500 = 'blog.views.server_error_view' handle403 = 'blog.views.permission_denied_view' - -def health_check(request): - """ - 健康检查接口 - 简单返回服务健康状态 - """ - return JsonResponse({ - 'status': 'healthy', - 'timestamp': time.time() - }) - urlpatterns = [ path('i18n/', include('django.conf.urls.i18n')), - path('health/', health_check, name='health_check'), ] urlpatterns += i18n_patterns( re_path(r'^admin/', admin_site.urls), diff --git a/src/djangoblog/utils.py b/src/djangoblog/utils.py index 91d2b91..57f63dc 100644 --- a/src/djangoblog/utils.py +++ b/src/djangoblog/utils.py @@ -224,49 +224,9 @@ def get_resource_url(): ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', - 'h2', 'p', 'span', 'div'] - -# 安全的class值白名单 - 只允许代码高亮相关的class -ALLOWED_CLASSES = [ - 'codehilite', 'highlight', 'hll', 'c', 'err', 'k', 'l', 'n', 'o', 'p', 'cm', 'cp', 'c1', 'cs', - 'gd', 'ge', 'gr', 'gh', 'gi', 'go', 'gp', 'gs', 'gu', 'gt', 'kc', 'kd', 'kn', 'kp', 'kr', 'kt', - 'ld', 'm', 'mf', 'mh', 'mi', 'mo', 'na', 'nb', 'nc', 'no', 'nd', 'ni', 'ne', 'nf', 'nl', 'nn', - 'nt', 'nv', 'ow', 'w', 'mb', 'mh', 'mi', 'mo', 'sb', 'sc', 'sd', 'se', 'sh', 'si', 'sx', 's2', - 's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il' -] - -def class_filter(tag, name, value): - """自定义class属性过滤器""" - if name == 'class': - # 只允许预定义的安全class值 - allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES] - return ' '.join(allowed_classes) if allowed_classes else False - return value - -# 安全的属性白名单 -ALLOWED_ATTRIBUTES = { - 'a': ['href', 'title'], - 'abbr': ['title'], - 'acronym': ['title'], - 'span': class_filter, - 'div': class_filter, - 'pre': class_filter, - 'code': class_filter -} - -# 安全的协议白名单 - 防止javascript:等危险协议 -ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] + 'h2', 'p'] +ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']} + def sanitize_html(html): - """ - 安全的HTML清理函数 - 使用bleach库进行白名单过滤,防止XSS攻击 - """ - return bleach.clean( - html, - tags=ALLOWED_TAGS, - attributes=ALLOWED_ATTRIBUTES, - protocols=ALLOWED_PROTOCOLS, # 限制允许的协议 - strip=True, # 移除不允许的标签而不是转义 - strip_comments=True # 移除HTML注释 - ) + return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) diff --git a/src/plugins/article_copyright/plugin.py b/src/plugins/article_copyright/plugin.py index 5dba3b3..317fed2 100644 --- a/src/plugins/article_copyright/plugin.py +++ b/src/plugins/article_copyright/plugin.py @@ -22,11 +22,6 @@ class ArticleCopyrightPlugin(BasePlugin): article = kwargs.get('article') if not article: return content - - # 如果是摘要模式(首页),不添加版权声明 - is_summary = kwargs.get('is_summary', False) - if is_summary: - return content copyright_info = f"\n

本文由 {article.author.username} 原创,转载请注明出处。

" return content + copyright_info diff --git a/src/plugins/reading_time/plugin.py b/src/plugins/reading_time/plugin.py index 4b929d8..35f9db1 100644 --- a/src/plugins/reading_time/plugin.py +++ b/src/plugins/reading_time/plugin.py @@ -17,15 +17,7 @@ class ReadingTimePlugin(BasePlugin): def add_reading_time(self, content, *args, **kwargs): """ 计算阅读时间并添加到内容开头。 - 只在文章详情页显示,首页(文章列表页)不显示。 """ - # 检查是否为摘要模式(首页/文章列表页) - # 通过kwargs中的is_summary参数判断 - is_summary = kwargs.get('is_summary', False) - if is_summary: - # 如果是摘要模式(首页),直接返回原内容,不添加阅读时间 - return content - # 移除HTML标签和空白字符,以获得纯文本 clean_content = re.sub(r'<[^>]*>', '', content) clean_content = clean_content.strip() diff --git a/src/plugins/seo_optimizer/plugin.py b/src/plugins/seo_optimizer/plugin.py index de12c15..b5b19a3 100644 --- a/src/plugins/seo_optimizer/plugin.py +++ b/src/plugins/seo_optimizer/plugin.py @@ -97,8 +97,6 @@ class SeoOptimizerPlugin(BasePlugin): structured_data = { "@context": "https://schema.org", "@type": "WebSite", - "name": blog_setting.site_name, - "description": blog_setting.site_description, "url": request.build_absolute_uri('/'), "potentialAction": { "@type": "SearchAction", @@ -133,15 +131,12 @@ class SeoOptimizerPlugin(BasePlugin): json_ld_script = f'' - seo_html = f""" + return f""" {seo_data.get("title", "")} {seo_data.get("meta_tags", "")} {json_ld_script} """ - - # 将SEO内容追加到现有的metas内容上 - return metas + seo_html plugin = SeoOptimizerPlugin() diff --git a/src/requirements.txt b/src/requirements.txt index 4b09638..9dc5c93 100644 Binary files a/src/requirements.txt and b/src/requirements.txt differ diff --git a/src/templates/blog/tags/article_info.html b/src/templates/blog/tags/article_info.html index 7af7617..3deec44 100644 --- a/src/templates/blog/tags/article_info.html +++ b/src/templates/blog/tags/article_info.html @@ -48,7 +48,7 @@
{% if isindex %} - {% render_article_content article True %} + {{ article.body|custom_markdown|escape|truncatechars_content }}

Read more

{% else %} @@ -62,7 +62,7 @@ {% endif %}
- {% render_article_content article False %} + {{ article.body|custom_markdown|escape }}
{% endif %} diff --git a/src/templates/blog/tags/sidebar.html b/src/templates/blog/tags/sidebar.html index ecb6d20..f70544c 100644 --- a/src/templates/blog/tags/sidebar.html +++ b/src/templates/blog/tags/sidebar.html @@ -16,7 +16,7 @@ {% endfor %} diff --git a/src/templates/comments/tags/comment_item.html b/src/templates/comments/tags/comment_item.html index 0693649..ebb0388 100644 --- a/src/templates/comments/tags/comment_item.html +++ b/src/templates/comments/tags/comment_item.html @@ -2,13 +2,10 @@