initial commit...

This commit is contained in:
Vasily Zubarev 2023-01-10 16:00:15 +01:00
commit c5b202a42f
293 changed files with 14809 additions and 0 deletions

15
.env.example Normal file
View File

@ -0,0 +1,15 @@
SECRET_KEY=""
TELEGRAM_TOKEN=""
TELEGRAM_MAIN_CHAT_ID=""
SENTRY_DSN=""
PATREON_CLIENT_ID=""
PATREON_CLIENT_SECRET=""
JWT_SECRET=""
EMAIL_HOST="email-smtp.eu-central-1.amazonaws.com"
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""

55
.gitignore vendored Normal file
View File

@ -0,0 +1,55 @@
*.py[co]
# Packages
*.egg
*.egg-info
dist
build
eggs
parts
sdist
develop-eggs
.installed.cfg
# Installer logs
pip-log.txt
# Unit test / coverage reports
.coverage
.tox
# Translations
*.mo
# IDEA
.idea
# Mac OS X
.DS_Store
# Node
node_modules/
.npm
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.pnp.*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
webpack-stats.json
# Python
__pycache__
.mypy_cache
private_settings.py
local_settings.py
vas3k_blog/.env
.env
db.sqlite3
*.session
venv/
.history

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM ubuntu:22.04
ENV PIP_NO_CACHE_DIR=true
ENV POETRY_VIRTUALENVS_CREATE=false
ENV PIP_DISABLE_PIP_VERSION_CHECK=true
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install --no-install-recommends -yq \
build-essential \
python3 \
python3-dev \
python3-pip \
libpq-dev \
make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
RUN pip3 install poetry
RUN poetry install --no-interaction --no-root
CMD ["make", "docker-run-production"]

31
Makefile Normal file
View File

@ -0,0 +1,31 @@
# Set the default goal if no targets were specified on the command line
.DEFAULT_GOAL = run
# Makes shell non-interactive and exit on any error
.SHELLFLAGS = -ec
PROJECT_NAME=vas3k_blog
run-dev: ## Runs dev server locally
poetry run python manage.py runserver 0.0.0.0:8000
docker-run-dev: ## Runs dev server in docker
python3 ./utils/wait_for_postgres.py
python3 manage.py migrate
python3 manage.py runserver 0.0.0.0:8000
docker-run-production: ## Runs production server in docker
python3 manage.py migrate
gunicorn vas3k_blog.asgi:application -w 7 -k uvicorn.workers.UvicornWorker --bind=0.0.0.0:8022 --capture-output --log-level debug --access-logfile - --error-logfile -
help: ## Display this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| sort \
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[0;32m%-30s\033[0m %s\n", $$1, $$2}'
migrate: ## Migrate database to the latest version
poetry run python3 manage.py migrate
.PHONY: \
docker-run-dev \
docker-run-production \
run-dev

33
README.md Normal file
View File

@ -0,0 +1,33 @@
# My blog source code
![](https://i.vas3k.ru/dxq.jpg)
## ШТО?
Да, это реальный код vas3k.ru, который сейчас крутится на продакшене.
Раз у гитхаба появились приватные репо, пусть полежит тут.
Теперь это основная ветка разработки. Когда я фикшу баги и пилю фичи — они будут появляться тут.
## Как запускать?
Чтобы всё красиво заработало, нужен дамп базы. Дать я его не могу потому что там имейлы, айпишники и другие приватные данные, которые я задолбаюсь вычищать чтобы выложить.
Но раз уж вы здесь, вы наверняка не боитесь трудностей, потому готовы развернуть голый движок и видеть нихуя. Потому сейчас быстро расскажу как тут и чо.
Сайт написан на Python 3.6+, Django, PostgreSQL 10+. Стиль кода такой, чтобы в нём разобрался одноглазый инвалид после комы. Именно так я вижу свой код когда месяца два к нему не подходил.
Некоторые фрагменты тут написаны аж в 2013-м, потому что изначально всё делалось тупо и просто как батины сапоги. Чтобы на века! Тут даже докера с кубернетесом нет, что просто невидано по современным меркам.
Предположим, вы установили питон и постгрес на свою систему. Дальше делаем так:
```
$ git clone git@github.com:vas3k/vas3k.ru.git
$ cd vas3k.ru
$ pip3 install -U -r requirements.txt # ставим все зависимости
$ python3 manage.py migrate # накатываем пустые таблички в базу
$ python3 manage.py runserver 8080 # запускаем сервер на localhost:8080
```
Всё. Идем в браузер на localhost:8080 и наслаждаемся пустым сайтом.
Скорее всего он выдаст какую-нибудь ошибку из-за нехватки постов на главной. Это нормально. Джанговские трейсбеки и `DEBUG = True` помогут вам разобраться что и как.

0
authn/__init__.py Normal file
View File

3
authn/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
authn/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuthnConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "authn"

18
authn/club.py Normal file
View File

@ -0,0 +1,18 @@
import logging
import requests
log = logging.getLogger(__name__)
def parse_membership(user_slug, jwt_token):
try:
return requests.get(
url=f"https://vas3k.club/user/{user_slug}.json",
params={
"jwt": jwt_token
}
).json()
except Exception as ex:
log.exception(ex)
return None

2
authn/exceptions.py Normal file
View File

@ -0,0 +1,2 @@
class PatreonException(Exception):
pass

View File

3
authn/models.py Normal file
View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

110
authn/patreon.py Normal file
View File

@ -0,0 +1,110 @@
import logging
from json import JSONDecodeError
import requests
from django.conf import settings
from authn.exceptions import PatreonException
logger = logging.getLogger(__name__)
def fetch_auth_data(code, original_redirect_uri):
try:
response = requests.post(
url=settings.PATREON_TOKEN_URL,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"code": code,
"grant_type": "authorization_code",
"client_id": settings.PATREON_CLIENT_ID,
"client_secret": settings.PATREON_CLIENT_SECRET,
"redirect_uri": original_redirect_uri,
},
)
except requests.exceptions.RequestException as ex:
if "invalid_grant" not in str(ex):
logger.exception(f"Patreon error on login: {ex}")
raise PatreonException(ex)
if response.status_code >= 400:
logger.error(f"Patreon error on login {response.status_code}: {response.text}")
raise PatreonException(response.text)
try:
return response.json()
except JSONDecodeError:
raise PatreonException("Patreon is down")
def refresh_auth_data(refresh_token):
try:
response = requests.post(
url=settings.PATREON_TOKEN_URL,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"refresh_token": refresh_token,
"grant_type": "refresh_token",
"client_id": settings.PATREON_CLIENT_ID,
"client_secret": settings.PATREON_CLIENT_SECRET,
},
)
except requests.exceptions.RequestException as ex:
logger.exception(f"Patreon error on refreshing token: {ex}")
raise PatreonException(ex)
if response.status_code >= 400:
logger.error(
f"Patreon error on refreshing token {response.status_code}: {response.text}"
)
raise PatreonException(response.text)
try:
return response.json()
except JSONDecodeError:
raise PatreonException("Patreon is down")
def fetch_user_data(access_token):
logger.info(f"Fetching user data with access token: {access_token}")
try:
response = requests.get(
url=settings.PATREON_USER_URL,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Bearer {access_token}",
},
params={
"include": "memberships",
"fields[user]": "full_name,email,image_url,about",
"fields[member]": "patron_status,last_charge_status,pledge_relationship_start,lifetime_support_cents",
},
)
except requests.exceptions.RequestException as ex:
logger.exception(f"Patreon error on fetching user data: {ex}")
raise PatreonException(ex)
if response.status_code >= 400: # unauthorized etc
logger.warning(
f"Patreon error on fetching user data {response.status_code}: {response.text}"
)
raise PatreonException(response.text)
try:
return response.json()
except JSONDecodeError:
raise PatreonException("Patreon is down")
def parse_my_membership(user_data):
if not user_data or not user_data.get("data") or not user_data.get("included"):
return None
for membership in user_data["included"]:
if (
membership["attributes"]["patron_status"] == "active_patron"
and membership["attributes"]["last_charge_status"] == "Paid"
):
return membership["attributes"]
return None

3
authn/tests.py Normal file
View File

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

172
authn/views.py Normal file
View File

@ -0,0 +1,172 @@
import logging
from urllib.parse import urlencode, quote, urlparse
import jwt
from django.conf import settings
from django.contrib.auth import logout, login
from django.db.models import Q
from django.shortcuts import render, redirect
from django.urls import reverse
from authn import club, patreon
from authn.exceptions import PatreonException
from users.models import User
log = logging.getLogger(__name__)
def log_in(request):
return render(request, "users/login.html")
def log_out(request):
logout(request)
return redirect("index")
def login_club(request):
if request.user.is_authenticated:
return redirect("profile")
goto = request.GET.get("goto")
params = urlencode({
"app_id": "vas3k_blog",
"redirect": f"https://{request.get_host()}/auth/club_callback/" + (f"?goto={quote(goto)}" if goto else "")
})
return redirect(f"{settings.CLUB_AUTH_URL}?{params}")
def club_callback(request):
token = request.GET.get("jwt")
if not token:
return render(request, "error.html", {
"title": "Что-то пошло не так",
"message": "При авторизации потерялся токен. Попробуйте войти еще раз."
})
try:
payload = jwt.decode(token, settings.JWT_PUBLIC_KEY, algorithms=[settings.JWT_ALGORITHM])
except (jwt.DecodeError, jwt.ExpiredSignatureError) as ex:
log.error(f"JWT token error: {ex}")
return render(request, "error.html", {
"title": "Что-то сломалось",
"message": "Неправильный ключ. Наверное, что-то сломалось. Либо ты ХАКИР!!11"
})
user_slug = payload["user_slug"]
club_profile = club.parse_membership(user_slug, token)
if not club_profile or not club_profile.get("user"):
return render(request, "error.html", {
"message": f"Член Клуба с именем {user_slug} не найден. "
"<a href=\"https://vas3k.club\">Попробуйте</a> войти в свой "
"аккаунт и потом авторизоваться здесь снова."
})
if club_profile["user"]["payment_status"] != "active":
return render(request, "error.html", {
"message": "Ваша подписка истекла. "
"<a href=\"https://vas3k.club\">Продлите</a> её здесь."
})
user = User.objects.filter(Q(email=payload["user_email"]) | Q(vas3k_club_slug=payload["user_slug"])).first()
if user:
user.vas3k_club_slug = payload["user_slug"]
user.email = payload["user_email"]
user.country = club_profile["user"]["country"]
user.city = club_profile["user"]["city"]
user.save()
else:
user = User.objects.create_user(
vas3k_club_slug=payload["user_slug"],
username=club_profile["user"]["full_name"][:20],
email=payload["user_email"],
)
login(request, user)
goto = request.GET.get("goto")
if goto and urlparse(goto).netloc == request.get_host():
redirect_to = goto
else:
redirect_to = reverse("profile")
return redirect(redirect_to)
def login_patreon(request):
if request.user.is_authenticated:
return redirect("profile")
state = {}
goto = request.GET.get("goto")
if goto:
state["goto"] = goto
query_string = urlencode({
"response_type": "code",
"client_id": settings.PATREON_CLIENT_ID,
"redirect_uri": f"https://{request.get_host()}/auth/patreon_callback/",
"scope": settings.PATREON_SCOPE,
"state": urlencode(state) if state else ""
})
return redirect(f"{settings.PATREON_AUTH_URL}?{query_string}")
def patreon_callback(request):
code = request.GET.get("code")
if not code:
return render(request, "error.html", {
"message": "Что-то сломалось между нами и патреоном. Так бывает. Попробуйте залогиниться еще раз."
})
try:
auth_data = patreon.fetch_auth_data(
code=code,
original_redirect_uri=f"https://{request.get_host()}/auth/patreon_callback/"
)
user_data = patreon.fetch_user_data(auth_data["access_token"])
except PatreonException as ex:
if "invalid_grant" in str(ex):
return render(request, "error.html", {
"message": "Тут такое дело. Авторизация патреона — говно. "
"Она не сразу понимает, что вы стали моим патроном и отдаёт мне ошибку. "
"Саппорт молчит, так что единственный рабочий вариант — вернуться и авторизоваться еще раз. "
"Обычно тогда срабатывает."
})
return render(request, "error.html", {
"message": "Не получилось загрузить ваш профиль с серверов патреона. "
"Попробуйте еще раз, наверняка оно починится. "
f"Но если нет, то вот текст ошибки, с которым можно пожаловаться мне в личку: {ex}"
})
membership = patreon.parse_my_membership(user_data)
if not membership:
return render(request, "error.html", {
"message": "Надо быть активным патроном, чтобы комментировать на сайте.<br>"
"<a href=\"https://www.patreon.com/join/vas3k\">Станьте им здесь!</a>"
})
user = User.objects\
.filter(Q(email=user_data["data"]["attributes"]["email"]) | Q(patreon_id=user_data["data"]["id"]))\
.first()
if user:
user.patreon_id = user_data["data"]["id"]
user.save()
else:
user = User.objects.create_user(
patreon_id=user_data["data"]["id"],
username=str(user_data["data"]["attributes"]["full_name"])[:20],
email=user_data["data"]["attributes"]["email"],
)
login(request, user)
goto = request.GET.get("goto")
if goto and urlparse(goto).netloc == request.get_host():
redirect_to = goto
else:
redirect_to = reverse("profile")
return redirect(redirect_to)

