Compare commits

...

6 commits

Author SHA1 Message Date
Henri Dickson f085263499
Merge fb02bf8492 into 7c34ac78ed 2024-02-09 14:10:54 -07:00
Andrew Godwin 7c34ac78ed Write a release checklist and do a couple things on it 2024-02-06 14:49:35 -07:00
Henri Dickson fb02bf8492 fix precommit checks 2024-01-02 18:28:40 -05:00
Henri Dickson 0f4dc3b19f remove unused imports 2024-01-02 18:21:29 -05:00
Henri Dickson 32200e5868 relay: fanout public posts to relays 2024-01-02 18:15:43 -05:00
Henri Dickson dd8d49b9be relay: manage relays 2024-01-02 18:15:41 -05:00
13 changed files with 386 additions and 4 deletions

View file

@ -44,6 +44,7 @@ from users.models.follow import FollowStates
from users.models.hashtag_follow import HashtagFollow
from users.models.identity import Identity, IdentityStates
from users.models.inbox_message import InboxMessage
from users.models.relay import Relay
from users.models.system_actor import SystemActor
logger = logging.getLogger(__name__)
@ -77,6 +78,30 @@ class PostStates(StateGraph):
type=type_,
subject_post=post,
)
cls.fan_out_to_relay(post, type_)
@classmethod
def fan_out_to_relay(cls, post: "Post", type_: str) -> None:
if not post.local or post.visibility != Post.Visibilities.public:
return
relay_uris = Relay.active_inbox_uris()
if not relay_uris:
return
obj = None
match type_:
case FanOut.Types.post:
obj = canonicalise(post.to_create_ap())
case FanOut.Types.post_edited:
obj = canonicalise(post.to_update_ap())
case FanOut.Types.post_deleted:
obj = canonicalise(post.to_delete_ap())
if not obj:
return
for uri in relay_uris:
try:
post.author.signed_request(method="post", uri=uri, body=obj)
except Exception as e:
logger.warning(f"Error sending relay: {uri} {e}")
@classmethod
def handle_new(cls, instance: "Post"):

View file

@ -172,3 +172,37 @@ We use `HTMX <https://htmx.org/>`_ for dynamically loading content, and
`Hyperscript <https://hyperscript.org/>`_ for most interactions rather than raw
JavaScript. If you can accomplish what you need with these tools, please use them
rather than adding JS.
Cutting a release
-----------------
In order to make a release of Takahē, follow these steps:
* Create or update the release document (in ``/docs/releases``) for the
release; major versions get their own document, minor releases get a
subheading in the document for their major release.
* Go through the git commit history since the last release in order to write
a reasonable summary of features.
* Be sure to include the little paragraphs at the end about contributing and
the docker tag, and an Upgrade Notes section that at minimum mentions
migrations and if they're normal or weird (even if there aren't any, it's
nice to call that out).
* If it's a new doc, make sure you include it in ``docs/releases/index.rst``!
* Update the version number in ``/takahe/__init__.py``
* Update the version number in ``README.md``
* Make a commit containing these changes called ``Releasing 1.23.45``.
* Tag that commit with a tag in the format ``1.23.45``.
* Wait for the GitHub Actions to run and publish the docker images (around 20
minutes as the ARM build is a bit slow)
* Post on the official account announcing the relase and linking to the
now-published release notes.

View file

@ -35,3 +35,20 @@ In additions, there's many bugfixes and minor changes, including:
* Perform some basic domain validity
* Correctly reject more operations when the identity is deleted
* Post edit fanouts for likers/boosters
If you'd like to help with code, design, or other areas, see
:doc:`/contributing` to see how to get in touch.
You can download images from `Docker Hub <https://hub.docker.com/r/jointakahe/takahe>`_,
or use the image name ``jointakahe/takahe:0.11``.
Upgrade Notes
-------------
Migrations
~~~~~~~~~~
There are new database migrations; they are backwards-compatible and should
not present any major database load.

