From 81de10b70c85c5222b17d8c4358a8aa8812f2559 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Fri, 18 Nov 2022 08:28:15 -0700 Subject: Migration reset, start of docs, env vars --- users/migrations/0001_initial.py | 79 +++++++++++++++++++--- users/migrations/0002_identity_public_key_id.py | 18 ----- .../0003_user_last_seen_alter_identity_domain.py | 34 ---------- users/migrations/0004_passwordreset.py | 60 ---------------- users/migrations/0005_invite.py | 32 --------- users/models/domain.py | 7 +- users/models/password_reset.py | 2 +- users/views/activitypub.py | 59 +++++++++++++--- users/views/admin/domains.py | 52 ++++++++------ users/views/auth.py | 5 ++ users/views/identity.py | 32 ++++++++- users/views/settings.py | 4 +- 12 files changed, 195 insertions(+), 189 deletions(-) delete mode 100644 users/migrations/0002_identity_public_key_id.py delete mode 100644 users/migrations/0003_user_last_seen_alter_identity_domain.py delete mode 100644 users/migrations/0004_passwordreset.py delete mode 100644 users/migrations/0005_invite.py (limited to 'users') diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index a51ef00..d8ab363 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.3 on 2022-11-11 20:02 +# Generated by Django 4.1.3 on 2022-11-18 17:49 import functools @@ -6,10 +6,12 @@ import django.db.models.deletion from django.conf import settings from django.db import migrations, models +import core.uploads import stator.models import users.models.follow import users.models.identity import users.models.inbox_message +import users.models.password_reset class Migration(migrations.Migration): @@ -45,6 +47,7 @@ class Migration(migrations.Migration): ("deleted", models.BooleanField(default=False)), ("created", models.DateTimeField(auto_now_add=True)), ("updated", models.DateTimeField(auto_now=True)), + ("last_seen", models.DateTimeField(auto_now_add=True)), ], options={ "abstract": False, @@ -70,6 +73,7 @@ class Migration(migrations.Migration): ("local", models.BooleanField()), ("blocked", models.BooleanField(default=False)), ("public", models.BooleanField(default=False)), + ("default", models.BooleanField(default=False)), ("created", models.DateTimeField(auto_now_add=True)), ("updated", models.DateTimeField(auto_now=True)), ( @@ -111,6 +115,25 @@ class Migration(migrations.Migration): "abstract": False, }, ), + migrations.CreateModel( + name="Invite", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("token", models.CharField(max_length=500, unique=True)), + ("email", models.EmailField(blank=True, max_length=254, null=True)), + ("note", models.TextField(blank=True, null=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ], + ), migrations.CreateModel( name="UserEvent", fields=[ @@ -146,6 +169,48 @@ class Migration(migrations.Migration): ), ], ), + migrations.CreateModel( + name="PasswordReset", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state_ready", models.BooleanField(default=True)), + ("state_changed", models.DateTimeField(auto_now_add=True)), + ("state_attempted", models.DateTimeField(blank=True, null=True)), + ("state_locked_until", models.DateTimeField(blank=True, null=True)), + ( + "state", + stator.models.StateField( + choices=[("new", "new"), ("sent", "sent")], + default="new", + graph=users.models.password_reset.PasswordResetStates, + max_length=100, + ), + ), + ("token", models.CharField(max_length=500, unique=True)), + ("new_account", models.BooleanField()), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="password_resets", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), migrations.CreateModel( name="Identity", fields=[ @@ -194,9 +259,7 @@ class Migration(migrations.Migration): blank=True, null=True, upload_to=functools.partial( - users.models.identity.upload_namer, - *("profile_images",), - **{}, + core.uploads.upload_namer, *("profile_images",), **{} ), ), ), @@ -206,14 +269,13 @@ class Migration(migrations.Migration): blank=True, null=True, upload_to=functools.partial( - users.models.identity.upload_namer, - *("background_images",), - **{}, + core.uploads.upload_namer, *("background_images",), **{} ), ), ), ("private_key", models.TextField(blank=True, null=True)), ("public_key", models.TextField(blank=True, null=True)), + ("public_key_id", models.TextField(blank=True, null=True)), ("created", models.DateTimeField(auto_now_add=True)), ("updated", models.DateTimeField(auto_now=True)), ("fetched", models.DateTimeField(blank=True, null=True)), @@ -224,6 +286,7 @@ class Migration(migrations.Migration): blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + related_name="identities", to="users.domain", ), ), @@ -302,7 +365,7 @@ class Migration(migrations.Migration): ("local_requested", "local_requested"), ("remote_requested", "remote_requested"), ("accepted", "accepted"), - ("undone_locally", "undone_locally"), + ("undone", "undone"), ("undone_remotely", "undone_remotely"), ], default="unrequested", diff --git a/users/migrations/0002_identity_public_key_id.py b/users/migrations/0002_identity_public_key_id.py deleted file mode 100644 index 3648c20..0000000 --- a/users/migrations/0002_identity_public_key_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-12 21:29 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="identity", - name="public_key_id", - field=models.TextField(blank=True, null=True), - ), - ] diff --git a/users/migrations/0003_user_last_seen_alter_identity_domain.py b/users/migrations/0003_user_last_seen_alter_identity_domain.py deleted file mode 100644 index b6c49d1..0000000 --- a/users/migrations/0003_user_last_seen_alter_identity_domain.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-17 04:18 - -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0002_identity_public_key_id"), - ] - - operations = [ - migrations.AddField( - model_name="user", - name="last_seen", - field=models.DateTimeField( - auto_now_add=True, default=django.utils.timezone.now - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="identity", - name="domain", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.PROTECT, - related_name="identities", - to="users.domain", - ), - ), - ] diff --git a/users/migrations/0004_passwordreset.py b/users/migrations/0004_passwordreset.py deleted file mode 100644 index d996ff4..0000000 --- a/users/migrations/0004_passwordreset.py +++ /dev/null @@ -1,60 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-18 01:40 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - -import stator.models -import users.models.password_reset - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0003_user_last_seen_alter_identity_domain"), - ] - - operations = [ - migrations.CreateModel( - name="PasswordReset", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("state_ready", models.BooleanField(default=True)), - ("state_changed", models.DateTimeField(auto_now_add=True)), - ("state_attempted", models.DateTimeField(blank=True, null=True)), - ("state_locked_until", models.DateTimeField(blank=True, null=True)), - ( - "state", - stator.models.StateField( - choices=[("new", "new"), ("sent", "sent")], - default="new", - graph=users.models.password_reset.PasswordResetStates, - max_length=100, - ), - ), - ("token", models.CharField(max_length=500, unique=True)), - ("new_account", models.BooleanField()), - ("created", models.DateTimeField(auto_now_add=True)), - ("updated", models.DateTimeField(auto_now=True)), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="password_resets", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/users/migrations/0005_invite.py b/users/migrations/0005_invite.py deleted file mode 100644 index bb18841..0000000 --- a/users/migrations/0005_invite.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-18 06:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0004_passwordreset"), - ] - - operations = [ - migrations.CreateModel( - name="Invite", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("token", models.CharField(max_length=500, unique=True)), - ("email", models.EmailField(blank=True, max_length=254, null=True)), - ("note", models.TextField(blank=True, null=True)), - ("created", models.DateTimeField(auto_now_add=True)), - ("updated", models.DateTimeField(auto_now=True)), - ], - ), - ] diff --git a/users/models/domain.py b/users/models/domain.py index 4743503..c238025 100644 --- a/users/models/domain.py +++ b/users/models/domain.py @@ -41,6 +41,9 @@ class Domain(models.Model): # should) public = models.BooleanField(default=False) + # If this is the default domain (shown as the default entry for new users) + default = models.BooleanField(default=False) + # Domains can also be linked to one or more users for their private use # This should be display domains ONLY users = models.ManyToManyField("users.User", related_name="domains", blank=True) @@ -52,7 +55,7 @@ class Domain(models.Model): root = "/admin/domains/" create = "/admin/domains/create/" edit = "/admin/domains/{self.domain}/" - delete = "/admin/domains/{self.domain}/delete/" + delete = "{edit}delete/" @classmethod def get_remote_domain(cls, domain: str) -> "Domain": @@ -81,7 +84,7 @@ class Domain(models.Model): return cls.objects.filter( models.Q(public=True) | models.Q(users__id=user.id), local=True, - ) + ).order_by("-default", "domain") def __str__(self): return self.domain diff --git a/users/models/password_reset.py b/users/models/password_reset.py index 628efa6..290b08d 100644 --- a/users/models/password_reset.py +++ b/users/models/password_reset.py @@ -12,7 +12,7 @@ from stator.models import State, StateField, StateGraph, StatorModel class PasswordResetStates(StateGraph): - new = State(try_interval=3) + new = State(try_interval=300) sent = State() new.transitions_to(sent) diff --git a/users/views/activitypub.py b/users/views/activitypub.py index 4660d7a..2719f17 100644 --- a/users/views/activitypub.py +++ b/users/views/activitypub.py @@ -1,18 +1,22 @@ import json from asgiref.sync import async_to_sync +from django.conf import settings from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.generic import View +from activities.models import Post from core.ld import canonicalise +from core.models import Config from core.signatures import ( HttpSignature, LDSignature, VerificationError, VerificationFormatError, ) +from takahe import __version__ from users.models import Identity, InboxMessage from users.shortcuts import by_handle_or_404 @@ -37,6 +41,51 @@ class HostMeta(View): ) +class NodeInfo(View): + """ + Returns the well-known nodeinfo response, pointing to the 2.0 one + """ + + def get(self, request): + host = request.META.get("HOST", settings.MAIN_DOMAIN) + return JsonResponse( + { + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": f"https://{host}/nodeinfo/2.0/", + } + ] + } + ) + + +class NodeInfo2(View): + """ + Returns the nodeinfo 2.0 response + """ + + def get(self, request): + # Fetch some user stats + local_identities = Identity.objects.filter(local=True).count() + local_posts = Post.objects.filter(local=True).count() + return JsonResponse( + { + "version": "2.0", + "software": {"name": "takahe", "version": __version__}, + "protocols": ["activitypub"], + "services": {"outbound": [], "inbound": []}, + "usage": { + "users": {"total": local_identities}, + "localPosts": local_posts, + }, + "openRegistrations": Config.system.signup_allowed + and not Config.system.signup_invite_only, + "metadata": {}, + } + ) + + class Webfinger(View): """ Services webfinger requests @@ -70,16 +119,6 @@ class Webfinger(View): ) -class Actor(View): - """ - Returns the AP Actor object - """ - - def get(self, request, handle): - identity = by_handle_or_404(self.request, handle) - return JsonResponse(canonicalise(identity.to_ap(), include_security=True)) - - @method_decorator(csrf_exempt, name="dispatch") class Inbox(View): """ diff --git a/users/views/admin/domains.py b/users/views/admin/domains.py index e1a011b..c42137c 100644 --- a/users/views/admin/domains.py +++ b/users/views/admin/domains.py @@ -41,6 +41,11 @@ class DomainCreate(FormView): widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), required=False, ) + default = forms.BooleanField( + help_text="If this is the default option for new identities", + widget=forms.Select(choices=[(True, "Yes"), (False, "No")]), + required=False, + ) domain_regex = re.compile( r"^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9\-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$" @@ -72,13 +77,22 @@ class DomainCreate(FormView): ) return self.cleaned_data["service_domain"] + def clean_default(self): + value = self.cleaned_data["default"] + if value and not self.cleaned_data.get("public"): + raise forms.ValidationError("A non-public domain cannot be the default") + return value + def form_valid(self, form): - Domain.objects.create( + domain = Domain.objects.create( domain=form.cleaned_data["domain"], service_domain=form.cleaned_data["service_domain"] or None, public=form.cleaned_data["public"], + default=form.cleaned_data["default"], local=True, ) + if domain.default: + Domain.objects.exclude(pk=domain.pk).update(default=False) return redirect(Domain.urls.root) @@ -88,21 +102,17 @@ class DomainEdit(FormView): template_name = "admin/domain_edit.html" extra_context = {"section": "domains"} - class form_class(forms.Form): - domain = forms.CharField( - help_text="The domain displayed as part of a user's identity.\nCannot be changed after the domain has been created.", - disabled=True, - ) - service_domain = forms.CharField( - help_text="Optional - a domain that serves Takahē if it is not running on the main domain.\nCannot be changed after the domain has been created.", - disabled=True, - required=False, - ) - public = forms.BooleanField( - help_text="If any user on this server can create identities here", - widget=forms.Select(choices=[(True, "Public"), (False, "Private")]), - required=False, - ) + class form_class(DomainCreate.form_class): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["domain"].disabled = True + self.fields["service_domain"].disabled = True + + def clean_domain(self): + return self.cleaned_data["domain"] + + def clean_service_domain(self): + return self.cleaned_data["service_domain"] def dispatch(self, request, domain): self.domain = get_object_or_404( @@ -110,14 +120,17 @@ class DomainEdit(FormView): ) return super().dispatch(request) - def get_context_data(self): - context = super().get_context_data() + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) context["domain"] = self.domain return context def form_valid(self, form): self.domain.public = form.cleaned_data["public"] + self.domain.default = form.cleaned_data["default"] self.domain.save() + if self.domain.default: + Domain.objects.exclude(pk=self.domain.pk).update(default=False) return redirect(Domain.urls.root) def get_initial(self): @@ -125,6 +138,7 @@ class DomainEdit(FormView): "domain": self.domain.domain, "service_domain": self.domain.service_domain, "public": self.domain.public, + "default": self.domain.default, } @@ -150,4 +164,4 @@ class DomainDelete(TemplateView): if self.domain.identities.exists(): raise ValueError("Tried to delete domain with identities!") self.domain.delete() - return redirect("/settings/system/domains/") + return redirect("admin_domains") diff --git a/users/views/auth.py b/users/views/auth.py index a04b1b1..2257ea5 100644 --- a/users/views/auth.py +++ b/users/views/auth.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib.auth.password_validation import validate_password from django.contrib.auth.views import LoginView, LogoutView from django.shortcuts import get_object_or_404, render @@ -50,6 +51,10 @@ class Signup(FormView): def form_valid(self, form): user = User.objects.create(email=form.cleaned_data["email"]) + # Auto-promote the user to admin if that setting is set + if settings.AUTO_ADMIN_EMAIL and user.email == settings.AUTO_ADMIN_EMAIL: + user.admin = True + user.save() PasswordReset.create_for_user(user) if "invite_code" in form.cleaned_data: Invite.objects.filter(token=form.cleaned_data["invite_code"]).delete() diff --git a/users/views/identity.py b/users/views/identity.py index ae8e5b0..5524c4c 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -2,11 +2,12 @@ import string from django import forms from django.contrib.auth.decorators import login_required -from django.http import Http404 +from django.http import Http404, JsonResponse from django.shortcuts import redirect from django.utils.decorators import method_decorator from django.views.generic import FormView, TemplateView, View +from core.ld import canonicalise from core.models import Config from users.decorators import identity_required from users.models import Domain, Follow, FollowStates, Identity, IdentityStates @@ -14,16 +15,41 @@ from users.shortcuts import by_handle_or_404 class ViewIdentity(TemplateView): + """ + Shows identity profile pages, and also acts as the Actor endpoint when + approached with the right Accept header. + """ template_name = "identity/view.html" - def get_context_data(self, handle): + def get(self, request, handle): + # Make sure we understand this handle identity = by_handle_or_404( self.request, handle, local=False, fetch=True, ) + # If they're coming in looking for JSON, they want the actor + accept = request.META.get("HTTP_ACCEPT", "text/html").lower() + if ( + "application/json" in accept + or "application/ld" in accept + or "application/activity" in accept + ): + # Return actor info + return self.serve_actor(identity) + else: + # Show normal page + return super().get(request, identity=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)) + + def get_context_data(self, identity): posts = identity.posts.all()[:100] if identity.data_age > Config.system.identity_max_age: identity.transition_perform(IdentityStates.outdated) @@ -150,7 +176,7 @@ class CreateIdentity(FormView): domain = form.cleaned_data["domain"] domain_instance = Domain.get_domain(domain) new_identity = Identity.objects.create( - actor_uri=f"https://{domain_instance.uri_domain}/@{username}@{domain}/actor/", + actor_uri=f"https://{domain_instance.uri_domain}/@{username}@{domain}/", username=username.lower(), domain_id=domain, name=form.cleaned_data["name"], diff --git a/users/views/settings.py b/users/views/settings.py index fd138c2..1403821 100644 --- a/users/views/settings.py +++ b/users/views/settings.py @@ -147,8 +147,8 @@ class ProfilePage(FormView): return { "name": self.request.identity.name, "summary": self.request.identity.summary, - "icon": self.request.identity.icon.url, - "image": self.request.identity.image.url, + "icon": self.request.identity.icon and self.request.identity.icon.url, + "image": self.request.identity.image and self.request.identity.image.url, } def form_valid(self, form): -- cgit v1.2.3