0
clickers/__init__.py Normal file
View File

5
clickers/admin.py Normal file
View File

@ -0,0 +1,5 @@
from django.contrib import admin
from clickers.models import Clicker
admin.site.register(Clicker)

6
clickers/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ClickersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "clickers"

View File

View File

View File

@ -0,0 +1,50 @@
import logging
from django.core.management import BaseCommand
from django.db import connections, IntegrityError
from clickers.models import Clicker
from posts.models import Post
log = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Migrate clickers from old database to the new one"
def handle(self, *args, **options):
with connections["old"].cursor() as cursor:
cursor.execute(
"select *, (select type from stories where clickers.story_id = stories.id) as post_type, "
"(select slug from stories where clickers.story_id = stories.id) as post_slug from clickers"
)
for row in dictfetchall(cursor):
post = Post.objects.filter(slug=row["post_slug"], type=row["post_type"]).first()
if not post:
continue
try:
clicker, _ = Clicker.objects.update_or_create(
post_id=post.id,
comment_id=row["comment_id"],
block=row["block"],
ipaddress=row["ip"],
defaults=dict(
created_at=row["created_at"],
useragent=row["useragent"],
)
)
except IntegrityError:
continue
self.stdout.write(f"Clicker {clicker.id} updated...")
self.stdout.write("Done 🥙")
def dictfetchall(cursor):
columns = [col[0] for col in cursor.description]
return [
dict(zip(columns, row))
for row in cursor.fetchall()
]

View File

@ -0,0 +1,34 @@
# Generated by Django 4.1.4 on 2023-01-06 17:54
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Clicker",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("block", models.TextField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("ipaddress", models.GenericIPAddressField(db_index=True)),
("useragent", models.CharField(max_length=256)),
],
options={
"db_table": "clickers",
},
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 4.1.4 on 2023-01-06 17:54
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("comments", "0001_initial"),
("clickers", "0001_initial"),
("posts", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="clicker",
name="comment",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="clickers",
to="comments.comment",
),
),
migrations.AddField(
model_name="clicker",
name="post",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="clickers",
to="posts.post",
),
),
]

View File

13
clickers/models.py Normal file
View File

@ -0,0 +1,13 @@
from django.db import models
class Clicker(models.Model):
post = models.ForeignKey("posts.Post", related_name="clickers", on_delete=models.CASCADE)
comment = models.ForeignKey("comments.Comment", related_name="clickers", null=True, on_delete=models.CASCADE)
block = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
ipaddress = models.GenericIPAddressField(db_index=True)
useragent = models.CharField(max_length=256)
class Meta:
db_table = "clickers"

55
clickers/views.py Normal file
View File

@ -0,0 +1,55 @@
from django.db.models import F
from django.http import Http404, JsonResponse, HttpResponseNotAllowed, HttpResponse
from django.shortcuts import get_object_or_404
from clickers.models import Clicker
from comments.models import Comment
from posts.models import Post
from utils.request import parse_ip_address
def click_comment(request, comment_id):
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
comment = get_object_or_404(Comment, id=comment_id)
clicker, is_created = Clicker.objects.get_or_create(
post_id=comment.post_id,
block=comment.id,
ipaddress=parse_ip_address(request),
defaults=dict(
post_id=comment.post_id,
comment_id=comment.id,
block=comment.id,
useragent=request.META.get("HTTP_USER_AGENT", ""),
)
)
if is_created:
Comment.objects.filter(id=comment.id).update(upvotes=F("upvotes") + 1)
return HttpResponse(comment.upvotes + 1)
return HttpResponse(comment.upvotes)
def click_block(request, post_slug, block):
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
post = get_object_or_404(Post, slug=post_slug)
clicker, is_created = Clicker.objects.get_or_create(
post=post,
block=block,
ipaddress=parse_ip_address(request),
defaults=dict(
useragent=request.META.get("HTTP_USER_AGENT", ""),
)
)
if is_created:
total_clicks = Clicker.objects.filter(post=post, block=block).count()
return HttpResponse(total_clicks)
return HttpResponse("x")

0
comments/__init__.py Normal file
View File

12
comments/admin.py Normal file
View File

@ -0,0 +1,12 @@
from django.contrib import admin
from comments.models import Comment
class CommentAdmin(admin.ModelAdmin):
list_display = (
"author_name", "author", "created_at", "ipaddress", "text", "upvotes"
)
admin.site.register(Comment, CommentAdmin)

6
comments/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CommentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "comments"

35
comments/forms.py Normal file
View File

@ -0,0 +1,35 @@
from django import forms
from django.forms import ModelForm
from comments.models import Comment
class CommentForm(ModelForm):
post_slug = forms.CharField(
label="ID поста",
required=True,
max_length=54,
widget=forms.HiddenInput
)
block = forms.CharField(
label="ID блока",
max_length=54,
widget=forms.HiddenInput,
required=False,
)
text = forms.CharField(
label="Текст",
min_length=3,
max_length=10000,
required=True,
)
class Meta:
model = Comment
fields = [
"block",
"text",
]

View File

View File

View File

@ -0,0 +1,73 @@
import logging
from django.core.management import BaseCommand
from django.db import connections
from comments.models import Comment
from posts.models import Post
from users.models import User
log = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Migrate comments from old database to the new one"
def handle(self, *args, **options):
with connections["old"].cursor() as cursor:
cursor.execute(
"select *, (select type from stories where comments.story_id = stories.id) as post_type, "
"(select slug from stories where comments.story_id = stories.id) as post_slug from comments"
)
for row in dictfetchall(cursor):
post = Post.objects.filter(slug=row["post_slug"], type=row["post_type"]).first()
if not post:
continue
user = None
if row["user_id"]:
cursor.execute("select * from users where id = %s", [row["user_id"]])
old_user = dictfetchall(cursor)
if old_user:
old_user = old_user.pop()
old_user_email = old_user["email"] or old_user["name"] + "@legacy.vas3k.ru"
user, _ = User.objects.get_or_create(
email=old_user_email,
defaults=dict(
username=old_user["name"],
patreon_id=old_user["platform_id"] if old_user["platform_id"].isnumeric() else None,
vas3k_club_slug=old_user["platform_id"] if not old_user["platform_id"].isnumeric() else None,
avatar=old_user["avatar"],
)
)
comment, _ = Comment.objects.update_or_create(
id=row["id"],
defaults=dict(
author_id=user.id if user else None,
author_name=row["author"],
post_id=post.id,
text=row["text"],
block=row["block"],
metadata=row["data"],
ipaddress=row["ip"],
useragent=row["useragent"],
created_at=row["created_at"],
updated_at=row["created_at"],
upvotes=row["rating"],
is_visible=row["is_visible"],
is_deleted=False,
is_pinned=False,
)
)
self.stdout.write(f"Comment {comment.id} updated...")
self.stdout.write("Done 🥙")
def dictfetchall(cursor):
columns = [col[0] for col in cursor.description]
return [
dict(zip(columns, row))
for row in cursor.fetchall()
]

View File

@ -0,0 +1,49 @@
# Generated by Django 4.1.4 on 2023-01-06 17:54
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Comment",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"author_name",
models.CharField(blank=True, max_length=128, null=True),
),
("block", models.CharField(blank=True, max_length=128, null=True)),
("text", models.TextField()),
("html_cache", models.TextField(null=True)),
("metadata", models.JSONField(null=True)),
("ipaddress", models.GenericIPAddressField(null=True)),
("useragent", models.CharField(max_length=512, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("upvotes", models.IntegerField(db_index=True, default=0)),
("is_visible", models.BooleanField(default=True)),
("is_deleted", models.BooleanField(default=False)),
("is_pinned", models.BooleanField(default=False)),
("deleted_by", models.UUIDField(null=True)),
],
options={
"db_table": "comments",
"ordering": ["created_at"],
},
),
]

View File

@ -0,0 +1,48 @@
# Generated by Django 4.1.4 on 2023-01-06 17:54
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("comments", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("posts", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="comment",
name="author",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="comments",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="comment",
name="post",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="posts.post",
),
),
migrations.AddField(
model_name="comment",
name="reply_to",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="replies",
to="comments.comment",
),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.1.4 on 2023-01-08 13:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("comments", "0002_initial"),
]
operations = [
migrations.AlterModelOptions(
name="comment",
options={"ordering": ("-created_at",)},
),
migrations.AlterField(
model_name="comment",
name="created_at",
field=models.DateTimeField(),
),
migrations.AlterField(
model_name="comment",
name="updated_at",
field=models.DateTimeField(),
),
]

View File

92
comments/models.py Normal file
View File

