Domains management pages

This commit is contained in:
Andrew Godwin 2022-11-16 21:12:28 -07:00
parent 44af0d4c59
commit 1b52acdb56
16 changed files with 308 additions and 12 deletions

View file

@ -43,7 +43,6 @@ the less sure I am about it.
- [ ] Receive post edits - [ ] Receive post edits
- [x] Set content warnings on posts - [x] Set content warnings on posts
- [x] Show content warnings on posts - [x] Show content warnings on posts
- [ ] Attach images to posts
- [ ] Receive images on posts - [ ] Receive images on posts
- [x] Create boosts - [x] Create boosts
- [x] Receive boosts - [x] Receive boosts
@ -65,8 +64,8 @@ the less sure I am about it.
- [x] Multiple domain support - [x] Multiple domain support
- [x] Multiple identity support - [x] Multiple identity support
- [x] Serverless-friendly worker subsystem - [x] Serverless-friendly worker subsystem
- [ ] Settings subsystem - [x] Settings subsystem
- [ ] Server management page - [x] Server management page
- [ ] Domain management page - [ ] Domain management page
- [ ] Email subsystem - [ ] Email subsystem
- [ ] Signup flow - [ ] Signup flow
@ -75,6 +74,7 @@ the less sure I am about it.
### Beta ### Beta
- [ ] Attach images to posts
- [ ] Delete posts - [ ] Delete posts
- [ ] Reply threading on post creation - [ ] Reply threading on post creation
- [ ] Display posts with reply threads - [ ] Display posts with reply threads

View file