View file

@ -1 +1 @@
__version__ = "0.10.1"
__version__ = "0.11.0"

View file

@ -153,6 +153,11 @@ urlpatterns = [
admin.FederationEdit.as_view(),
name="admin_federation_edit",
),
path(
"admin/relays/",
admin.RelaysRoot.as_view(),
name="admin_relays",
),
path(
"admin/users/",
admin.UsersRoot.as_view(),

View file

@ -45,6 +45,10 @@
<i class="fa-solid fa-diagram-project"></i>
<span>Federation</span>
</a>
<a href="{% url "admin_relays" %}" {% if section == "relays" %}class="selected"{% endif %} title="Relays">
<i class="fa-solid fa-tower-broadcast"></i>
<span>Relays</span>
</a>
<a href="{% url "admin_users" %}" {% if section == "users" %}class="selected"{% endif %} title="Users">
<i class="fa-solid fa-users"></i>
<span>Users</span>

View file

@ -0,0 +1,53 @@
{% extends "admin/base_main.html" %}
{% load activity_tags %}
{% block subtitle %}Relay{% endblock %}
{% block settings_content %}
<form action="?subscribe" method="post" class="search">
<input type="url"
name="inbox_uri"
pattern="^https?://.+"
placeholder="Relay inbox URI, e.g. https://relay.server/inbox">
{% csrf_token %}
<button>Subscribe</button>
</form>
<table class="items">
{% for relay in page_obj %}
<tr>
<td class="icon">
{% if relay.state == 'subscribed' %}
<i class="fa-regular fa-circle-check"></i>
{% elif relay.state == 'failed' or relay.state == 'rejected' or relay.state == 'unsubscribed' %}
<i class="fa-solid fa-circle-exclamation"></i>
{% else %}
<i class="fa-solid fa-cog fa-spin"></i>
{% endif %}
</td>
<td class="name">{{ relay.inbox_uri }}</td>
<td class="stat">{{ relay.state }}</td>
<td class="actions">
<form action="?unsubscribe" method="post">
<input type="hidden" name="id" value="{{ relay.id }}">
{% csrf_token %}
<button {% if relay.state == 'failed' or relay.state == 'rejected' %}disabled{% endif %}>Unsubscribe</button>
</form>
</td>
<td class="actions">
<form action="?remove" method="post">
<input type="hidden" name="id" value="{{ relay.id }}">
{% csrf_token %}
<button onclick="return confirm('Sure to force remove?')"
{% if relay.state == 'subscribed' %}disabled{% endif %}>Remove</button>
</form>
</td>
</tr>
{% empty %}
<tr class="empty">
<td>There are no relay yet.</td>
</tr>
{% endfor %}
</table>
<div class="view-options">
<small><i class="fa-regular fa-lightbulb"></i>&nbsp; Use remove only when it's stuck in (un)subscribing state for more than 10 minutes.</small>
</div>
{% include "admin/_pagination.html" with nouns="relay,relays" %}
{% endblock %}

View file

@ -0,0 +1,60 @@
# Generated by Django 4.2.8 on 2024-01-02 16:20
from django.db import migrations, models
import stator.models
import users.models.relay
class Migration(migrations.Migration):
dependencies = [
("users", "0022_follow_request"),
]
operations = [
migrations.CreateModel(
name="Relay",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_next_attempt", models.DateTimeField(blank=True, null=True)),
(
"state_locked_until",
models.DateTimeField(blank=True, db_index=True, null=True),
),
("inbox_uri", models.CharField(max_length=500, unique=True)),
(
"state",
stator.models.StateField(
choices=[
("new", "new"),
("subscribed", "subscribed"),
("unsubscribing", "unsubscribing"),
("unsubscribed", "unsubscribed"),
],
default="new",
graph=users.models.relay.RelayStates,
max_length=100,
),
),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
],
options={
"indexes": [
models.Index(
fields=["state", "state_next_attempt", "state_locked_until"],
name="ix_relay_state_next",
)
],
},
),
]