@ -0,0 +1,92 @@
import random
from datetime import datetime, timedelta
from uuid import uuid4
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.template.defaultfilters import date as django_date
from django.db import models
from django.db.models import F
from posts.models import Post
from users.avatars import AVATARS
from users.models import User
from vas3k_blog.exceptions import BadRequest
class Comment(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
author = models.ForeignKey(User, related_name="comments", null=True, on_delete=models.SET_NULL)
author_name = models.CharField(max_length=128, null=True, blank=True)
post = models.ForeignKey(Post, related_name="comments", on_delete=models.CASCADE)
reply_to = models.ForeignKey("self", related_name="replies", null=True, on_delete=models.CASCADE)
block = models.CharField(max_length=128, null=True, blank=True)
text = models.TextField(null=False)
html_cache = models.TextField(null=True)
metadata = models.JSONField(null=True)
ipaddress = models.GenericIPAddressField(null=True)
useragent = models.CharField(max_length=512, null=True)
created_at = models.DateTimeField()
updated_at = models.DateTimeField()
upvotes = models.IntegerField(default=0, db_index=True)
is_visible = models.BooleanField(default=True)
is_deleted = models.BooleanField(default=False)
is_pinned = models.BooleanField(default=False)
deleted_by = models.UUIDField(null=True)
class Meta:
db_table = "comments"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self.reply_to and self.reply_to.reply_to and self.reply_to.reply_to.reply_to_id:
raise BadRequest(message="3 уровня комментариев это максимум")
self.updated_at = datetime.utcnow()
return super().save(*args, **kwargs)
def delete(self, deleted_by=None, *args, **kwargs):
self.is_deleted = True
self.save()
def undelete(self, *args, **kwargs):
self.is_deleted = False
self.save()
def increment_vote_count(self):
return Comment.objects.filter(id=self.id).update(upvotes=F("upvotes") + 1)
def decrement_vote_count(self):
return Comment.objects.filter(id=self.id).update(upvotes=F("upvotes") - 1)
def is_deletable_by(self, user):
return user == self.author or user.is_admin()
@classmethod
def visible_objects(cls, show_deleted=False):
comments = cls.objects\
.filter(is_visible=True)\
.select_related("author", "post", "reply_to")
if not show_deleted:
comments = comments.filter(is_deleted=False)
return comments
def natural_created_at(self):
if self.created_at > datetime.utcnow() - timedelta(days=7):
return naturaltime(self.created_at)
return django_date(self.created_at, "d E Y в H:i")
def get_avatar(self):
if self.author:
return self.author.get_avatar()
return random.choice(AVATARS)

View File

View File

@ -0,0 +1,25 @@
from django import template
from django.utils.safestring import mark_safe
from common.markdown.markdown import markdown_comment
register = template.Library()
@register.simple_tag(takes_context=True)
def show_comment(context, comment):
return mark_safe(markdown_comment(comment.text))
@register.simple_tag(takes_context=True)
def mark_if_voted(context, comment, css_class, other_css_class="", ipaddress=None):
user_votes = context.get("user_votes")
if user_votes:
return css_class if str(comment.id) in user_votes else other_css_class
else:
return other_css_class
@register.filter(is_safe=True)
def without_inline_comments(comments):
return [c for c in comments if not c.block]

74
comments/views.py Normal file
View File

@ -0,0 +1,74 @@
from datetime import datetime, timedelta
from django.conf import settings
from django.http import HttpResponseForbidden, HttpResponseNotAllowed, HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, render
from comments.forms import CommentForm
from comments.models import Comment
from posts.models import Post
from utils.request import parse_ip_address
def create_comment(request):
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
form = CommentForm(request.POST)
if not form.is_valid():
return HttpResponseBadRequest("post_slug is required")
post = get_object_or_404(Post, slug=form.cleaned_data.get("post_slug"))
if not post.is_commentable:
return render(
request, "error.html", {
"message": "Нельзя комментировать этот пост"
}
)
ipaddress = parse_ip_address(request)
same_ip_comments_24h = Comment.objects.filter(
ipaddress=ipaddress,
created_at__gte=datetime.utcnow() - timedelta(hours=24)
).count()
if same_ip_comments_24h >= settings.MAX_COMMENTS_PER_24H:
return HttpResponseBadRequest(
"Вы оставили слишком много комментариев, остановитесь"
)
comment = Comment.objects.create(
author=request.user,
author_name=request.user.username,
post=post,
block=form.cleaned_data.get("block"),
text=form.cleaned_data.get("text"),
created_at=datetime.utcnow(),
)
Post.objects.filter(id=post.id).update(
comment_count=Comment.objects.filter(post=post, is_visible=True, is_deleted=False).count()
)
if comment.block:
response_template = "comments/partials/create-inline-comment-response.html"
else:
response_template = "comments/partials/create-comment-response.html"
return render(request, response_template, {
"post": post,
"comment": comment,
"block": comment.block,
})
def delete_comment(request, comment_id):
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
if not request.user.is_superuser:
return HttpResponseForbidden()
comment = get_object_or_404(Comment, id=comment_id)
comment.delete()
return HttpResponse("☠️ Комментарий удален")

0
common/__init__.py Normal file
View File

1764
common/emoji.py Normal file

File diff suppressed because it is too large Load Diff

0
common/epub/__init__.py Normal file
View File

109
common/epub/epub.py Normal file
View File

@ -0,0 +1,109 @@
import io
import re
import mimetypes
from urllib.parse import urlparse
import requests
from django.template.loader import render_to_string
from ebooklib import epub
from common.markdown.epub import markdown_epub
def generate_epub(story):
book = epub.EpubBook()
book.set_identifier("vas3k_ru_{}".format(story.slug))
book.set_language("ru")
book.add_author("vas3k.ru")
book.add_metadata('DC', 'description', story.preview_text or story.subtitle)
if story.subtitle:
book.set_title("{}. {}".format(story.title, story.subtitle))
else:
book.set_title(story.title)
if story.book_image:
book.set_cover("cover.jpg", requests.get(story.book_image).content)
else:
book.set_cover("cover.jpg", requests.get(story.image).content)
css = epub.EpubItem(
uid="style",
file_name="styles/index.css",
media_type="text/css",
content=render_to_string("epub/style.css")
)
book.add_item(css)
intro = epub.EpubHtml(title=story.title, file_name="title.xhtml", lang="ru")
intro.content = render_to_string("epub/title.html", {
"story": story
})
book.add_item(intro)
spine = [intro]
xhtml = markdown_epub(story.text)
xhtml = bundle_images(from_text=xhtml, book=book)
for index, content in break_pages(xhtml):
if not content:
continue
chapter = epub.EpubHtml(
uid="chap_{}".format(index),
title=story.title,
file_name="chap_{}.xhtml".format(index),
lang="ru"
)
chapter.content = content
chapter.add_item(css)
book.add_item(chapter)
spine.append(chapter)
# add default NCX and Nav file
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
# create spine (required for epub)
book.spine = spine
mem_file = io.BytesIO()
epub.write_epub(mem_file, book, {})
return mem_file.getvalue()
def bundle_images(from_text, book):
pattern = r"<!--BUNDLE\[(.*?)\]-->"
images = re.findall(pattern, from_text)
for image in set(list(images)):
if "youtu" in image:
continue
file_name = image[image.rfind("/") + 1:]
file_type = guess_mimetype(file_name)
content = requests.get(image).content
book.add_item(
epub.EpubItem(
uid=file_name,
file_name="images/{}".format(file_name),
media_type=file_type,
content=content
)
)
from_text = from_text.replace(image, "images/{}".format(file_name))
return from_text
def guess_mimetype(file_name):
mime, _ = mimetypes.guess_type(file_name)
if not mime:
path = urlparse(file_name).path
mime, _ = mimetypes.guess_type(path or "")
if not mime:
mime = "application/octet-stream"
return mime
def break_pages(text):
return enumerate(text.split("<!--PAGEBREAK-->"))

3
common/geoip.py Normal file
View File

@ -0,0 +1,3 @@
from geolite2 import geolite2
geoip = geolite2.reader()

20
common/languages.py Normal file
View File

@ -0,0 +1,20 @@
DEFAULT_ALLOWED = ["ru", "en"]
DEFAULT = "en"
def request_language(request, allowed=DEFAULT_ALLOWED, default=DEFAULT):
for locale in allowed:
if locale in request.GET:
return locale
header = request.META.get("HTTP_ACCEPT_LANGUAGE", "") # e.g. en-gb,en;q=0.8,es-es;q=0.5,eu;q=0.3
for locale in header.split(","):
try:
locale = locale.lower()
locale = locale.split(";")[0].lower()[:2]
except (KeyError, ValueError):
continue
else:
if locale in allowed:
return locale
return default

View File

View File

@ -0,0 +1,93 @@
import html
import mistune
from urllib.parse import unquote
from slugify import slugify
from common.regexp import IMAGE_RE, VIDEO_RE, YOUTUBE_RE, TWITTER_RE
class Vas3kRenderer(mistune.HTMLRenderer):
block_counter = 0
def paragraph(self, text: str) -> str:
if not text:
return "" # hide empty <p></p>
return super().paragraph(text)
def heading(self, text, level, **attrs):
anchor = slugify(text[:24])
return f"<div class=\"header-{level}\" id=\"{anchor}\"><a href=\"#{anchor}\">{text}</a></div>\n"
def link(self, text, url, title=None):
if not text and not title:
# it's a pure link (without link tag) and we can try to parse it
embed = self.embed(url, text or "", title or "")
if embed:
return embed
if text is None:
text = url
# here's some magic of unescape->unquote->escape
# to fix cyrillic (and other non-latin) wikipedia URLs
return f'<a href="{self.safe_url(url)}">{text or url}</a>'
def image(self, alt, url, title=None):
embed = self.embed(url, alt, title)
if embed:
return embed
# users can try to "hack" our parser by using non-image urls
# so, if its not an image or video, display it as a link to avoid auto-loading
return f'<a href="{mistune.escape(url)}">{mistune.escape(url)}</a>'
def embed(self, src, alt="", title=None):
if IMAGE_RE.match(src):
return self.simple_image(src, alt, title)
if YOUTUBE_RE.match(src):
return self.youtube(src, alt, title)
if VIDEO_RE.match(src):
return self.video(src, alt, title)
if TWITTER_RE.match(src):
return self.tweet(src, alt, title)
return None
def simple_image(self, src, alt="", title=None):
title = title or alt
image_tag = f'<img src="{mistune.escape(src)}" alt="{mistune.escape(title)}">'
caption = f"<figcaption>{title}</figcaption>" if title else ""
return f'<figure>{image_tag}{caption}</figure>'
def youtube(self, src, alt="", title=None):
youtube_match = YOUTUBE_RE.match(src)
playlist = ""
if youtube_match.group(2):
playlist = f"list={mistune.escape(youtube_match.group(2))}&listType=playlist&"
video_tag = (
f'<span class="ratio-16-9">'
f'<iframe loading="lazy" src="https://www.youtube.com/embed/{mistune.escape(youtube_match.group(1) or "")}'
f'?{playlist}autoplay=0&amp;controls=1&amp;showinfo=1&amp;vq=hd1080"'
f'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"'
f'allowfullscreen></iframe>'
f"</span>"
)
caption = f"<figcaption>{mistune.escape(title)}</figcaption>" if title else ""
return f"<figure>{video_tag}{caption}</figure>"
def video(self, src, alt="", title=None):
video_tag = (
f'<video src="{mistune.escape(src)}" controls autoplay loop muted playsinline>{mistune.escape(alt)}</video>'
)
caption = f"<figcaption>{mistune.escape(title)}</figcaption>" if title else ""
return f"<figure>{video_tag}{caption}</figure>"
def tweet(self, src, alt="", title=None):
tweet_match = TWITTER_RE.match(src)
twitter_tag = f'<blockquote class="twitter-tweet" tw-align-center>' \
f'<a href="{tweet_match.group(1)}"></a></blockquote><br>' \
f'<a href="{src}" target="_blank">{src}</a>'
return twitter_tag

View File

@ -0,0 +1,26 @@
import mistune
from common.markdown.club_renderer import Vas3kRenderer
from common.regexp import YOUTUBE_RE
class EmailRenderer(Vas3kRenderer):
def simple_image(self, src, alt="", title=None):
return f"""<img src="{src}" alt="{alt}" width="600" border="0"><br>{title or ""}"""
def youtube(self, src, alt="", title=None):
youtube_match = YOUTUBE_RE.match(src)
youtube_id = mistune.escape(youtube_match.group(1) or "")
return f'<a href="{mistune.escape(src)}"><span class="ratio-16-9 video-preview" ' \
f'style="background-image: url(\'https://img.youtube.com/vi/{mistune.escape(youtube_id)}/0.jpg\');">' \
f'</span></a><br>{mistune.escape(title or "")}'
def video(self, src, alt="", title=None):
return f'<video src="{mistune.escape(src)}" controls autoplay loop muted playsinline>{alt}</video><br>{title or ""}'
def tweet(self, src, alt="", title=None):
return f'<a href="{mistune.escape(src)}">{mistune.escape(src)}</a><br>{mistune.escape(title or "")}'
def heading(self, text, level, **attrs):
tag = f"h{level}"
return f"<{tag}>{text}</{tag}>\n"

View File

@ -0,0 +1,50 @@
import mistune
from common.markdown.club_renderer import Vas3kRenderer
from common.markdown.email_renderer import EmailRenderer
from common.markdown.plain_renderer import PlainRenderer
from common.markdown.plugins.cite_block import cite_block
from common.markdown.plugins.media_block import media_block
from common.markdown.plugins.spoiler import spoiler
from common.markdown.plugins.text_block import text_block
def markdown_text(text, renderer=Vas3kRenderer):
markdown = mistune.create_markdown(
escape=False,
hard_wrap=True,
renderer=renderer(escape=False) if renderer else None,
plugins=[
"strikethrough",
"url",
"table",
"speedup",
text_block,
media_block,
spoiler,
cite_block,
]
)
return markdown(text)
def markdown_comment(text, renderer=Vas3kRenderer):
markdown = mistune.create_markdown(
escape=True,
hard_wrap=True,
renderer=renderer(escape=True) if renderer else None,
plugins=[
"strikethrough",
"url",
"speedup",
]
)
return markdown(text)
def markdown_plain(text):
return markdown_text(text, renderer=PlainRenderer)
def markdown_email(text):
return markdown_text(text, renderer=EmailRenderer)

View File

@ -0,0 +1,48 @@
import mistune
class PlainRenderer(mistune.HTMLRenderer):
def link(self, link, text=None, title=None):
if text:
return f'[{text}]({link})'
else:
return f'({link})'
def image(self, src, alt="", title=None):
return "🖼"
def emphasis(self, text):
return text
def strong(self, text):
return text
def codespan(self, text):
return text
def linebreak(self):
return "\n"
def paragraph(self, text):
return text + "\n\n"
def heading(self, text, level):
return text + "\n\n"
def newline(self):
return "\n"
def block_quote(self, text):
return "> " + text
def block_code(self, code, info=None):
return code
def list(self, text, ordered, level, start=None):
return text
def list_item(self, text, level):
return "- " + text + "\n"
def thematic_break(self):
return '---\n'

View File

View File

@ -0,0 +1,32 @@
__all__ = ["cite_block"]
from common.markdown.plugins.utils import parse_classes
CITE_BLOCK_PATTERN = r'^\% (?P<cite_block_text>[\s\S]+?)$'
def parse_cite_block(block, m, state):
text = m.group("cite_block_text")
child = state.child_state(text)
block.parse(child)
state.append_token({"type": "cite_block", "children": child.tokens})
return m.end()
def render_cite_block(renderer, text, **attrs):
return f'<div class="block-cite">{text}</div>\n'
def cite_block(md):
"""
Custom plugin which supports block-like things:
% some text
They are custom to vas3k blog
"""
md.block.register("cite_block", CITE_BLOCK_PATTERN, parse_cite_block)
if md.renderer and md.renderer.NAME == "html":
md.renderer.register("cite_block", render_cite_block)

View File

@ -0,0 +1,37 @@
__all__ = ["media_block"]
from common.markdown.plugins.utils import parse_classes_and_ids
MEDIA_BLOCK_PATTERN = r'\{{3}(?P<media_block_classes>[^\s]+)?(?P<media_block_text>[\s\S]+?)\}{3}'
def parse_media_block(block, m, state):
text = m.group("media_block_text")
classes = m.group("media_block_classes")
child = state.child_state(text)
block.parse(child)
state.append_token({"type": "media_block", "children": child.tokens, "attrs": {"classes": classes}})
return m.end()
def render_media_block(renderer, text, **attrs):
text = text.replace("<p>", "").replace("</p>", "") # dirty hack to fix some browsers
classes, ids = parse_classes_and_ids(attrs.get("classes") or "")
return f'<div class="block-media {" ".join(classes)}" id="{" ".join(ids)}">{text}</div>\n'
def media_block(md):
"""
Custom plugin which supports media-like things:
{{{
some text
}}}
They are custom to vas3k blog
"""
md.block.register("media_block", MEDIA_BLOCK_PATTERN, parse_media_block)
if md.renderer and md.renderer.NAME == "html":
md.renderer.register("media_block", render_media_block)

View File

@ -0,0 +1,34 @@
__all__ = ["spoiler"]
SPOILER_BLOCK_PATTERN = r'\[\?(?P<spoiler_text>.+?)\?\]'
def parse_spoiler_block(inline, m, state):
text = m.group("spoiler_text")
new_state = state.copy()
new_state.src = text
children = inline.render(new_state)
state.append_token({"type": "spoiler", "children": children})
return m.end()
def render_spoiler(renderer, text):
return f"<span class=\"block-spoiler\">" \
f"<span class=\"block-spoiler-button\">?</span>" \
f"<span class=\"block-spoiler-text\">{text}</span>" \
f"</span>\n\n"
def spoiler(md):
"""
Custom plugin which supports spoilers in text
Some text [? spoiler ?] other text
They are custom to vas3k blog
"""
md.inline.register("spoiler", SPOILER_BLOCK_PATTERN, parse_spoiler_block, before="link")
if md.renderer and md.renderer.NAME == "html":
md.renderer.register("spoiler", render_spoiler)

View File

@ -0,0 +1,42 @@
__all__ = ["text_block"]
from common.markdown.plugins.utils import parse_classes_and_ids
TEXT_BLOCK_PATTERN = r'\[{3}(?P<text_block_classes>[^\s]+)?(?P<text_block_text>[\s\S]+?)\]{3}'
def parse_text_block(block, m, state):
text = m.group("text_block_text")
classes = m.group("text_block_classes")
child = state.child_state(text)
block.parse(child)
state.append_token({"type": "text_block", "children": child.tokens, "attrs": {"classes": classes}})
return m.end()
def render_text_block(renderer, text, **attrs):
block_counter = 0
if hasattr(renderer, "block_counter"):
renderer.block_counter += 10
block_counter = renderer.block_counter
classes, ids = parse_classes_and_ids(attrs.get("classes") or "")
return f'<div class="block-text {" ".join(classes)}" id="{" ".join(ids)}">' \
f'{text}' \
f'<br><br>[commentable {block_counter}]</div>\n'
def text_block(md):
"""
Custom plugin which supports block-like things:
[[[
some text
]]]
They are custom to vas3k blog
"""
md.block.register("text_block", TEXT_BLOCK_PATTERN, parse_text_block)
if md.renderer and md.renderer.NAME == "html":
md.renderer.register("text_block", render_text_block)

View File

@ -0,0 +1,16 @@
def parse_classes(value):
return [
klass for klass in str(value or "").split(".") if klass and not klass.startswith("#")
]
def parse_ids(value):
return [
klass[1:] for klass in str(value or "").split(".") if klass and klass.startswith("#")
]
def parse_classes_and_ids(value):
classes = parse_classes(value)
ids = parse_ids(value)
return classes, ids

53
common/parsers.py Normal file
View File

@ -0,0 +1,53 @@
import re
import requests
from bs4 import BeautifulSoup
TAGS = {
"title": "title"
}
OG_TAGS = {
"og:image": "image",
"og:title": "title",
"og:description": "description",
"og:url": "url"
}
TWITTER_TAGS = {
"twitter:image": "image",
"twitter:image:src": "image",
"twitter:title": "title",
"twitter:description": "description",
"twitter:url": "url"
}
USERAGENT = "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) " \
"Chrome/41.0.2228.0 Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
def parse_metatags(url):
try:
page = requests.get(url, headers={"User-Agent": USERAGENT}, timeout=5.0)
except:
return {}
if not page.headers.get("Content-Type", "").startswith("text/html"):
return {}
document = BeautifulSoup(page.text, "html.parser")
metadata = {
"title": document.title.string
}
for meta in document.html.head.findAll(property=re.compile(r'^twitter')):
meta_property = meta.get("property")
if meta_property and meta_property in TWITTER_TAGS:
meta_content = meta.get("content")
if meta_content:
metadata[TWITTER_TAGS[meta_property]] = meta_content
for meta in document.html.head.findAll(property=re.compile(r'^og')):
meta_property = meta.get("property")
if meta_property and meta_property in OG_TAGS:
meta_content = meta.get("content")
if meta_content:
metadata[OG_TAGS[meta_property]] = meta_content
return metadata

29
common/regexp.py Normal file
View File

@ -0,0 +1,29 @@
import re
IMAGE_RE = re.compile(r"(http(s?):)([/|.|\w|\s|-])*\.(?:jpg|jpeg|gif|png)")
VIDEO_RE = re.compile(r"(http(s?):)([/|.|\w|\s|-])*\.(?:mov|mp4|m4v|webm)")
YOUTUBE_RE = re.compile(
(
r"http(?:s?):\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|playlist\?)|youtu\.be\/)"
r"(?:(?:(?<=\bv=)|(?<=youtu\.be\/))([\w\-\_]*))?(?:.*list=(PL[\w\-\_]*))?"
)
)
TWITTER_RE = re.compile(r"(https?:\/\/twitter.com\/[a-zA-Z0-9_]+\/status\/[\d]+)")
FAVICON_RE = re.compile(r"(http(s?):)([/|.|\w|\s|-])*\.(?:jpg|jpeg|gif|png|ico)")
EMOJI_RE = re.compile(
"["
"\U0001F1E0-\U0001F1FF" # flags (iOS)
"\U0001F300-\U0001F5FF" # symbols & pictographs
"\U0001F600-\U0001F64F" # emoticons
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F700-\U0001F77F" # alchemical symbols
"\U0001F780-\U0001F7FF" # Geometric Shapes Extended
"\U0001F800-\U0001F8FF" # Supplemental Arrows-C
"\U0001F900-\U0001F9FF" # Supplemental Symbols and Pictographs
"\U0001FA00-\U0001FA6F" # Chess Symbols
"\U0001FA70-\U0001FAFF" # Symbols and Pictographs Extended-A
"\U00002702-\U000027B0" # Dingbats
"\U000024C2-\U0001F251"
"]+"
)

View File

@ -0,0 +1,35 @@
version: "3"
services:
club_app:
image: ghcr.io/vas3k/blog:${GITHUB_SHA:-latest}
command: make docker-run-production
container_name: blog_app
environment:
- PYTHONUNBUFFERED=1
- DEBUG=false
- POSTGRES_DB=vas3k_blog
- POSTGRES_USER=vas3k
- POSTGRES_PASSWORD=vas3k
- POSTGRES_HOST=postgres
- EMAIL_HOST
- EMAIL_PORT
env_file:
- .env
restart: always
depends_on:
- postgres
ports:
- "127.0.0.1:8814:8814"
postgres:
image: postgres:15
container_name: blog_postgres
restart: always
environment:
POSTGRES_USER: vas3k
POSTGRES_PASSWORD: vas3k
POSTGRES_DB: vas3k_blog
volumes:
- /home/vas3k/pgdata:/var/lib/postgresql/data:rw
ports:
- "127.0.0.1:54334:5432"

35
docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
version: "3.7"
services:
club_app:
build:
dockerfile: Dockerfile
context: .
command: make docker-run-dev
container_name: blog_app
environment:
- DEBUG=true
- PYTHONUNBUFFERED=1
- POSTGRES_DB=vas3k_blog
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST=postgres
env_file:
- .env
restart: always
volumes:
- .:/app:delegated # enable hot code reload in debug mode
depends_on:
- postgres
ports:
- "8000:8000"
postgres:
image: postgres:15
container_name: blog_postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=vas3k_blog
ports:
- "5432:5432"

65
etc/nginx/vas3k.blog.conf Normal file
View File

@ -0,0 +1,65 @@
limit_req_zone $binary_remote_addr zone=vas3k_blog_limit:10m rate=3r/s;
server {
listen 80;
listen [::]:80;
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name www.vas3k.blog www.vas3k.ru vas3k.ru;
rewrite ^(.*) https://vas3k.blog$1 permanent;
}
server {
listen 80;
listen [::]:80;
server_name vas3k.blog vas3k.ru;
return 301 https://vas3k.blog$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name vas3k.blog;
charset utf-8;
client_max_body_size 15M;
index index.html index.htm;
set_real_ip_from 172.17.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
rewrite ^/favicon.ico$ https://vas3k.blog/static/images/favicon.ico;
rewrite ^/favicon.png$ https://vas3k.blog/static/images/favicon_32.png;
ssl_certificate /home/vas3k/certs/pubkey.pem;
ssl_certificate_key /home/vas3k/certs/privkey.pem;
location /static/ {
root /home/vas3k/vas3k.blog/frontend/;
gzip_static on;
expires max;
add_header Cache-Control "public";
}
location / {
limit_req zone=vas3k_blog_limit burst=50 nodelay;
add_header "Access-Control-Allow-Origin" "*";
add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS";
add_header "Access-Control-Allow-Headers" "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range";
add_header "Access-Control-Expose-Headers" "Content-Length,Content-Range";
add_header "Strict-Transport-Security" "max-age=31536000;includeSubDomains";
add_header "X-Content-Type-Options" "nosniff";
add_header "Referrer-Policy" "strict-origin-when-cross-origin";
add_header "Permissions-Policy" "accelerometer=(),camera=(),geolocation=(),gyroscope=(),magnetometer=(),microphone=(),payment=(),usb=()";
proxy_set_header "Host" $http_host;
proxy_set_header "X-Forwarded-For" $proxy_add_x_forwarded_for;
proxy_set_header "X-Forwarded-Proto" $scheme;
proxy_redirect off;
proxy_buffering off;
proxy_pass http://0.0.0.0:8020;
}
}

View File

@ -0,0 +1,10 @@
<div class="clicker {% if is_voted %}status_voted{% endif %} clicker-updater">
<form action="/clickers/click/" method="post" accept-charset="utf-8" onsubmit="return ajax_click(event);">
<span class="clicker-votes count-updater">{{ votes }}</span>
<span class="clicker-text">{{ clicker }}</span>
<input type="hidden" name="story_id" value="{{ story.id }}">
<input type="hidden" name="block" value="{{ block }}">
<input type="hidden" name="author" value="{{ username }}">
<input type="submit" class="clicker__button" value="">
</form>
</div>

View File

@ -0,0 +1,5 @@
<div class="block-extra-placeholder-text">
🔒 Тут инфа только <a href="{% url "club" %}" target="_blank">для своих</a><br>
<a href="{% url "login_patreon" %}?goto={{ request.scheme }}://{{ request.get_host }}{{ story.get_absolute_url }}" class="button members-only-button">Войти через Патреон</a>
<a href="{% url "login" %}?goto={{ request.scheme }}://{{ request.get_host }}{{ story.get_absolute_url }}" class="button button-black" style="margin-top: 10px;" target="_blank">Войти через Вастрик.Клуб</a>
</div>

View File

@ -0,0 +1,27 @@
<form hx-post="{% url "create_comment" %}"
hx-swap="outerHTML"
action="{% url "create_comment" %}"
method="post"
class="comments-form"
id="comments-form"
accept-charset="utf-8"
>
{% csrf_token %}
<input type="hidden" name="post_slug" value="{{ post.slug }}">
<div class="comments-form-inputs">
<div class="comments-form-user">
<span class="avatar comments-form-avatar" style="background-image: url('{{ request.user.get_avatar }}')"></span>
</div>
<textarea cols="30" rows="10" name="text" class="comments-form-textarea" id="comment-text" tabindex="2" minlength="3" required></textarea>
</div>
<div class="comments-form-footer">
<span>
Можно использовать <a href="https://doka.guide/tools/markdown/" target="_blank">Markdown</a>
</span>
<input type="submit" tabindex="3" class="button comments-form-submit" value="Отправить">
</div>
</form>

View File

@ -0,0 +1,24 @@
{% load text_filters %}
{% load comments %}
<div class="comments">
{% if post.is_commentable or comments|length > 0 %}
<span class="header-2 comments-title">Комментарии 👇</span>
{% endif %}
<div class="comments-list" id="comments-list">
{% for comment in comments|without_inline_comments %}
{% include "comments/comment.html" %}
{% endfor %}
</div>
</div>
{% if post.is_commentable %}
{% if request.user.is_authenticated %}
{% include "comments/comment-form.html" %}
{% else %}
<div class="comments-form-login">
<a href="{% url "login" %}" class="button button-big">Войдите, чтобы написать комментарий</a>
</div>
{% endif %}
{% endif %}

View File

@ -0,0 +1,42 @@
{% load comments %}
{% load humanize %}
<div class="comment" id="comment-{{ comment.id }}">
<div class="comment-head">
{% if comment.user %}
<a href="{% url "profile" comment.user.id %}" class="avatar comment-avatar" style="background-image: url('{{ comment.get_avatar }}');"></a>
{% else %}
<span class="avatar comment-avatar" style="background-image: url('{{ comment.get_avatar }}');"></span>
{% endif %}
<span class="comment-author" onclick="return nick('{{ comment.author_name }}', '#comment-text');">
{{ comment.author_name }}
</span>
<span class="comment-date">{{ comment.natural_created_at }}</span>
<a href="#comment-{{ comment.id }}" class="comment-id">#</a>
<div
class="button button-inverted comment-rating {% mark_if_voted comment "status-voted" %}"
hx-post="{% url "click_comment" comment.id %}"
hx-swap="innerHTML"
>{{ comment.upvotes }}</div>
</div>
<div class="comment-body">
{% show_comment comment %}
</div>
{% if request.user.is_superuser %}
<div class="comment-footer">
<button
hx-post="{% url "delete_comment" comment.id %}"
hx-target="#comment-{{ comment.id }}"
hx-swap="innerHTML"
hx-confirm="Удаляем?"
>
Удалить
</button>
</div>
{% endif %}
</div>

View File

@ -0,0 +1,34 @@
{% if request.user.is_authenticated %}
<form hx-post="{% url "create_comment" %}"
hx-target="#comments-list"
hx-swap="beforeend"
action="{% url "create_comment" %}"
method="post"
class="comments-form"
id="comments-form"
accept-charset="utf-8"
>
{% csrf_token %}
<input type="hidden" name="post_slug" value="{{ post.slug }}">
<div class="comments-form-inputs">
<div class="comments-form-user">
<span class="avatar comments-form-avatar" style="background-image: url('{{ request.user.get_avatar }}')"></span>
</div>
<textarea cols="30" rows="10" name="text" class="comments-form-textarea" id="comment-text" tabindex="2" minlength="3" required></textarea>
</div>
<div class="comments-form-footer">
<span>
Можно использовать <a href="https://doka.guide/tools/markdown/" target="_blank">Markdown</a>
</span>
<input type="submit" tabindex="3" class="button comments-form-submit" value="Отправить">
</div>
</form>
{% else %}
<div class="comments-form-login">
<a href="{% url "login" %}" class="button button-big">Войдите, чтобы написать комментарий</a>
</div>
{% endif %}

View File

@ -0,0 +1,18 @@
<div class="inline-comments-form">
<form
hx-post="{% url "create_comment" %}"
hx-swap="outerHTML"
action="{% url "create_comment" %}"
method="post"
id="comments-form-{{ block }}"
accept-charset="utf-8"
>
{% csrf_token %}
<input type="hidden" name="post_slug" value="{{ post.slug }}">
<input type="hidden" name="block" value="{{ block }}">
<div class="inline-comments-form-inputs">
<textarea rows="2" name="text" class="inline-comments-form-textarea" id="inline-comment-text-{{ block }}" placeholder="Откомментировать прямо здесь..." minlength="3" maxlength="10000" required></textarea>
<input type="submit" class="button inline-comments-form-submit" value="↵">
</div>
</form>
</div>

View File

@ -0,0 +1,32 @@
{% load comments %}
{% load text_filters %}
<div class="inline-comments" id="block-{{ block }}">
<div class="inline-comments-header" onclick="return toggle(event, '.inline-comments-content', 'inline-comments-content-hidden');">
{% if block_comments|length > 0 %}
{{ block_comments|length }}&nbsp;{{ block_comments|length|rupluralize:"комментарий,комментария,комментариев" }}
{% else %}
Комментировать
{% endif %}
</div>
<div class="inline-comments-content {% if not request.user.is_authenticated %}inline-comments-content-hidden{% endif %}">
{% if block_comments %}
<div class="inline-comments-list" id="inline-comments-list-{{ block }}">
{% for comment in block_comments %}
{% include "comments/inline-comment.html" %}
{% endfor %}
</div>
{% endif %}
{% if post.is_commentable %}
{% if request.user.is_authenticated %}
{% include "comments/inline-comment-form.html" %}
{% else %}
<div class="inline-comments-login">
<a href="{% url "login" %}" class="button">Войти и написать</a>
</div>
{% endif %}
{% endif %}
</div>
</div>

View File

@ -0,0 +1,31 @@
{% load comments %}
<div class="inline-comment" id="block-{{ block }}-{{ comment.id }}" data-timestamp="{{ comment.created_at.timestamp | floatformat:"0" }}">
<div class="inline-comment-upvotes">
<div
class="button button-inverted inline-comment-rating {% mark_if_voted comment "status-voted" %}"
hx-post="{% url "click_comment" comment.id %}"
hx-swap="innerHTML"
>{{ comment.upvotes }}</div>
</div>
<div class="inline-comments-author" onclick="return nick('{{ comment.author_name }}', '#inline-comment-text-{{ block }}');">
{{ comment.author_name }}
</div>
<div class="inline-comments-body">
{% show_comment comment %}
</div>
{% if request.user.is_superuser %}
<div class="inline-comments-footer">
<button
hx-post="{% url "delete_comment" comment.id %}"
hx-target="#block-{{ block }}-{{ comment.id }}"
hx-swap="innerHTML"
hx-confirm="Удаляем?"
>
Удалить
</button>
</div>
{% endif %}
</div>

View File

@ -0,0 +1,5 @@
<div class="comments">
{% include "comments/comment.html" %}
</div>
{% include "comments/comment-form.html" %}

View File

@ -0,0 +1,3 @@
{% include "comments/inline-comment.html" %}
{% include "comments/inline-comment-form.html" %}

View File

@ -0,0 +1,36 @@
{% load static %}
<section class="contacts">
<a href="https://t.me/vas3k_channel" class="contacts-item" target="_blank" rel="me">
<i class="fab fa-telegram"></i><span>Телеграм-канал</span>
</a>
<a href="https://twitter.com/vas3k" class="contacts-item" target="_blank" rel="me">
<i class="fab fa-twitter-square"></i>
<span>Твиттер</span>
</a>
<a href="https://mastodon.online/@vas3k" class="contacts-item" target="_blank" rel="me">
<i class="fab fa-mastodon"></i>
<span>Мастодон</span>
</a>
<a href="https://www.patreon.com/vas3k" class="contacts-item" target="_blank" rel="me">
<i class="fab fa-patreon"></i>
<span>Патреон</span>
</a>
<a href="https://github.com/vas3k" class="contacts-item" target="_blank" rel="me">
<i class="fab fa-github"></i>
<span>Гитхаб</span>
</a>
<a href="mailto:me@vas3k.ru" class="contacts-item" target="_blank" rel="me">
<i class="fas fa-envelope"></i>
<span>me@vas3k.ru</span>
</a>
<a href="https://vas3k.ru/rss/" class="contacts-item" target="_blank" rel="syndication">
<i class="fas fa-rss-square"></i>
<span>RSS</span>
</a>
</section>

View File

@ -0,0 +1,7 @@
{% load static %}
<link rel="icon" type="image/png" href="{% static "images/favicon_32.png" %}" sizes="32x32">
<link rel="icon" type="image/png" href="{% static "images/favicon_64.png" %}" sizes="64x64">
<link rel="icon" type="image/png" href="{% static "images/favicon_128.png" %}" sizes="128x128">
<link rel="shortcut icon" type="image/png" href="{% static "images/favicon_square.png" %}">
<link rel="apple-touch-icon" type="image/png" href="{% static "images/favicon_square.png" %}">
<link rel="mask-icon" href="{% static "images/favicon_128.png" %}" color="#5954ca">

View File

@ -0,0 +1,49 @@
{% load static %}
<div class="header h-card" hx-boost="true">
<a href="{% url "index" %}" class="header-logo u-url">
<img src="{% static "images/logo.png" %}" alt="" class="header-logo-image u-photo">
<span class="header-logo-title p-name">Вастрик</span>
</a>
<div class="header-menu">
<a href="{% url "list_posts" "all" %}" class="button button-inverted header-menu-item">
<span>✏️</span>
<span>️Посты</span>
</a>
<a href="{% url "list_posts" "world" %}" class="button button-inverted header-menu-item">
<span>🌍</span>
<span>Путешествия</span>
</a>
<a href="{% url "subscribe" %}" class="button button-inverted header-menu-item">
<span></span>
<span>Подписаться</span>
</a>
<a href="{% url "donate" %}" class="button button-inverted header-menu-item">
<span>👍</span>
<span>Донат</span>
</a>
<div class="button button-inverted header-menu-item header-menu-round" onclick="return toggleHeaderSearch(event, '#header-search');">
🔍
</div>
<a href="{% url "profile" %}" class="button button-inverted avatar header-menu-item header-menu-round" {% if request.user.is_authenticated %}style="background-image: url('{{ request.user.avatar }}');" {% endif %}>
{% if not request.user.is_authenticated or not request.user.avatar %}👤{% endif %}
</a>
</div>
</div>
<div class="header-search header-search-hidden" id="header-search">
<div class="header-search-form">
<form action="https://www.google.ru/search?q=site%3Avas3k.ru+">
<input type="hidden" name="domains" value="vas3k.ru">
<input type="hidden" name="sitesearch" value="vas3k.ru">
<input type="text" name="q" placeholder="Искать по сайту..." class="header-search-form-input">
<button type="submit" class="header-search-form-submit">🔍</button>
</form>
</div>
</div>

View File

@ -0,0 +1,28 @@
<div class="clearfix"></div>
{% if items and num_pages > 1 %}
<div class="paginator">
{% if items.has_previous %}
<a href="?page={{ items.previous_page_number }}" class="paginator-page">&larr;</a>
{% endif %}
{% if show_first %}
<a href="?page=1" class="paginator-page">1</a>
<span class="paginator-dots">...</span>
{% endif %}
{% for page in page_numbers %}
<a href="?page={{ page }}" class="paginator-page {% if page == items.number %}paginator-page-active{% endif %}">
{{ page }}
</a>
{% endfor %}
{% if show_last %}
<span class="paginator-dots">...</span>
<a href="?page={{ num_pages }}" class="paginator-page">{{ num_pages }}</a>
{% endif %}
{% if items.has_next %}
<a href="?page={{ items.next_page_number }}" class="paginator-page">&rarr;</a>
{% endif %}
</div>
{% endif %}

View File

@ -0,0 +1,22 @@
{% load static %}
<section class="post-footer">
{% if story.is_members_only %}
<div class="post-members-share">
<div class="post-members-share-heart">
<i class="fas fa-heart"></i>
</div>
<div>
<h3>Как делиться закрытыми постами?</h3>
<ul>
<li>👍 Можно цитировать, делать скриншоты, репостить картинки, и давать ссылки на пост в паблике. Это хорошо и привлекает новых читателей</li>
<li>🙅‍♀️ Нельзя воровать весь текст или выкладывать сохранённые страницы. Это плохо и атата</li>
</ul>
</div>
</div>
{% else %}
<a href="{% url "donate" %}" class="button button-red button-huuuuge">🍺 Поблагодарить автора</a>
{% endif %}
{% include "common/post_subscribe.html" %}
</section>

View File

@ -0,0 +1,12 @@
{% load static %}
{% load text_filters %}
{% load posts %}
<section class="post-related">
<div class="post-related-title"><span><b>Еще?</b> Тогда вот</span></div>
<div class="cards-group cards-group-3x">
{% for post in related %}
{% include "posts/cards/horizontal.html" %}
{% endfor %}
</div>
</section>

View File

@ -0,0 +1,38 @@
<section class="post-subscribe">
<div class="post-subscribe-header">
✅ Подписка на Вастрика
</div>
<div class="post-subscribe-description">
🚨 Роскомнадзор банит одну соцсеть за другой,
поэтому я рекомендую подписаться как по почте (её сложнее заблокировать),
так и на вашей любимой площадке. Тогда у нас будет хотя бы два канала для связи.
</div>
<div class="post-subscribe-form">
<form action="/subscribe/" method="post">
<label class="post-subscribe-form-label" for="inside_email">Ваш электронный адрес:</label>
<div class="post-subscribe-form-fields">
{% csrf_token %}
<input type="text" name="name" style="position: absolute; left: -99999px;">
<input type="email" id="inside_email" name="email" placeholder="your@email.com" required="required">
<button type="submit" class="button button-red">Подписаться</button>
</div>
<div class="post-subscribe-form-hint">
* никакого спама, только мои уведомления
</div>
</form>
</div>
<div class="post-subscribe-sub-header">
Другие площадки 👇
</div>
<div class="post-subscribe-items">
<a href="https://t.me/vas3k_channel" class="button">Телеграм-канал</a>
<a href="https://www.patreon.com/vas3k" class="button">Патреон</a>
<a href="https://mastodon.online/@vas3k" class="button">Мастодон</a>
<a href="https://twitter.com/vas3k" class="button">Твиттер</a>
<a href="{% url "rss.full" %}" class="button">RSS</a>
</div>
</section>

View File

@ -0,0 +1 @@
<link rel="alternate" type="application/rss+xml" title="Полный фид" href="https://vas3k.ru/rss/"/>

View File

@ -0,0 +1,14 @@
{% load static %}
<script src="{% static "js/vendor/highlight.pack.js" %}" async></script>
<script src="{% static "js/vendor/tweemoji.min.js" %}"></script>
<script src="{% static "js/vendor/lightense.min.js" %}"></script>
<script src="{% static "js/vendor/htmx.min.js" %}"></script>
<script src="{% static "js/main.js" %}?v={{ settings.STYLES_HASH }}"></script>
<script src="{% static "js/comments.js" %}?v={{ settings.STYLES_HASH }}"></script>
<script>
window.addEventListener("htmx:configRequest", (event) => {
event.detail.headers["X-CSRFToken"] = "{{ csrf_token }}";
})
</script>

View File

@ -0,0 +1,13 @@
{% load static %}
<link rel="stylesheet" href="{% static "css/normalize.css" %}">
<link rel="stylesheet" href="{% static "css/fonts.css" %}">
<link rel="stylesheet" href="{% static "css/theme.css" %}?v={{ settings.STYLES_HASH }}">
<link rel="stylesheet" href="{% static "css/base.css" %}?v={{ settings.STYLES_HASH }}">
<link rel="stylesheet" href="{% static "css/layout.css" %}?v={{ settings.STYLES_HASH }}">
<link rel="stylesheet" href="{% static "css/cards.css" %}?v={{ settings.STYLES_HASH }}">
<link rel="stylesheet" href="{% static "css/posts.css" %}?v={{ settings.STYLES_HASH }}">
<link rel="stylesheet" href="{% static "css/comments.css" %}?v={{ settings.STYLES_HASH }}">
<link rel="stylesheet" href="{% static "css/donates.css" %}?v={{ settings.STYLES_HASH }}">
<link rel="stylesheet" href="{% static "css/users.css" %}?v={{ settings.STYLES_HASH }}">
<script src="https://kit.fontawesome.com/c29b827576.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="{% static "css/highlight/monokai_sublime.css" %}">

172
frontend/html/donate.html Normal file
View File

@ -0,0 +1,172 @@
{% extends "layout.html" %}
{% load static %}
{% load text_filters %}
{% block title %}
Донат — {{ settings.TITLE }}
{% endblock %}
{% block meta %}
<meta property="og:title" content="Донат — {{ settings.TITLE }}">
<meta property="og:url" content="{{ request.scheme }}://{{ request.get_host }}/donate/">
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}/static/images/favicon_128.png">
<meta property="og:description" content="">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Донат — {{ settings.TITLE }}">
<meta name="twitter:description" content="">
<meta name="twitter:image" content="{{ request.scheme }}://{{ request.get_host }}/static/images/favicon_128.png">
<meta name="twitter:image:src" content="{{ request.scheme }}://{{ request.get_host }}/static/images/favicon_128.png">
{% endblock %}
{% block body %}
<div class="container">
<div class="block donate">
<div class="block-title">
👍 Поддержать автора
</div>
<div class="block-description">
Независимые авторы в интернете — вымирающая редкость.
Они существуют только благодаря людям, которые их поддерживают.
Если вам нравится то, что я делаю, занести мне донат — это лучший способ сказать «спасибо».
</div>
<div class="donate-selector">
<div class="donate-selector-item" onclick="return showDonateTab(event, '#donate-amount-1');">300<br><small>руб</small></div>
<div class="donate-selector-item donate-selector-item-active" onclick="return showDonateTab(event, '#donate-amount-2');">512<br><small>руб</small></div>
<div class="donate-selector-item" onclick="return showDonateTab(event, '#donate-amount-3');">1499<br><small>руб</small></div>
<div class="donate-selector-item" onclick="return showDonateTab(event, '#donate-amount-4');">2500<br><small>руб</small></div>
<div class="donate-selector-item" style="line-height: 40px;" onclick="return showDonateTab(event, '#donate-amount-5');"><small>Любая сумма</small></div>
</div>
<div class="donate-amount" id="donate-amount-1">
<img src="{% static "images/donate/server.jpg" %}">
<strong>На сервер</strong><br>
Мы используем только качественное и высокопроизводительное оборудование!
</div>
<div class="donate-amount donate-amount-active" id="donate-amount-2">
<img src="{% static "images/donate/beers.jpg" %}">
<strong>512 рублей на пиво</strong><br>
Клинические исследования доказали: пиво эффективнее всего генерирует идеи для нового поста в блоге. Не знаю почему так.
</div>
<div class="donate-amount" id="donate-amount-3">
<img src="{% static "images/donate/mindset.jpg" %}">
<strong>За охуительные идеи</strong><br>
Мы всё реже говорим людям, что разделяем их идеи. Потому мы и одиноки.
Теперь можно сообщить об этом без слов.
</div>
<div class="donate-amount" id="donate-amount-4">
<img src="{% static "images/donate/oleg.jpg" %}">
<strong>Олег Юрьевич</strong><br>
Да не, никто столько не заносит. Это донат только для сильных и смелых миллионеров по жизни.
</div>
<div class="donate-amount" id="donate-amount-5">
<img src="{% static "images/donate/other.jpg" %}">
<strong>Побрацки</strong><br>
Да все и так поняли, что эти переключатели ни на что не влияют и лишь хитрая психологическая манипуляция.
</div>
</div>
<div class="block donate-service">
<div class="donate-service-description">
<strong>На карту Тиньков (карты РФ)</strong><br>
Если вы из России, перевод на карту будет проще всего. По клику откроется удобное окошко.
<br><br>
<small>
Либо по номеру карты: 5536913972058263
</small>
</div>
<div class="donate-service-button">
<a href="https://www.tinkoff.ru/rm/zubarev.vasiliy1/KlQ9b64480" class="button" target="_blank">
💳 Донатить
</a>
</div>
</div>
<div class="block donate-service">
<div class="donate-service-description">
<strong>Через Stripe (карты не РФ)</strong><br>
Позволяет напрямую задонатить мне в Евро с любых зарубежных карт. Поддерживает Google и Apple Pay.
</div>
<div class="donate-service-button">
<a href="https://donate.stripe.com/5kA8xA9RGdZscQUeUU" class="button" target="_blank">
💶 Донатить
</a>
</div>
</div>
<div class="block donate-service">
<div class="donate-service-description">
<strong>PayPal</strong><br>
Вариант для живущих заграницей и просто у кого там завалялась мелочь на аккаунте.
</div>
<div class="donate-service-button">
<a href="https://paypal.me/vas3kcom" class="button" target="_blank">
💰 PayPal.me
</a>
</div>
</div>
<div class="block donate-service">
<div class="donate-service-description">
<strong>На Патреоне</strong><br>
Там можно заносить автоматически и получить ранний доступ и прочие плюшки.
</div>
<div class="donate-service-button">
<a href="https://www.patreon.com/vas3k" class="button" target="_blank">❤️ Стать Патроном</a>
</div>
</div>
<div class="block donate-service donate-service-column">
<div class="donate-service-description">
<strong>Криптой</strong>
</div>
<div class="donate-service-selector">
<div class="donate-service-selector-item">
<span>Биткоин</span>
<img src="{% static "images/btc.png" %}" alt="Bitcoin QR code">
<small>bc1qu5m46dq4zdmyye738pqe0wjcxtgmdq20qatm6k</small>
</div>
<div class="donate-service-selector-item">
<span>Эфир или USDC/USDT</span>
<img src="{% static "images/eth.png" %}" alt="ETH/ERC20 QR code">
<small>0x35c0ef9C40568b25520d25235280e73a9bcb0d77</small>
</div>
<div class="donate-service-selector-item">
<span>Polkadot</span>
<a href="https://sub.id/13gCScunZLdXDkYKuaDi8QcsTT5nEEgxpiyYLTSHc6iCNW3b" target="_blank">
<img src="{% static "images/dot.png" %}" alt="DOT QR code">
</a>
<small>13gCScunZLdXDkYKuaDi8QcsTT5nEEgxpiyYLTSHc6iCNW3b</small>
</div>
</div>
</div>
</div>
<script>
function showDonateTab(event, tabId) {
document.querySelectorAll(".donate-selector-item").forEach(item => {
item.classList.remove("donate-selector-item-active");
});
event.target.classList.add("donate-selector-item-active");
document.querySelectorAll(".donate-amount").forEach(item => {
item.classList.remove("donate-amount-active");
});
let tab = document.querySelector(tabId);
tab.classList.add("donate-amount-active");
}
</script>
{% endblock %}

