diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 66346290..d659eb9d 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -4,8 +4,8 @@ on: [push, pull_request] jobs: push_to_docker_hub: - name: Push image to Docker Hub - if: github.repository_owner == 'alphatownsman' + name: build image and push to Docker Hub + if: github.repository_owner == 'neodb-social' runs-on: ubuntu-latest steps: - name: Check out the repo diff --git a/boofilsic/settings.py b/boofilsic/settings.py index 5ab46f96..edbefdcb 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -207,7 +207,6 @@ if DEBUG: # NEODB_STATIC_ROOT is readonly in docker mode, so we give it a writable place SASS_PROCESSOR_ROOT = "/tmp" -STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" STATICFILES_FINDERS = [ "django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder", @@ -222,10 +221,25 @@ SILENCED_SYSTEM_CHECKS = [ "fields.W344", # Required by takahe: identical table name in different database ] -TAKAHE_MEDIA_PREFIX = "/media/" +TAKAHE_MEDIA_URL = os.environ.get("TAKAHE_MEDIA_URL", "/media/") +TAKAHE_MEDIA_ROOT = os.environ.get("TAKAHE_MEDIA_ROOT", "media") MEDIA_URL = "/m/" -MEDIA_ROOT = os.environ.get("NEODB_MEDIA_ROOT", os.path.join(BASE_DIR, "media/")) - +MEDIA_ROOT = os.environ.get("NEODB_MEDIA_ROOT", os.path.join(BASE_DIR, "media")) +STORAGES = { # TODO: support S3 + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage", + }, + "takahe": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + "OPTIONS": { + "location": TAKAHE_MEDIA_ROOT, + "base_url": TAKAHE_MEDIA_URL, + }, + }, +} SITE_DOMAIN = os.environ.get("NEODB_SITE_DOMAIN", "nicedb.org") SITE_INFO = { "site_name": os.environ.get("NEODB_SITE_NAME", "NiceDB"), diff --git a/catalog/views.py b/catalog/views.py index 1e65e0cc..301f6d86 100644 --- a/catalog/views.py +++ b/catalog/views.py @@ -9,6 +9,7 @@ from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext_lazy as _ from django.views.decorators.clickjacking import xframe_options_exempt +from django.views.decorators.http import require_http_methods from common.config import PAGE_LINK_NUMBER from common.utils import PageLinksGenerator, get_uuid_or_404 @@ -255,6 +256,7 @@ def reviews(request, item_path, item_uuid): ) +@require_http_methods(["GET"]) def discover(request): if request.method != "GET": raise BadRequest() diff --git a/common/templates/_field.html b/common/templates/_field.html new file mode 100644 index 00000000..79b7204a --- /dev/null +++ b/common/templates/_field.html @@ -0,0 +1,19 @@ +
diff --git a/common/templatetags/mastodon.py b/common/templatetags/mastodon.py index 982edf99..748ddefc 100644 --- a/common/templatetags/mastodon.py +++ b/common/templatetags/mastodon.py @@ -21,6 +21,7 @@ def current_user_relationship(context, target_identity: "APIdentity"): ) r = { "requesting": False, + "requested": False, "following": False, "muting": False, "rejecting": False, @@ -33,6 +34,7 @@ def current_user_relationship(context, target_identity: "APIdentity"): r["rejecting"] = True else: r["requesting"] = current_identity.is_requesting(target_identity) + r["requested"] = current_identity.is_requested(target_identity) r["muting"] = current_identity.is_muting(target_identity) r["following"] = current_identity.is_following(target_identity) if r["following"]: diff --git a/docker-compose.yml b/docker-compose.yml index eeefb75d..912aa7e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ x-shared: NEODB_TYPESENSE_PORT: 8108 NEODB_TYPESENSE_KEY: eggplant NEODB_FROM_EMAIL: no-reply@${NEODB_SITE_DOMAIN} - NEODB_MEDIA_ROOT: /www/m/ + NEODB_MEDIA_ROOT: /www/m TAKAHE_DB_NAME: takahe TAKAHE_DB_USER: takahe TAKAHE_DB_PASSWORD: aubergine @@ -47,7 +47,7 @@ x-shared: TAKAHE_DATABASE_SERVER: postgres://takahe:aubergine@takahe-db/takahe TAKAHE_CACHES_DEFAULT: redis://redis:6379/0 TAKAHE_MEDIA_BACKEND: local://www/media/ - TAKAHE_MEDIA_ROOT: /www/media/ + TAKAHE_MEDIA_ROOT: /www/media TAKAHE_USE_PROXY_HEADERS: true TAKAHE_STATOR_CONCURRENCY: 4 TAKAHE_STATOR_CONCURRENCY_PER_MODEL: 2 @@ -147,7 +147,7 @@ services: <<: *neodb-service # ports: # - "18000:8000" - command: /neodb/.venv/bin/gunicorn boofilsic.wsgi -w ${NEODB_WEB_WORKER_NUM:-8} --preload -b 0.0.0.0:8000 + command: /neodb/.venv/bin/gunicorn boofilsic.wsgi -w ${NEODB_WEB_WORKER_NUM:-8} --preload --max-requests 1000 -b 0.0.0.0:8000 healthcheck: test: ['CMD', 'wget', '-qO/tmp/test', 'http://127.0.0.1:8000/nodeinfo/2.0/'] depends_on: @@ -172,7 +172,7 @@ services: <<: *neodb-service # ports: # - "19000:8000" - command: /takahe/.venv/bin/gunicorn --chdir /takahe takahe.wsgi -w ${TAKAHE_WEB_WORKER_NUM:-8} --preload -b 0.0.0.0:8000 + command: /takahe/.venv/bin/gunicorn --chdir /takahe takahe.wsgi -w ${TAKAHE_WEB_WORKER_NUM:-8} --max-requests 1000 --preload -b 0.0.0.0:8000 healthcheck: test: ['CMD', 'wget', '-qO/tmp/test', 'http://127.0.0.1:8000/nodeinfo/2.0/'] depends_on: diff --git a/misc/bin/nginx-start b/misc/bin/nginx-start index bfc9bfc3..d2148be7 100755 --- a/misc/bin/nginx-start +++ b/misc/bin/nginx-start @@ -1,3 +1,4 @@ #!/bin/sh +chown app:app /www/media /www/m envsubst '${NEODB_WEB_SERVER} ${TAKAHE_WEB_SERVER}' < $NGINX_CONF > /etc/nginx/conf.d/neodb.conf nginx -g 'daemon off;' diff --git a/takahe/models.py b/takahe/models.py index 5a902907..e16e7df5 100644 --- a/takahe/models.py +++ b/takahe/models.py @@ -1,4 +1,5 @@ import datetime +import os import re import secrets import ssl @@ -15,10 +16,12 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding, rsa from django.conf import settings from django.contrib.auth.models import AbstractBaseUser, BaseUserManager +from django.core.files.storage import FileSystemStorage from django.db import models, transaction from django.template.defaultfilters import linebreaks_filter from django.utils import timezone from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ from loguru import logger from lxml import etree @@ -280,6 +283,24 @@ class Domain(models.Model): return self.domain +def upload_store(): + return FileSystemStorage( + location=settings.TAKAHE_MEDIA_ROOT, base_url=settings.TAKAHE_MEDIA_URL + ) + + +def upload_namer(prefix, instance, filename): + """ + Names uploaded images. + + By default, obscures the original name with a random UUID. + """ + _, old_extension = os.path.splitext(filename) + new_filename = secrets.token_urlsafe(20) + now = timezone.now() + return f"{prefix}/{now.year}/{now.month}/{now.day}/{new_filename}{old_extension}" + + class Identity(models.Model): """ Represents both local and remote Fediverse identities (actors) @@ -303,6 +324,8 @@ class Identity(models.Model): # state = StateField(IdentityStates) state = models.CharField(max_length=100, default="outdated") state_changed = models.DateTimeField(auto_now_add=True) + state_next_attempt = models.DateTimeField(blank=True, null=True) + state_locked_until = models.DateTimeField(null=True, blank=True, db_index=True) local = models.BooleanField(db_index=True) users = models.ManyToManyField( @@ -321,10 +344,12 @@ class Identity(models.Model): related_name="identities", ) - name = models.CharField(max_length=500, blank=True, null=True) - summary = models.TextField(blank=True, null=True) - manually_approves_followers = models.BooleanField(blank=True, null=True) - discoverable = models.BooleanField(default=True) + name = models.CharField(max_length=500, blank=True, null=True, verbose_name=_("昵称")) + summary = models.TextField(blank=True, null=True, verbose_name=_("简介")) + manually_approves_followers = models.BooleanField( + default=False, verbose_name=_("手工审核关注者") + ) + discoverable = models.BooleanField(default=True, verbose_name=_("允许被发现或推荐")) profile_uri = models.CharField(max_length=500, blank=True, null=True) inbox_uri = models.CharField(max_length=500, blank=True, null=True) @@ -337,12 +362,19 @@ class Identity(models.Model): featured_collection_uri = models.CharField(max_length=500, blank=True, null=True) actor_type = models.CharField(max_length=100, default="person") - # icon = models.ImageField( - # upload_to=partial(upload_namer, "profile_images"), blank=True, null=True - # ) - # image = models.ImageField( - # upload_to=partial(upload_namer, "background_images"), blank=True, null=True - # ) + icon = models.ImageField( + upload_to=partial(upload_namer, "profile_images"), + blank=True, + null=True, + verbose_name=_("头像"), + storage=upload_store, + ) + image = models.ImageField( + upload_to=partial(upload_namer, "background_images"), + blank=True, + null=True, + storage=upload_store, + ) # Should be a list of {"name":..., "value":...} dicts metadata = models.JSONField(blank=True, null=True) @@ -1177,7 +1209,7 @@ class Emoji(models.Model): def full_url(self, always_show=False) -> RelativeAbsoluteUrl: if self.is_usable or always_show: if self.file: - return AutoAbsoluteUrl(settings.TAKAHE_MEDIA_PREFIX + self.file.name) + return AutoAbsoluteUrl(settings.TAKAHE_MEDIA_URL + self.file.name) # return AutoAbsoluteUrl(self.file.url) elif self.remote_url: return ProxyAbsoluteUrl( diff --git a/takahe/utils.py b/takahe/utils.py index b2f7d9b0..206df163 100644 --- a/takahe/utils.py +++ b/takahe/utils.py @@ -551,3 +551,30 @@ class Takahe: else: child_queryset = child_queryset.unlisted(include_replies=True) return child_queryset + + @staticmethod + def html2txt(html: str) -> str: + if not html: + return "" + return FediverseHtmlParser(html).plain_text + + @staticmethod + def txt2html(txt: str) -> str: + if not txt: + return "" + return FediverseHtmlParser(linebreaks_filter(txt)).html + + @staticmethod + def update_state(obj, state): + obj.state = state + obj.state_changed = timezone.now() + obj.state_next_attempt = None + obj.state_locked_until = None + obj.save( + update_fields=[ + "state", + "state_changed", + "state_next_attempt", + "state_locked_until", + ] + ) diff --git a/users/data.py b/users/data.py index 27590a1d..eea7213c 100644 --- a/users/data.py +++ b/users/data.py @@ -68,18 +68,6 @@ def data(request): ) -@mastodon_request_included -@login_required -def account_info(request): - return render( - request, - "users/account.html", - { - "allow_any_site": settings.MASTODON_ALLOW_ANY_SITE, - }, - ) - - @login_required def data_import_status(request): return render( diff --git a/users/models/apidentity.py b/users/models/apidentity.py index cfbe3934..3e257b43 100644 --- a/users/models/apidentity.py +++ b/users/models/apidentity.py @@ -79,7 +79,11 @@ class APIdentity(models.Model): @property def avatar(self): if self.local: - return self.takahe_identity.icon_uri or static("img/avatar.svg") + return ( + self.takahe_identity.icon.url + if self.takahe_identity.icon + else static("img/avatar.svg") + ) else: return f"/proxy/identity_icon/{self.pk}/" @@ -134,6 +138,10 @@ class APIdentity(models.Model): def blocking_identities(self): return APIdentity.objects.filter(pk__in=self.blocking) + @property + def requested_follower_identities(self): + return APIdentity.objects.filter(pk__in=self.requested_followers) + @property def follow_requesting_identities(self): return APIdentity.objects.filter(pk__in=self.following_request) @@ -161,10 +169,10 @@ class APIdentity(models.Model): return Takahe.get_following_request_ids(self.pk) def accept_follow_request(self, target: "APIdentity"): - Takahe.accept_follow_request(self.pk, target.pk) + Takahe.accept_follow_request(target.pk, self.pk) def reject_follow_request(self, target: "APIdentity"): - Takahe.reject_follow_request(self.pk, target.pk) + Takahe.reject_follow_request(target.pk, self.pk) def block(self, target: "APIdentity"): Takahe.block(self.pk, target.pk) @@ -198,6 +206,9 @@ class APIdentity(models.Model): def is_requesting(self, target: "APIdentity"): return target.pk in self.following_request + def is_requested(self, target: "APIdentity"): + return target.pk in self.requested_followers + def is_followed_by(self, target: "APIdentity"): return target.is_following(self) diff --git a/users/models/user.py b/users/models/user.py index 3776353d..112358c2 100644 --- a/users/models/user.py +++ b/users/models/user.py @@ -186,14 +186,7 @@ class User(AbstractUser): @property def avatar(self): - if self.mastodon_account: - return self.mastodon_account.get("avatar") or static("img/avatar.svg") - if self.email: - return ( - "https://www.gravatar.com/avatar/" - + hashlib.md5(self.email.lower().encode()).hexdigest() - ) - return static("img/avatar.svg") + return self.identity.avatar if self.identity else static("img/avatar.svg") @property def handler(self): diff --git a/users/profile.py b/users/profile.py new file mode 100644 index 00000000..994a7c81 --- /dev/null +++ b/users/profile.py @@ -0,0 +1,84 @@ +from datetime import timedelta +from typing import Any, Dict +from urllib.parse import quote + +import django_rq +from django import forms +from django.conf import settings +from django.contrib import auth, messages +from django.contrib.auth import authenticate +from django.contrib.auth.decorators import login_required +from django.core.cache import cache +from django.core.exceptions import BadRequest, ObjectDoesNotExist +from django.core.mail import send_mail +from django.core.signing import TimestampSigner +from django.core.validators import EmailValidator +from django.db.models import Count, Q +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from loguru import logger + +from common.config import * +from common.utils import AuthedHttpRequest +from journal.exporters.doufen import export_marks_task +from journal.importers.douban import DoubanImporter +from journal.importers.goodreads import GoodreadsImporter +from journal.importers.opml import OPMLImporter +from journal.models import remove_data_by_user, reset_journal_visibility_for_user +from mastodon import mastodon_request_included +from mastodon.api import * +from mastodon.api import verify_account +from social.models import reset_social_visibility_for_user +from takahe.models import Identity as TakaheIdentity +from takahe.utils import Takahe + +from .models import Preference, User +from .tasks import * + + +class ProfileForm(forms.ModelForm): + class Meta: + model = TakaheIdentity + fields = [ + "name", + "summary", + "manually_approves_followers", + "discoverable", + "icon", + ] + + def clean_summary(self): + return Takahe.txt2html(self.cleaned_data["summary"]) + + +@login_required +def account_info(request): + profile_form = ProfileForm( + instance=request.user.identity.takahe_identity, + initial={ + "summary": Takahe.html2txt(request.user.identity.summary), + }, + ) + return render( + request, + "users/account.html", + { + "allow_any_site": settings.MASTODON_ALLOW_ANY_SITE, + "profile_form": profile_form, + }, + ) + + +@login_required +def account_profile(request): + if request.method == "POST": + form = ProfileForm( + request.POST, request.FILES, instance=request.user.identity.takahe_identity + ) + if form.is_valid(): + i = form.save() + Takahe.update_state(i, "edited") + return HttpResponseRedirect(reverse("users:info")) diff --git a/users/templates/users/account.html b/users/templates/users/account.html index 8373ad22..a960bb47 100644 --- a/users/templates/users/account.html +++ b/users/templates/users/account.html @@ -19,7 +19,7 @@