@ -108,6 +108,7 @@ class Boost(View):
class Compose(FormView): class Compose(FormView):
template_name = "activities/compose.html" template_name = "activities/compose.html"
extra_context = {"top_section": "compose"}
class form_class(forms.Form): class form_class(forms.Form):
text = forms.CharField( text = forms.CharField(

View file

@ -59,7 +59,7 @@ class HttpSignature:
elif header_name == "content-type": elif header_name == "content-type":
value = request.META["CONTENT_TYPE"] value = request.META["CONTENT_TYPE"]
else: else:
value = request.META[f"HTTP_{header_name.upper()}"] value = request.META["HTTP_%s" % header_name.upper().replace("-", "_")]
headers[header_name] = value headers[header_name] = value
return "\n".join(f"{name.lower()}: {value}" for name, value in headers.items()) return "\n".join(f"{name.lower()}: {value}" for name, value in headers.items())

View file

@ -81,6 +81,7 @@ a {
--color-bg-box: #1a2631; --color-bg-box: #1a2631;
--color-bg-error: rgb(87, 32, 32); --color-bg-error: rgb(87, 32, 32);
--color-highlight: #449c8c; --color-highlight: #449c8c;
--color-delete: #8b2821;
--color-text-duller: #5f6983; --color-text-duller: #5f6983;
--color-text-dull: #99a; --color-text-dull: #99a;
@ -148,7 +149,8 @@ header menu a {
border-right: 1px solid var(--color-bg-menu); border-right: 1px solid var(--color-bg-menu);
} }
header menu a:hover { header menu a:hover,
header menu a.selected {
border-bottom: 3px solid var(--color-highlight); border-bottom: 3px solid var(--color-highlight);
} }
@ -438,6 +440,11 @@ form .button {
display: inline-block; display: inline-block;
} }
form button.delete,
form .button.delete {
background: var(--color-delete);
}
form button.toggle, form button.toggle,
form .button.toggle { form .button.toggle {
background: var(--color-bg-main); background: var(--color-bg-main);

View file

@ -17,6 +17,13 @@ urlpatterns = [
path("settings/interface/", settings_identity.InterfacePage.as_view()), path("settings/interface/", settings_identity.InterfacePage.as_view()),
path("settings/system/", settings_system.SystemSettingsRoot.as_view()), path("settings/system/", settings_system.SystemSettingsRoot.as_view()),
path("settings/system/basic/", settings_system.BasicPage.as_view()), path("settings/system/basic/", settings_system.BasicPage.as_view()),
path("settings/system/domains/", settings_system.DomainsPage.as_view()),
path("settings/system/domains/create/", settings_system.DomainCreatePage.as_view()),
path("settings/system/domains/<domain>/", settings_system.DomainEditPage.as_view()),
path(
"settings/system/domains/<domain>/delete/",
settings_system.DomainDeletePage.as_view(),
),
# Identity views # Identity views
path("@<handle>/", identity.ViewIdentity.as_view()), path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/actor/", activitypub.Actor.as_view()), path("@<handle>/actor/", activitypub.Actor.as_view()),

View file

@ -28,10 +28,16 @@
</a> </a>
<menu> <menu>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="/compose/" title="Compose"><i class="fa-solid fa-feather"></i> Compose</a> <a href="/compose/" title="Compose" {% if top_section == "compose" %}class="selected"{% endif %}>
<a href="/settings/" title="Settings"><i class="fa-solid fa-gear"></i> Settings</a> <i class="fa-solid fa-feather"></i> Compose
</a>
<a href="/settings/" title="Settings" {% if top_section == "settings" %}class="selected"{% endif %}>
<i class="fa-solid fa-gear"></i> Settings
</a>
{% if request.user.admin %} {% if request.user.admin %}
<a href="/settings/system/" title="Admin"><i class="fa-solid fa-toolbox"></i> Admin</a> <a href="/settings/system/" title="Admin" {% if top_section == "settings_system" %}class="selected"{% endif %}>
<i class="fa-solid fa-toolbox"></i> Admin
</a>
{% endif %} {% endif %}
<div class="gap"></div> <div class="gap"></div>
<a href="/identity/select/" class="identity"> <a href="/identity/select/" class="identity">

View file

@ -5,7 +5,7 @@
</label> </label>
{% if field.help_text %} {% if field.help_text %}
<p class="help"> <p class="help">
{{ field.help_text }} {{ field.help_text|linebreaksbr }}
</p> </p>
{% endif %} {% endif %}
{{ field.errors }} {{ field.errors }}

View file

@ -1,3 +1,5 @@
<nav> <nav>
<a href="#" {% if section == "basic" %}class="selected"{% endif %}>Basic</a> <a href="/settings/system/basic/" {% if section == "basic" %}class="selected"{% endif %}>Basic</a>
<a href="/settings/system/domains/" {% if section == "domains" %}class="selected"{% endif %}>Domains</a>
<a href="/settings/system/users/" {% if section == "users" %}class="selected"{% endif %}>Users</a>
</nav> </nav>

View file

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Add Domain - System Settings{% endblock %}
{% block content %}
{% block menu %}
{% include "settings/_settings_system_menu.html" %}
{% endblock %}
<form action="." method="POST">
<h1>Add A Domain</h1>
<p>
Use this form to add a domain that your users can create identities
on.
</p>
<p>
Takahē supports multiple domains per server, but note that when
identities are created they are <b>fixed to their chosen domain</b>,
and you will <b>not be able to delete a domain with identities on it</b>.
</p>
<p>
If you will be serving Takahē on the domain you choose, you can leave
the "service domain" field blank. If you would like to let users create
accounts on a domain serving something else, you must pick a unique
"service domain" that pairs up to your chosen domain name, make sure
Takahē is served on that, and add redirects
for <tt>/.well-known/webfinger</tt>, <tt>/.well-known/host-meta</tt>
and <tt>/.well-known/nodeinfo</tt> from the main domain to the
service domain.
</p>
{% csrf_token %}
{% for field in form %}
{% include "forms/_field.html" %}
{% endfor %}
<div class="buttons">
<a href="{{ domain.urls.delete }}" class="button delete">Delete</a>
<button>Save</button>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Delete {{ domain.domain }} - System Settings{% endblock %}
{% block content %}
{% block menu %}
{% include "settings/_settings_system_menu.html" %}
{% endblock %}
<form action="." method="POST">
{% csrf_token %}
<h1>Deleting {{ domain.domain }}</h1>
{% if num_identities %}
<p>
You cannot delete this domain as it has <b>{{ num_identities }}
identit{{ num_identities|pluralize:"y,ies" }}</b> registered on it.
</p>
<p>
You will need to manually remove all identities from this domain in
order to delete it.
</p>
{% else %}
<p>Please confirm deletion of this domain - there are no identities registed on it.</p>
<div class="buttons">
<a class="button" href="{{ domain.urls.edit }}">Cancel</a>
<button class="delete">Confirm Deletion</button>
</div>
{% endif %}
</form>
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}{{ domain.domain }} - System Settings{% endblock %}
{% block content %}
{% block menu %}
{% include "settings/_settings_system_menu.html" %}
{% endblock %}
<form action="." method="POST">
{% csrf_token %}
{% for field in form %}
{% include "forms/_field.html" %}
{% endfor %}
<div class="buttons">
<a href="{{ domain.urls.delete }}" class="button delete">Delete</a>
<button>Save</button>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} - System Settings{% endblock %}
{% block content %}
{% block menu %}
{% include "settings/_settings_system_menu.html" %}
{% endblock %}
<section class="icon-menu">
{% for domain in domains %}
<a class="option" href="{{ domain.urls.edit }}">
<i class="fa-solid fa-globe"></i>
<span class="handle">
{{ domain.domain }}
<small>
{% if domain.public %}Public{% else %}Private{% endif %}
{% if domain.service_domain %}({{ domain.service_domain }}){% endif %}
</small>
</span>
</a>
{% empty %}
<p class="option empty">You have no domains set up.</p>
{% endfor %}
<a href="/settings/system/domains/create/" class="option new">
<i class="fa-solid fa-plus"></i> Add a domain
</a>
</section>
{% endblock %}

View file

@ -1,5 +1,6 @@
from typing import Optional from typing import Optional
import urlman
from django.db import models from django.db import models
@ -47,6 +48,12 @@ class Domain(models.Model):
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
class urls(urlman.Urls):
root = "/settings/system/domains/"
create = "/settings/system/domains/create/"
edit = "/settings/system/domains/{self.domain}/"
delete = "/settings/system/domains/{self.domain}/delete/"
@classmethod @classmethod
def get_remote_domain(cls, domain: str) -> "Domain": def get_remote_domain(cls, domain: str) -> "Domain":
return cls.objects.get_or_create(domain=domain, local=False)[0] return cls.objects.get_or_create(domain=domain, local=False)[0]

View file

@ -67,6 +67,7 @@ class Identity(StatorModel):
blank=True, blank=True,
null=True, null=True,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="identities",
) )
name = models.CharField(max_length=500, blank=True, null=True) name = models.CharField(max_length=500, blank=True, null=True)