View File

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Вастрик.ру</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<style type="text/css">
/* CLIENT-SPECIFIC STYLES */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; }
/* RESET STYLES */
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
table { border-collapse: collapse !important; }
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
/* iOS BLUE LINKS */
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* GMAIL BLUE LINKS */
u + #body a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
/* SAMSUNG MAIL BLUE LINKS */
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
/* These rules adjust styles for desktop devices, keeping the email responsive for users. */
/* Some email clients don't properly apply media query-based styles, which is why we go mobile-first. */
@media screen and (min-width:600px) {
h1 { font-size: 48px !important; line-height: 48px !important; }
.intro { font-size: 24px !important; line-height: 36px !important; }
}
/* CUSTOM STYLES */
a {
color: #4682b4;
font-weight: 600;
text-decoration: underline;
}
a:hover {
color: #000000 !important;
}
main img {
width: 100%;
max-width: 800px;
padding-bottom: 20px;
}
.button {
display: inline-block;
font-size: 20px;
padding: 10px 20px;
border: solid 2px #333;
background-color: #333;
color: #FFF;
border-radius: 5px;
text-decoration: none !important;
}
.button:hover {
background-color: #FFF !important;
color: #333 !important;;
}
.button-big {
font-size: 32px;
padding: 15px 20px;
}
</style>
</head>
<body style="margin: 0 !important; padding: 0 !important;">
<!-- Some preview text. -->
<div style="display: none; max-height: 0; overflow: hidden;">
{% block preview %}{% endblock %}
</div>
<!-- This ghost table is used to constrain the width in Outlook. The role attribute is set to presentation to prevent it from being read by screenreaders. -->
<!--[if (gte mso 9)|(IE)]>
<table cellspacing="0" cellpadding="0" border="0" width="600" align="center" role="presentation"><tr><td>
<![endif]-->
<!-- The role and aria-label attributes are added to wrap the email content as an article for screen readers. Some of them will read out the aria-label as the title of the document, so use something like "An email from Your Brand Name" to make it recognizable. -->
<!-- Default styling of text is applied to the wrapper div. Be sure to use text that is large enough and has a high contrast with the background color for people with visual impairments. -->
<div role="article" aria-label="An email from Your Brand Name" lang="en" style="background-color: white; color: #2b2b2b; font-family: 'Avenir Next', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; font-weight: 400; line-height: 28px; margin: 0 auto; max-width: 600px; padding: 40px 20px 40px 20px;">
<header>
<a href="https://vas3k.blog">
<center><img src="https://vas3k.ru/static/images/favicon_128.png" alt="" height="80" width="80"></center>
</a>
<h1 style="color: #000000; font-size: 32px; font-weight: 800; line-height: 32px; margin: 48px 0; text-align: center;">
{% block title %}{% endblock %}<br>
<span style="font-size: 24px; font-weight: 600;">{% block subtitle %}{% endblock %}</span>
</h1>
</header>
<!-- Main content section. Main is a useful landmark element. -->
<main>
{% block body %}{% endblock %}
</main>
<br><br><br><br>
<!-- Footer information. Footer is a useful landmark element. -->
<footer>
<div style="border-top: solid 1px #EEEEEE; padding: 20px 0 0 0; font-size: 12px; opacity: 0.6;">
<a href="https://t.me/vas3k_channel">Телеграм</a> ·
<a href="https://twitter.com/vas3k">Твиттер</a> ·
<a href="https://www.patreon.com/vas3k">Патреон</a>
{% block footer %}{% endblock %}
</div>
</footer>
</div>
<!--[if (gte mso 9)|(IE)]>
</td></tr></table>
<![endif]-->
</body>
</html>

