summaryrefslogtreecommitdiffstats
path: root/users
diff options
context:
space:
mode:
authorAndrew Godwin2022-11-18 08:28:15 -0700
committerAndrew Godwin2022-11-18 11:28:16 -0700
commit81de10b70c85c5222b17d8c4358a8aa8812f2559 (patch)
tree8e028b62d3a883294caedc82c5870f23273e2032 /users
parent1b44a253316a84f40070264ea8134c86d1223441 (diff)
downloadtakahe-81de10b70c85c5222b17d8c4358a8aa8812f2559.tar.gz
takahe-81de10b70c85c5222b17d8c4358a8aa8812f2559.tar.bz2
takahe-81de10b70c85c5222b17d8c4358a8aa8812f2559.zip
Migration reset, start of docs, env vars
Diffstat (limited to 'users')
-rw-r--r--users/migrations/0001_initial.py79
-rw-r--r--users/migrations/0002_identity_public_key_id.py18
-rw-r--r--users/migrations/0003_user_last_seen_alter_identity_domain.py34
-rw-r--r--users/migrations/0004_passwordreset.py60
-rw-r--r--users/migrations/0005_invite.py32
-rw-r--r--users/models/domain.py7
-rw-r--r--users/models/password_reset.py2
-rw-r--r--users/views/activitypub.py59
-rw-r--r--users/views/admin/domains.py52
-rw-r--r--users/views/auth.py5
-rw-r--r--users/views/identity.py32
-rw-r--r--users/views/settings.py4
12 files changed, 195 insertions, 189 deletions
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)),
(
@@ -112,6 +116,25 @@ class Migration(migrations.Migration):
},
),
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=[
(
@@ -147,6 +170,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):