View file

@ -8,6 +8,7 @@ from .identity import Identity, IdentityStates # noqa
from .inbox_message import InboxMessage, InboxMessageStates # noqa
from .invite import Invite # noqa
from .password_reset import PasswordReset # noqa
from .relay import Relay, RelayStates # noqa
from .report import Report # noqa
from .system_actor import SystemActor # noqa
from .user import User # noqa

View file

@ -16,7 +16,7 @@ class InboxMessageStates(StateGraph):
@classmethod
def handle_received(cls, instance: "InboxMessage"):
from activities.models import Post, PostInteraction, TimelineEvent
from users.models import Block, Follow, Identity, Report
from users.models import Block, Follow, Identity, Relay, Report
from users.services import IdentityService
try:
@ -68,7 +68,10 @@ class InboxMessageStates(StateGraph):
case "accept":
match instance.message_object_type:
case "follow":
Follow.handle_accept_ap(instance.message)
if Relay.is_ap_message_for_relay(instance.message):
Relay.handle_accept_ap(instance.message)
else:
Follow.handle_accept_ap(instance.message)
case None:
# It's a string object, but these will only be for Follows
Follow.handle_accept_ap(instance.message)
@ -77,7 +80,10 @@ class InboxMessageStates(StateGraph):
case "reject":
match instance.message_object_type:
case "follow":
Follow.handle_reject_ap(instance.message)
if Relay.is_ap_message_for_relay(instance.message):
Relay.handle_reject_ap(instance.message)
else:
Follow.handle_reject_ap(instance.message)
case None:
# It's a string object, but these will only be for Follows
Follow.handle_reject_ap(instance.message)

146
users/models/relay.py Normal file
View file