View File

@ -0,0 +1,60 @@
{% extends "emails/layout.html" %}
{% load text_filters %}
{% load posts %}
{% block preview %}
{{ post.title }}{% if post.subtitle %}. {{ post.subtitle }}{% endif %}
{% endblock %}
{% block title %}
{{ post.title }}
{% endblock %}
{% block subtitle %}
Новый пост на Вастрике
{% endblock %}
{% block body %}
<p>
Привет, Олимпийский!
</p>
<p>
Это Вастрик. У меня в блоге сегодня вышел новый пост и я, как и обещал, присылаю вам его одними из первых.
</p>
<a href="https://vas3k.blog/{{ post.type }}/{{ post.slug }}/">
<img src="{% if post.preview_image %}{{ post.preview_image }}{% else %}{{ post.image }}{% endif %}" alt="{{ post.title }}">
</a>
{% if post.preview_text %}
<p>
{{ post.preview_text | markdown | safe }}
</p>
{% endif %}
<p>
<a href="https://vas3k.blog/{{ post.type }}/{{ post.slug }}/" class="button button-big">
👉 Читать пост
</a>
</p>
<br><br><br><br><br>
<hr>
<p>
Я стараюсь слать письма только тем, кто их хочет и читает.
Поэтому вот вам большая кнопка, чтобы вы любой момент могли отписаться от моих писем и удалить свой e-mail из базы.
</p>
<p>
<a href="https://vas3k.blog/unsubscribe/{{ subscriber.secret_hash }}/" class="button">
☠️ Отписаться
</a>
</p>
{% endblock %}
{% block footer %}
<a href="https://vas3k.blog/unsubscribe/{{ subscriber.secret_hash }}/" style="display: inline-block; float: right;">Отписаться</a>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "emails/layout.html" %}
{% block title %}
Подтверждение подписки
{% endblock %}
{% block body %}
<p>
Привет!
</p>
<p>
Кто-то, возможно даже вы, хочет получать новые посты с блога <a href="https://vas3k.blog">Вастрика</a> на этот адрес.
Нажмите сюда, чтобы подтвердить это 👇
</p>
<p style="text-align: center;">
<a href="https://vas3k.blog/subscribe/confirm/{{ secret_hash }}/" class="button button-big">
👍 Да, я хочу подписаться
</a>
</p>
<br><br>
<p>
Если вы не знаете о чём идет речь, значит кто-то по ошибке ввёл адрес вашей почты.
Просто проигнорируйте это письмо или нажмите <a href="https://vas3k.blog/unsubscribe/{{ secret_hash }}/">сюда</a>, чтобы полностью стереть свой e-mail из базы.
В любом случае, без вашего согласия по кнопке выше я ничего слать не буду.
</p>
{% endblock %}

