mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-25 08:41:00 +00:00
Add Domain.state with nodeinfo fetching (#347)
This commit is contained in:
parent
a7a292a84c
commit
801fe2e58a
6 changed files with 247 additions and 5 deletions
|
@ -102,6 +102,9 @@ class StatorModel(models.Model):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
# Need this empty indexes to ensure child Models have a Meta.indexes
|
||||||
|
# that will look to add indexes (that we inject with class_prepared)
|
||||||
|
indexes: list = []
|
||||||
|
|
||||||
def __init_subclass__(cls) -> None:
|
def __init_subclass__(cls) -> None:
|
||||||
if cls is not StatorModel:
|
if cls is not StatorModel:
|
||||||
|
|
|
@ -95,13 +95,17 @@ def user() -> User:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def domain() -> Domain:
|
def domain() -> Domain:
|
||||||
return Domain.objects.create(domain="example.com", local=True, public=True)
|
return Domain.objects.create(
|
||||||
|
domain="example.com", local=True, public=True, state="updated"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def domain2() -> Domain:
|
def domain2() -> Domain:
|
||||||
return Domain.objects.create(domain="example2.com", local=True, public=True)
|
return Domain.objects.create(
|
||||||
|
domain="example2.com", local=True, public=True, state="updated"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -164,7 +168,7 @@ def remote_identity() -> Identity:
|
||||||
"""
|
"""
|
||||||
Creates a basic remote test identity with a domain.
|
Creates a basic remote test identity with a domain.
|
||||||
"""
|
"""
|
||||||
domain = Domain.objects.create(domain="remote.test", local=False)
|
domain = Domain.objects.create(domain="remote.test", local=False, state="updated")
|
||||||
return Identity.objects.create(
|
return Identity.objects.create(
|
||||||
actor_uri="https://remote.test/test-actor/",
|
actor_uri="https://remote.test/test-actor/",
|
||||||
inbox_uri="https://remote.test/@test/inbox/",
|
inbox_uri="https://remote.test/@test/inbox/",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import formats
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from activities.admin import IdentityLocalFilter
|
from activities.admin import IdentityLocalFilter
|
||||||
|
@ -18,9 +19,60 @@ from users.models import (
|
||||||
|
|
||||||
@admin.register(Domain)
|
@admin.register(Domain)
|
||||||
class DomainAdmin(admin.ModelAdmin):
|
class DomainAdmin(admin.ModelAdmin):
|
||||||
list_display = ["domain", "service_domain", "local", "blocked", "public"]
|
list_display = [
|
||||||
|
"domain",
|
||||||
|
"service_domain",
|
||||||
|
"local",
|
||||||
|
"blocked",
|
||||||
|
"software",
|
||||||
|
"user_count",
|
||||||
|
"public",
|
||||||
|
]
|
||||||
list_filter = ("local", "blocked")
|
list_filter = ("local", "blocked")
|
||||||
search_fields = ("domain", "service_domain")
|
search_fields = ("domain", "service_domain")
|
||||||
|
actions = ["force_outdated", "force_updated", "force_connection_issue"]
|
||||||
|
|
||||||
|
@admin.action(description="Force State: outdated")
|
||||||
|
def force_outdated(self, request, queryset):
|
||||||
|
for instance in queryset:
|
||||||
|
instance.transition_perform("outdated")
|
||||||
|
|
||||||
|
@admin.action(description="Force State: updated")
|
||||||
|
def force_updated(self, request, queryset):
|
||||||
|
for instance in queryset:
|
||||||
|
instance.transition_perform("updated")
|
||||||
|
|
||||||
|
@admin.action(description="Force State: connection_issue")
|
||||||
|
def force_connection_issue(self, request, queryset):
|
||||||
|
for instance in queryset:
|
||||||
|
instance.transition_perform("connection_issue")
|
||||||
|
|
||||||
|
@admin.display(description="Software")
|
||||||
|
def software(self, instance):
|
||||||
|
if instance.nodeinfo:
|
||||||
|
software = instance.nodeinfo.get("software", {})
|
||||||
|
name = software.get("name", "unknown")
|
||||||
|
version = software.get("version", "unknown")
|
||||||
|
return f"{name:.10} - {version:.10}"
|
||||||
|
|
||||||
|
return "-"
|
||||||
|
|
||||||
|
@admin.display(description="# Users")
|
||||||
|
def user_count(self, instance):
|
||||||
|
if instance.nodeinfo:
|
||||||
|
usage = instance.nodeinfo.get("usage", {})
|
||||||
|
total = usage.get("users", {}).get("total")
|
||||||
|
if total:
|
||||||
|
try:
|
||||||
|
return formats.number_format(
|
||||||
|
"%d" % (int(total)),
|
||||||
|
0,
|
||||||
|
use_l10n=True,
|
||||||
|
force_grouping=True,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return "-"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
|
|
74
users/migrations/0010_domain_state.py
Normal file
74
users/migrations/0010_domain_state.py
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
# Generated by Django 4.1.4 on 2023-01-02 03:54
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import stator.models
|
||||||
|
import users.models.domain
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("users", "0009_state_and_post_indexes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domain",
|
||||||
|
name="nodeinfo",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domain",
|
||||||
|
name="state",
|
||||||
|
field=stator.models.StateField(
|
||||||
|
choices=[
|
||||||
|
("outdated", "outdated"),
|
||||||
|
("updated", "updated"),
|
||||||
|
("connection_issue", "connection_issue"),
|
||||||
|
("purged", "purged"),
|
||||||
|
],
|
||||||
|
default="outdated",
|
||||||
|
graph=users.models.domain.DomainStates,
|
||||||
|
max_length=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domain",
|
||||||
|
name="state_attempted",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domain",
|
||||||
|
name="state_changed",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
auto_now_add=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domain",
|
||||||
|
name="state_locked_until",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domain",
|
||||||
|
name="state_ready",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="domain",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["state", "state_attempted"], name="ix_domain_state_attempted"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="domain",
|
||||||
|
index=models.Index(
|
||||||
|
condition=models.Q(("state_locked_until__isnull", False)),
|
||||||
|
fields=["state_locked_until", "state"],
|
||||||
|
name="ix_domain_state_locked",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,10 +1,46 @@
|
||||||
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
import urlman
|
import urlman
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
from stator.models import State, StateField, StateGraph, StatorModel
|
||||||
|
from users.schemas import NodeInfo
|
||||||
|
|
||||||
class Domain(models.Model):
|
|
||||||
|
class DomainStates(StateGraph):
|
||||||
|
outdated = State(try_interval=60 * 30, force_initial=True)
|
||||||
|
updated = State(try_interval=60 * 60 * 24, attempt_immediately=False)
|
||||||
|
connection_issue = State(externally_progressed=True)
|
||||||
|
purged = State()
|
||||||
|
|
||||||
|
outdated.transitions_to(updated)
|
||||||
|
updated.transitions_to(outdated)
|
||||||
|
|
||||||
|
outdated.transitions_to(connection_issue)
|
||||||
|
outdated.transitions_to(purged)
|
||||||
|
connection_issue.transitions_to(outdated)
|
||||||
|
connection_issue.transitions_to(purged)
|
||||||
|
|
||||||
|
outdated.times_out_to(connection_issue, 60 * 60 * 24)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def handle_outdated(cls, instance: "Domain"):
|
||||||
|
info = await instance.fetch_nodeinfo()
|
||||||
|
if info:
|
||||||
|
instance.nodeinfo = info.dict()
|
||||||
|
await sync_to_async(instance.save)()
|
||||||
|
return cls.updated
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def handle_updated(cls, instance: "Domain"):
|
||||||
|
return cls.outdated
|
||||||
|
|
||||||
|
|
||||||
|
class Domain(StatorModel):
|
||||||
"""
|
"""
|
||||||
Represents a domain that a user can have an account on.
|
Represents a domain that a user can have an account on.
|
||||||
|
|
||||||
|
@ -31,6 +67,11 @@ class Domain(models.Model):
|
||||||
unique=True,
|
unique=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
state = StateField(DomainStates)
|
||||||
|
|
||||||
|
# nodeinfo 2.0 detail about the remote server
|
||||||
|
nodeinfo = models.JSONField(null=True, blank=True)
|
||||||
|
|
||||||
# If we own this domain
|
# If we own this domain
|
||||||
local = models.BooleanField()
|
local = models.BooleanField()
|
||||||
|
|
||||||
|
@ -104,3 +145,39 @@ class Domain(models.Model):
|
||||||
f"Service domain {self.service_domain} is already a domain elsewhere!"
|
f"Service domain {self.service_domain} is already a domain elsewhere!"
|
||||||
)
|
)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
async def fetch_nodeinfo(self) -> NodeInfo | None:
|
||||||
|
"""
|
||||||
|
Fetch the /NodeInfo/2.0 for the domain
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=settings.SETUP.REMOTE_TIMEOUT,
|
||||||
|
headers={"User-Agent": settings.TAKAHE_USER_AGENT},
|
||||||
|
) as client:
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
f"https://{self.domain}/nodeinfo/2.0",
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPError as ex:
|
||||||
|
response = getattr(ex, "response", None)
|
||||||
|
if (
|
||||||
|
response
|
||||||
|
and response.status_code < 500
|
||||||
|
and response.status_code not in [401, 403, 404, 410]
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Client error fetching nodeinfo: domain={self.domain_id}, code={response.status_code}",
|
||||||
|
response.content,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = NodeInfo(**response.json())
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
raise ValueError(
|
||||||
|
f"Client error decoding nodeinfo: domain={self.domain_id}, error={str(ex)}"
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
32
users/schemas.py
Normal file
32
users/schemas.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class NodeInfoServices(BaseModel):
|
||||||
|
inbound: list[str]
|
||||||
|
outbound: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class NodeInfoSoftware(BaseModel):
|
||||||
|
name: str
|
||||||
|
version: str = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class NodeInfoUsage(BaseModel):
|
||||||
|
users: dict[str, int] | None
|
||||||
|
local_posts: int = Field(default=0, alias="localPosts")
|
||||||
|
|
||||||
|
|
||||||
|
class NodeInfo(BaseModel):
|
||||||
|
|
||||||
|
version: Literal["2.0"]
|
||||||
|
software: NodeInfoSoftware
|
||||||
|
protocols: list[str] | None
|
||||||
|
open_registrations: bool = Field(alias="openRegistrations")
|
||||||
|
usage: NodeInfoUsage
|
||||||
|
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = "ignore"
|
Loading…
Reference in a new issue