@ -0,0 +1,146 @@
import logging
import re
from django.db import models
from stator.models import State, StateField, StateGraph, StatorModel
from users.models.system_actor import SystemActor
logger = logging.getLogger(__name__)
class RelayStates(StateGraph):
new = State(try_interval=600)
subscribing = State(externally_progressed=True)
subscribed = State(externally_progressed=True)
failed = State(externally_progressed=True)
rejected = State(externally_progressed=True)
unsubscribing = State(try_interval=600)
unsubscribed = State(delete_after=1)
new.transitions_to(subscribing)
new.transitions_to(unsubscribing)
new.transitions_to(failed)
new.times_out_to(failed, seconds=38400)
subscribing.transitions_to(subscribed)
subscribing.transitions_to(unsubscribing)
subscribing.transitions_to(unsubscribed)
subscribing.transitions_to(rejected)
subscribing.transitions_to(failed)
subscribed.transitions_to(unsubscribing)
subscribed.transitions_to(rejected)
failed.transitions_to(unsubscribed)
rejected.transitions_to(unsubscribed)
unsubscribing.transitions_to(failed)
unsubscribing.transitions_to(unsubscribed)
unsubscribing.times_out_to(failed, seconds=38400)
@classmethod
def handle_new(cls, instance: "Relay"):
system_actor = SystemActor()
try:
response = system_actor.signed_request(
method="post",
uri=instance.inbox_uri,
body=instance.to_follow_ap(),
)
except Exception as e:
logger.error(f"Error sending follow request: {instance.inbox_uri} {e}")
return cls.failed
if response.status_code >= 200 and response.status_code < 300:
return cls.subscribing
else:
logger.error(f"Follow {instance.inbox_uri} HTTP {response.status_code}")
return cls.failed
@classmethod
def handle_unsubscribing(cls, instance: "Relay"):
system_actor = SystemActor()
try:
response = system_actor.signed_request(
method="post",
uri=instance.inbox_uri,
body=instance.to_unfollow_ap(),
)
except Exception as e:
logger.error(f"Error sending unfollow request: {instance.inbox_uri} {e}")
return cls.failed
if response.status_code >= 200 and response.status_code < 300:
return cls.unsubscribed
else:
logger.error(f"Unfollow {instance.inbox_uri} HTTP {response.status_code}")
return cls.failed
class Relay(StatorModel):
inbox_uri = models.CharField(max_length=500, unique=True)
state = StateField(RelayStates)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
indexes: list = []
@classmethod
def active_inbox_uris(cls):
return list(
cls.objects.filter(state=RelayStates.subscribed).values_list(
"inbox_uri", flat=True
)
)
@classmethod
def subscribe(cls, inbox_uri: str) -> "Relay":
return cls.objects.get_or_create(inbox_uri=inbox_uri.strip())[0]
def unsubscribe(self):
self.transition_perform(RelayStates.unsubscribing)
def force_unsubscribe(self):
self.transition_perform(RelayStates.unsubscribed)
def to_follow_ap(self):
system_actor = SystemActor()
return { # skip canonicalise here to keep Public addressing as full URI
"@context": ["https://www.w3.org/ns/activitystreams"],
"id": f"{system_actor.actor_uri}relay/{self.pk}/#follow",
"type": "Follow",
"actor": system_actor.actor_uri,
"object": "https://www.w3.org/ns/activitystreams#Public",
}
def to_unfollow_ap(self):
system_actor = SystemActor()
return { # skip canonicalise here to keep Public addressing as full URI
"@context": ["https://www.w3.org/ns/activitystreams"],
"id": f"{system_actor.actor_uri}relay/{self.pk}/#unfollow",
"type": "Undo",
"actor": system_actor.actor_uri,
"object": self.to_follow_ap(),
}
@classmethod
def is_ap_message_for_relay(cls, message) -> bool:
return (
re.match(r".+/relay/(\d+)/#(follow|unfollow)$", message["object"]["id"])
is not None
)
@classmethod
def get_by_ap(cls, message) -> "Relay":
m = re.match(r".+/relay/(\d+)/#(follow|unfollow)$", message["object"]["id"])
if not m:
raise ValueError("Not a valid relay follow response")
return cls.objects.get(pk=int(m[1]))
@classmethod
def handle_accept_ap(cls, message):
relay = cls.get_by_ap(message)
relay.transition_perform(RelayStates.subscribed)
@classmethod
def handle_reject_ap(cls, message):
relay = cls.get_by_ap(message)
relay.transition_perform(RelayStates.rejected)

View file

@ -31,6 +31,7 @@ from users.views.admin.federation import ( # noqa
from users.views.admin.hashtags import HashtagEdit, HashtagEnable, Hashtags # noqa
from users.views.admin.identities import IdentitiesRoot, IdentityEdit # noqa
from users.views.admin.invites import InviteCreate, InvitesRoot, InviteView # noqa
from users.views.admin.relays import RelaysRoot # noqa
from users.views.admin.reports import ReportsRoot, ReportView # noqa
from users.views.admin.settings import ( # noqa
BasicSettings,

View file

@ -0,0 +1,30 @@
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.views.generic import ListView
from users.decorators import admin_required
from users.models import Relay
@method_decorator(admin_required, name="dispatch")
class RelaysRoot(ListView):
template_name = "admin/relays.html"
paginate_by = 30
def get(self, request, *args, **kwargs):
self.extra_context = {
"section": "relays",
}
return super().get(request, *args, **kwargs)
def get_queryset(self):
return Relay.objects.all().order_by("-id")
def post(self, request, *args, **kwargs):
if "subscribe" in request.GET:
Relay.subscribe(request.POST.get("inbox_uri"))
elif "unsubscribe" in request.GET:
Relay.objects.get(pk=int(request.POST.get("id"))).unsubscribe()
elif "remove" in request.GET:
Relay.objects.get(pk=int(request.POST.get("id"))).force_unsubscribe()
return redirect(".")