16
frontend/html/error.html Executable file
View File

@ -0,0 +1,16 @@
{% extends "layout.html" %}
{% block body %}
<div class="container block error">
<div class="block-icon"></div>
{% if title %}
<div class="block-title">{{ title }}</div>
{% endif %}
<div class="block-description">
{{ message | safe }}
</div>
</div>
{% endblock %}

7
frontend/html/index.html Executable file
View File

@ -0,0 +1,7 @@
{% extends "layout.html" %}
{% block body %}
{% for block in blocks %}
{% include block.template %}
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,27 @@
<section class="container block index-block-about">
<div class="index-block-about-title">
<p>
Привет 👋 Я Вастрик!
</p>
</div>
<div class="index-block-about-description">
<p>
В этом блоге я пишу о технологиях, всратом айти и выживании в творящемся вокруг киберпанке. Делаю это уже больше 10 лет.
</p>
<p>
Я <a href="https://www.linkedin.com/in/vas3k/" target="_blank">программист</a>, техлид. Живу в <a href="https://vas3k.blog/blog/go_to_berlin/" target="_blank">Берлине</a> 🇩🇪 и нежно люблю его.
До этого жил в <a href="https://vas3k.blog/world/what_is_lithuania/" target="_blank">Литве</a> 🇱🇹, а <a href="https://vas3k.blog/world/nskflat/">рос</a> и учился и Новосибирске 🥶
</p>
<p>
У меня есть пёс, <a href="https://vas3k.blog/blog/bus_2022/" target="_blank">Бус</a> и <a href="https://vas3k.club" target="_blank">Клуб</a>.
Ну и еще мелкие <a href="https://github.com/vas3k" target="_blank">пет-проджекты</a>.
</p>
</div>
<div class="index-block-about-contacts">
{% include "common/contacts.html" %}
</div>
</section>

