393 lines
14 KiB
Python
393 lines
14 KiB
Python
import string
|
|
|
|
from django import forms
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.contrib.syndication.views import Feed
|
|
from django.core import validators
|
|
from django.http import Http404, JsonResponse
|
|
from django.shortcuts import redirect
|
|
from django.utils.decorators import method_decorator
|
|
from django.utils.feedgenerator import Rss201rev2Feed
|
|
from django.utils.xmlutils import SimplerXMLGenerator
|
|
from django.views.decorators.vary import vary_on_headers
|
|
from django.views.generic import FormView, ListView
|
|
|
|
from activities.models import Post
|
|
from activities.services import SearchService, TimelineService
|
|
from core.decorators import cache_page, cache_page_by_ap_json
|
|
from core.ld import canonicalise
|
|
from core.models import Config
|
|
from users.models import Domain, FollowStates, Identity, IdentityStates
|
|
from users.services import IdentityService
|
|
from users.shortcuts import by_handle_or_404
|
|
|
|
|
|
@method_decorator(vary_on_headers("Accept"), name="dispatch")
|
|
@method_decorator(cache_page_by_ap_json(public_only=True), name="dispatch")
|
|
class ViewIdentity(ListView):
|
|
"""
|
|
Shows identity profile pages, and also acts as the Actor endpoint when
|
|
approached with the right Accept header.
|
|
"""
|
|
|
|
template_name = "identity/view.html"
|
|
paginate_by = 25
|
|
with_replies = False
|
|
|
|
def get(self, request, handle):
|
|
# Make sure we understand this handle
|
|
self.identity = by_handle_or_404(
|
|
self.request,
|
|
handle,
|
|
local=False,
|
|
fetch=True,
|
|
)
|
|
if (
|
|
not self.identity.local
|
|
and self.identity.data_age > Config.system.identity_max_age
|
|
):
|
|
self.identity.transition_perform(IdentityStates.outdated)
|
|
# If they're coming in looking for JSON, they want the actor
|
|
if request.ap_json:
|
|
# Return actor info
|
|
return self.serve_actor(self.identity)
|
|
else:
|
|
# Show normal page
|
|
return super().get(request, identity=self.identity)
|
|
|
|
def serve_actor(self, identity):
|
|
# If this not a local actor, redirect to their canonical URI
|
|
if not identity.local:
|
|
return redirect(identity.actor_uri)
|
|
return JsonResponse(
|
|
canonicalise(identity.to_ap(), include_security=True),
|
|
content_type="application/activity+json",
|
|
)
|
|
|
|
def get_queryset(self):
|
|
return TimelineService(None).identity_public(
|
|
self.identity,
|
|
include_boosts=self.identity.config_identity.boosts_on_profile,
|
|
include_replies=self.with_replies,
|
|
)
|
|
|
|
def get_context_data(self):
|
|
context = super().get_context_data()
|
|
context["identity"] = self.identity
|
|
context["public_styling"] = True
|
|
context["post_count"] = self.identity.posts.count()
|
|
context["pinned_posts"] = TimelineService(self.identity).identity_pinned()
|
|
if self.with_replies:
|
|
context["section"] = "replies"
|
|
if self.identity.config_identity.visible_follows:
|
|
context["followers_count"] = self.identity.inbound_follows.filter(
|
|
state__in=FollowStates.group_active()
|
|
).count()
|
|
context["following_count"] = self.identity.outbound_follows.filter(
|
|
state__in=FollowStates.group_active()
|
|
).count()
|
|
return context
|
|
|
|
|
|
class FeedWithImages(Rss201rev2Feed):
|
|
"""Extended Feed class to attach multiple images."""
|
|
|
|
def rss_attributes(self):
|
|
attrs = super().rss_attributes()
|
|
attrs["xmlns:media"] = "http://search.yahoo.com/mrss/"
|
|
attrs["xmlns:webfeeds"] = "http://webfeeds.org/rss/1.0"
|
|
return attrs
|
|
|
|
def add_root_elements(self, handler: SimplerXMLGenerator):
|
|
super().add_root_elements(handler)
|
|
handler.startElement("image", {})
|
|
handler.addQuickElement("url", self.feed["image"]["url"])
|
|
handler.addQuickElement("title", self.feed["image"]["title"])
|
|
handler.addQuickElement("link", self.feed["image"]["link"])
|
|
handler.endElement("image")
|
|
handler.addQuickElement(
|
|
"webfeeds:icon",
|
|
self.feed["image"]["url"],
|
|
)
|
|
|
|
def add_item_elements(self, handler: SimplerXMLGenerator, item):
|
|
super().add_item_elements(handler, item)
|
|
|
|
for image in item["images"]:
|
|
handler.startElement(
|
|
"media:content",
|
|
{
|
|
"url": image["url"],
|
|
"type": image["mime_type"],
|
|
"fileSize": image["length"],
|
|
"medium": "image",
|
|
},
|
|
)
|
|
if image["description"]:
|
|
handler.addQuickElement(
|
|
"media:description", image["description"], {"type": "plain"}
|
|
)
|
|
handler.endElement("media:content")
|
|
|
|
for hashtag in item["hashtags"]:
|
|
handler.addQuickElement("category", hashtag)
|
|
|
|
|
|
@method_decorator(
|
|
cache_page("cache_timeout_identity_feed", public_only=True), name="__call__"
|
|
)
|
|
class IdentityFeed(Feed):
|
|
"""
|
|
Serves a local user's Public posts as an RSS feed
|
|
"""
|
|
|
|
feed_type = FeedWithImages
|
|
|
|
def get_object(self, request, handle):
|
|
return by_handle_or_404(
|
|
request,
|
|
handle,
|
|
local=True,
|
|
)
|
|
|
|
def title(self, identity: Identity):
|
|
return identity.name
|
|
|
|
def description(self, identity: Identity):
|
|
return f"Public posts from @{identity.handle}"
|
|
|
|
def link(self, identity: Identity):
|
|
return identity.absolute_profile_uri()
|
|
|
|
def feed_extra_kwargs(self, identity: Identity):
|
|
"""
|
|
Return attached images data to allow `FeedWithImages.add_item_elements()`
|
|
to attach multiple images for each `<item>`.
|
|
"""
|
|
image = {
|
|
"url": identity.local_icon_url().absolute,
|
|
"title": identity.name,
|
|
"link": identity.absolute_profile_uri(),
|
|
}
|
|
return {"image": image}
|
|
|
|
def items(self, identity: Identity):
|
|
return [
|
|
e.subject_post
|
|
for e in TimelineService(None).identity_public(
|
|
identity, include_boosts=False
|
|
)[:20]
|
|
]
|
|
|
|
def item_description(self, item: Post):
|
|
return item.safe_content_remote()
|
|
|
|
def item_link(self, item: Post):
|
|
return item.absolute_object_uri()
|
|
|
|
def item_pubdate(self, item: Post):
|
|
return item.published
|
|
|
|
def item_extra_kwargs(self, item: Post):
|
|
"""
|
|
Return attached images data to allow `FeedWithImages.add_root_elements()`
|
|
to add `<image>` as RSS feed icon.
|
|
"""
|
|
images = []
|
|
for attachment in item.attachments.all():
|
|
images.append(
|
|
{
|
|
"url": attachment.full_url().absolute,
|
|
"length": (str(attachment.file.size)),
|
|
"mime_type": (attachment.mimetype),
|
|
"description": (attachment.name),
|
|
}
|
|
)
|
|
|
|
return {"images": images, "hashtags": item.hashtags or []}
|
|
|
|
|
|
class IdentityFollows(ListView):
|
|
"""
|
|
Shows following/followers for an identity.
|
|
"""
|
|
|
|
template_name = "identity/follows.html"
|
|
paginate_by = 25
|
|
inbound = False
|
|
|
|
def get(self, request, handle):
|
|
self.identity = by_handle_or_404(
|
|
self.request,
|
|
handle,
|
|
local=False,
|
|
)
|
|
if not Config.load_identity(self.identity).visible_follows:
|
|
raise Http404("Hidden follows")
|
|
return super().get(request, identity=self.identity)
|
|
|
|
def get_queryset(self):
|
|
if self.inbound:
|
|
return IdentityService(self.identity).followers()
|
|
else:
|
|
return IdentityService(self.identity).following()
|
|
|
|
def get_context_data(self):
|
|
context = super().get_context_data()
|
|
context["identity"] = self.identity
|
|
context["inbound"] = self.inbound
|
|
context["section"] = "follows"
|
|
context["public_styling"] = True
|
|
context["followers_count"] = self.identity.inbound_follows.filter(
|
|
state__in=FollowStates.group_active()
|
|
).count()
|
|
context["following_count"] = self.identity.outbound_follows.filter(
|
|
state__in=FollowStates.group_active()
|
|
).count()
|
|
context["post_count"] = self.identity.posts.count()
|
|
return context
|
|
|
|
|
|
class IdentitySearch(FormView):
|
|
"""
|
|
Allows an identity's posts to be searched.
|
|
"""
|
|
|
|
template_name = "identity/search.html"
|
|
|
|
class form_class(forms.Form):
|
|
query = forms.CharField(help_text="The text to search for")
|
|
|
|
def dispatch(self, request, handle):
|
|
self.identity = by_handle_or_404(self.request, handle)
|
|
if not Config.load_identity(self.identity).search_enabled:
|
|
raise Http404("Search not enabled")
|
|
return super().dispatch(request, identity=self.identity)
|
|
|
|
def form_valid(self, form):
|
|
self.results = SearchService(
|
|
query=form.cleaned_data["query"], identity=self.identity
|
|
).search_post_content()
|
|
return self.form_invalid(form)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context["identity"] = self.identity
|
|
context["section"] = "search"
|
|
context["public_styling"] = True
|
|
context["followers_count"] = self.identity.inbound_follows.filter(
|
|
state__in=FollowStates.group_active()
|
|
).count()
|
|
context["following_count"] = self.identity.outbound_follows.filter(
|
|
state__in=FollowStates.group_active()
|
|
).count()
|
|
context["post_count"] = self.identity.posts.count()
|
|
context["results"] = getattr(self, "results", None)
|
|
return context
|
|
|
|
|
|
@method_decorator(login_required, name="dispatch")
|
|
class CreateIdentity(FormView):
|
|
template_name = "identity/create.html"
|
|
|
|
class form_class(forms.Form):
|
|
username = forms.CharField(
|
|
help_text="Must be unique on your domain. Cannot be changed easily. Use only: a-z 0-9 _ -"
|
|
)
|
|
domain = forms.ChoiceField(
|
|
help_text="Pick the domain to make this identity on. Cannot be changed later."
|
|
)
|
|
name = forms.CharField(
|
|
help_text="The display name other users see. You can change this easily."
|
|
)
|
|
discoverable = forms.BooleanField(
|
|
help_text="If this user is visible on the frontpage and in user directories.",
|
|
initial=True,
|
|
widget=forms.Select(
|
|
choices=[(True, "Discoverable"), (False, "Not Discoverable")]
|
|
),
|
|
required=False,
|
|
)
|
|
|
|
def __init__(self, user, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields["domain"].choices = [
|
|
(domain.domain, domain.domain)
|
|
for domain in Domain.available_for_user(user)
|
|
]
|
|
self.user = user
|
|
|
|
def clean_username(self):
|
|
# Remove any leading @ and force it lowercase
|
|
value = self.cleaned_data["username"].lstrip("@").lower()
|
|
|
|
if not self.user.admin:
|
|
# Apply username min length
|
|
limit = int(Config.system.identity_min_length)
|
|
validators.MinLengthValidator(limit)(value)
|
|
|
|
# Apply username restrictions
|
|
if value in Config.system.restricted_usernames.split():
|
|
raise forms.ValidationError(
|
|
"This username is restricted to administrators only."
|
|
)
|
|
if value in ["__system__"]:
|
|
raise forms.ValidationError(
|
|
"This username is reserved for system use."
|
|
)
|
|
|
|
# Validate it's all ascii characters
|
|
for character in value:
|
|
if character not in string.ascii_letters + string.digits + "_-":
|
|
raise forms.ValidationError(
|
|
"Only the letters a-z, numbers 0-9, dashes, and underscores are allowed."
|
|
)
|
|
return value
|
|
|
|
def clean(self):
|
|
# Check for existing users
|
|
username = self.cleaned_data.get("username")
|
|
domain = self.cleaned_data.get("domain")
|
|
if (
|
|
username
|
|
and domain
|
|
and Identity.objects.filter(
|
|
username__iexact=username,
|
|
domain=domain.lower(),
|
|
).exists()
|
|
):
|
|
raise forms.ValidationError(f"{username}@{domain} is already taken")
|
|
|
|
if not self.user.admin and (
|
|
Identity.objects.filter(users=self.user).count()
|
|
>= Config.system.identity_max_per_user
|
|
):
|
|
raise forms.ValidationError(
|
|
f"You are not allowed more than {Config.system.identity_max_per_user} identities"
|
|
)
|
|
|
|
def get_form(self):
|
|
form_class = self.get_form_class()
|
|
return form_class(user=self.request.user, **self.get_form_kwargs())
|
|
|
|
def form_valid(self, form):
|
|
username = form.cleaned_data["username"]
|
|
domain = form.cleaned_data["domain"]
|
|
domain_instance = Domain.get_domain(domain)
|
|
identity = IdentityService.create(
|
|
user=self.request.user,
|
|
username=username,
|
|
domain=domain_instance,
|
|
name=form.cleaned_data["name"],
|
|
discoverable=form.cleaned_data["discoverable"],
|
|
)
|
|
self.request.session["identity_id"] = identity.id
|
|
return redirect(identity.urls.view)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context["user"] = self.request.user
|
|
if len(context["form"].fields["domain"].choices) == 0:
|
|
context["no_valid_domains"] = True
|
|
return context
|