View file

@ -17,6 +17,8 @@ class IdentitySettingsPage(SystemSettingsPage):
at the bottom of the page. Don't add this to a URL directly - subclass! at the bottom of the page. Don't add this to a URL directly - subclass!
""" """
extra_context = {"top_section": "settings"}
options_class = Config.IdentityOptions options_class = Config.IdentityOptions
template_name = "settings/settings_identity.html" template_name = "settings/settings_identity.html"

View file

@ -1,13 +1,16 @@
import re
from functools import partial from functools import partial
from typing import ClassVar, Dict from typing import ClassVar, Dict
from django import forms from django import forms
from django.shortcuts import redirect from django.db import models
from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import FormView, RedirectView from django.views.generic import FormView, RedirectView, TemplateView
from core.models import Config from core.models import Config
from users.decorators import identity_required from users.decorators import identity_required
from users.models import Domain
@method_decorator(identity_required, name="dispatch") @method_decorator(identity_required, name="dispatch")
@ -27,6 +30,8 @@ class SystemSettingsPage(FormView):
section: ClassVar[str] section: ClassVar[str]
options: Dict[str, Dict[str, str]] options: Dict[str, Dict[str, str]]
extra_context = {"top_section": "settings_system"}
def get_form_class(self): def get_form_class(self):
# Create the fields dict from the config object # Create the fields dict from the config object
fields = {} fields = {}
@ -93,3 +98,142 @@ class BasicPage(SystemSettingsPage):
"help_text": "Used for logo background and other highlights", "help_text": "Used for logo background and other highlights",
}, },
} }
class DomainsPage(TemplateView):
template_name = "settings/settings_system_domains.html"
def get_context_data(self):
return {
"domains": Domain.objects.filter(local=True).order_by("domain"),
"section": "domains",
}
class DomainCreatePage(FormView):
template_name = "settings/settings_system_domain_create.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.",
)
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.",
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,
)
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,})$"
)
def clean_domain(self):
if not self.domain_regex.match(self.cleaned_data["domain"]):
raise forms.ValidationError("This does not look like a domain name")
if Domain.objects.filter(
models.Q(domain=self.cleaned_data["domain"])
| models.Q(service_domain=self.cleaned_data["domain"])
):
raise forms.ValidationError("This domain name is already in use")
return self.cleaned_data["domain"]
def clean_service_domain(self):
if not self.cleaned_data["service_domain"]:
return None
if not self.domain_regex.match(self.cleaned_data["service_domain"]):
raise forms.ValidationError("This does not look like a domain name")
if Domain.objects.filter(
models.Q(domain=self.cleaned_data["service_domain"])
| models.Q(service_domain=self.cleaned_data["service_domain"])
):
raise forms.ValidationError("This domain name is already in use")
if self.cleaned_data.get("domain") == self.cleaned_data["service_domain"]:
raise forms.ValidationError(
"You cannot have the domain and service domain be the same (did you mean to leave service domain blank?)"
)
return self.cleaned_data["service_domain"]
def form_valid(self, form):
Domain.objects.create(
domain=form.cleaned_data["domain"],
service_domain=form.cleaned_data["service_domain"] or None,
public=form.cleaned_data["public"],
local=True,
)
return redirect(Domain.urls.root)
class DomainEditPage(FormView):
template_name = "settings/settings_system_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,
)
def dispatch(self, request, domain):
self.domain = get_object_or_404(
Domain.objects.filter(local=True), domain=domain
)
return super().dispatch(request)
def get_context_data(self):
context = super().get_context_data()
context["domain"] = self.domain
return context
def form_valid(self, form):
self.domain.public = form.cleaned_data["public"]
self.domain.save()
return redirect(Domain.urls.root)
def get_initial(self):
return {
"domain": self.domain.domain,
"service_domain": self.domain.service_domain,
"public": self.domain.public,
}
class DomainDeletePage(TemplateView):
template_name = "settings/settings_system_domain_delete.html"
def dispatch(self, request, domain):
self.domain = get_object_or_404(
Domain.objects.filter(public=True), domain=domain
)
return super().dispatch(request)
def get_context_data(self):
return {
"domain": self.domain,
"num_identities": self.domain.identities.count(),
"section": "domains",
}
def post(self, request):
if self.domain.identities.exists():
raise ValueError("Tried to delete domain with identities!")
self.domain.delete()
return redirect("/settings/system/domains/")