View File

@ -0,0 +1,7 @@
<section class="container">
<div class="cards-group cards-group-1x">
{% with post=block.post %}
{% include "posts/cards/aspect.html" %}
{% endwith %}
</div>
</section>

View File

@ -0,0 +1,17 @@
<section class="container">
{% if block.title %}
<div class="header-1">
{% if block.url %}
<a href="{{ block.url }}">{{ block.title }}</a>
{% else %}
{{ block.title }}
{% endif %}
</div>
{% endif %}
<div class="cards-group cards-group-2x">
{% for post in block.posts %}
{% include "posts/cards/horizontal.html" %}
{% endfor %}
</div>
</section>

View File

@ -0,0 +1,18 @@
<section class="container container-width-max">
{% if block.title %}
<div class="header-1">
{% if block.url %}
<a href="{{ block.url }}">{{ block.title }}</a>
{% else %}
{{ block.title }}
{% endif %}
</div>
{% endif %}
<div class="cards-group cards-group-3x">
{% for post in block.posts %}
{% include "posts/cards/horizontal.html" %}
{% endfor %}
</div>
</section>

View File

@ -0,0 +1,23 @@
<section class="container container-width-max">
{% if block.title %}
<div class="header-1">
{% if block.url %}
<a href="{{ block.url }}">{{ block.title }}</a>
{% else %}
{{ block.title }}
{% endif %}
</div>
{% endif %}
{% if block.subtitle %}
<div class="header-2">
{{ block.subtitle }}
</div>
{% endif %}
<div class="cards-group cards-group-4x">
{% for post in block.posts %}
{% include "posts/cards/vertical.html" %}
{% endfor %}
</div>
</section>

View File

@ -0,0 +1,30 @@
<section class="container">
{% if block.title %}
<div class="header-1">
{{ block.title }}
</div>
{% endif %}
<div class="cards-group cards-group-3x cards-group-projects">
<a href="http://vas3k.club/" class="card card-post card-vertical" style="background-image: url('https://i.vas3k.ru/235b9705e449cd3c4e0d48ff41fe31196d6cbe359cdbd45e24359ac555fb9a0d.png');">
<span class="card-info">
<span class="card-title p-name">Вастрик.Клуб</span>
<span class="card-subtitle p-summary">Наше уютное закрытое коммьюнити на краю большого интернета</span>
</span>
</a>
<a href="https://howtoberlin.de/" class="card card-post card-vertical" style="background-image: url('https://i.vas3k.ru/fa05b3c8ef78a2df7d6098a42f110f83a2d4e50439134dabafb892a1a062cfdc.png');">
<span class="card-info">
<span class="card-title p-name">How to Berlin</span>
<span class="card-subtitle p-summary">Помогаем переехать в Берлин и не сойти с ума</span>
</span>
</a>
<a href="https://infomate.club/" class="card card-post card-vertical" style="background-image: url('https://i.vas3k.ru/47b9b0180d8eb1a9ee13ed45a0328a11da46661a15a4f813ebe10f106ca559a9.png');">
<span class="card-info">
<span class="card-title p-name">Infomate</span>
<span class="card-subtitle p-summary">Чтобы оставаться в курсе событий и получать информацию из разных источников</span>
</span>
</a>
</div>
</section>

69
frontend/html/layout.html Normal file
View File

@ -0,0 +1,69 @@
{% load static %}<!DOCTYPE html>
<html lang="ru">
<head>
<title>{% block title %}{{ settings.TITLE | safe }}{% endblock %}</title>
<meta charset="UTF-8">
<meta name="description" content="{{ settings.DESCRIPTION }}">
<meta name="keywords" content="{{ settings.KEYWORDS }}">
<meta name="author" content="{{ settings.AUTHOR }}">
<meta name="yandex-verification" content="5f66353ad89fbe6f">
<meta name="google-site-verification" content="B8zgWa65q_o7zEV-YAA3rmgq4AlT-l37W-2nNbDE6pc">
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0">
<meta name="format-detection" content="telephone=no">
{% block meta %}
<meta property="og:title" content="{{ settings.TITLE }}">
<meta property="og:url" content="{{ request.scheme }}://{{ request.get_host }}">
<meta property="og:type" content="website">
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}/{% static "images/favicon_128.png" %}">
<meta property="og:description" content="{{ settings.DESCRIPTION }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ settings.TITLE }}">
<meta name="twitter:description" content="{{ settings.DESCRIPTION }}">
<meta name="twitter:image" content="{{ request.scheme }}://{{ request.get_host }}/{% static "images/favicon_128.png" %}">
<meta name="twitter:image:src" content="{{ request.scheme }}://{{ request.get_host }}/{% static "images/favicon_128.png" %}">
{% endblock %}
{% include "common/rss.html" %}
{% include "common/favicon.html" %}
{% include "common/styles.html" %}
{% include "common/scripts.html" %}
{% block css %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}" style="{% block body_styles %}{% endblock %}">
{% block menu %}
{% include "common/header.html" %}
{% endblock %}
{% block body %}{% endblock %}
{% block footer %}
<footer class="footer">
{% block footer_contacts %}
<div class="contacts">
<div>Для связи &rarr;</div>
<a href="mailto:me@vas3k.ru" class="contacts-item" target="_blank" rel="me">
<i class="fas fa-envelope"></i>
<span>me@vas3k.ru</span>
</a>
<a href="https://t.me/to_vas3k_bot" class="contacts-item" target="_blank" rel="me">
<i class="fab fa-telegram"></i>
<span>Телеграм-бот</span>
</a>
<a href="https://twitter.com/vas3k" class="contacts-item" target="_blank" rel="me">
<i class="fab fa-twitter-square"></i>
<span>Твиттер</span>
</a>
</div>
{% endblock %}
<div class="footer-disclaimer">
Вы можете использовать цитаты из статей, картинки и скриншоты в своих блогах и презентациях, если поставите ссылку на оригинальный пост.
Присылайте ссылки на них мне в личку, мне будет приятно посмотреть как моё странное творчество идет в народ.
В коммерческих целях ничего использовать нельзя.
</div>
</footer>
{% endblock %}
{% block js %}{% endblock %}
</body>
</html>

15
frontend/html/message.html Executable file
View File

@ -0,0 +1,15 @@
{% extends "layout.html" %}
{% block body %}
<div class="container block message">
<div class="block-icon"></div>
{% if title %}
<div class="block-title">{{ title }}</div>
{% endif %}
<div class="block-description">
{{ message | safe }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% load text_filters %}
{% load posts %}
<a href="{{ post.get_absolute_url }}" class="card card-post card-horizontal h-entry" style="background-image: url('{{ post.main_image }}');">
<img src="{{ post.main_image }}" alt="" class="card-stretch-img u-photo">
{% if post.is_members_only %}
<span class="card-icon-lock">
{% if me %}<i class="fas fa-lock-open"></i>{% else %}<i class="fas fa-lock"></i>{% endif %}
</span>
{% endif %}
<span class="card-views"><i class="fas fa-eye"></i>&nbsp;{{ post.view_count|cool_number }}</span>
<span class="card-info">
<span class="card-title p-name" {% if post.data.headline_title_size %}style="font-size: {{ post.data.headline_title_size }};"{% endif %}>{{ post.title }}</span>
{% if post.subtitle %}
<span class="clearfix10"></span>
<span class="card-subtitle p-summary">{{ post.subtitle }}</span>
{% endif %}
</span>
</a>

View File

@ -0,0 +1,17 @@
{% load text_filters %}
{% load posts %}
<a href="{{ post.get_absolute_url }}" class="card card-post card-horizontal h-entry" style="background-image: url('{{ post.main_image }}');">
{% if post.is_members_only %}
<span class="card-icon-lock">
{% if me %}<i class="fas fa-lock-open"></i>{% else %}<i class="fas fa-lock"></i>{% endif %}
</span>
{% endif %}
<span class="card-views"><i class="fas fa-eye"></i>&nbsp;{{ post.view_count|cool_number }}</span>
<span class="card-info">
<span class="card-title p-name" {% if post.data.headline_title_size %}style="font-size: {{ post.data.headline_title_size }};"{% endif %}>{{ post.title }}</span>
{% if post.subtitle %}
<span class="clearfix10"></span>
<span class="card-subtitle p-summary">{{ post.subtitle }}</span>
{% endif %}
</span>
</a>

Some files were not shown because too many files have changed in this diff Show More