relay/app/feeds/models.py
Andros Fenollosa 22271f2e0d Send Webmentions for external links found in posts
Implements the sender side of https://www.w3.org/TR/webmention/. During
feed scans, external URLs found in new posts (and links added by edits)
are queued as OutgoingWebmention rows; a periodic task discovers each
target's endpoint and delivers the notification. The unique
(source, target) pair guarantees a webmention is sent at most once, no
matter how many times a feed is rescanned. Targets without an endpoint
are marked permanently, failures retry with exponential backoff, and
endpoints resolving to loopback/private addresses are rejected.
Receiving webmentions is out of scope: plain text feeds cannot
advertise an endpoint.
2026-07-16 10:22:47 +02:00

359 lines
11 KiB
Python

from django.db import models
from django.utils import timezone
import logging
logger = logging.getLogger(__name__)
class Profile(models.Model):
"""
Represents an Org Social profile (social.org file)
"""
feed = models.URLField(unique=True, help_text="URL to the social.org file")
title = models.CharField(max_length=200, help_text="Title of the social feed")
nick = models.CharField(max_length=100, help_text="Nickname (no spaces allowed)")
description = models.TextField(
blank=True, help_text="Short description about the profile"
)
avatar = models.URLField(
blank=True, help_text="URL to avatar image (128x128px JPG/PNG)"
)
location = models.CharField(
max_length=200, blank=True, help_text="User location (city, country, etc.)"
)
birthday = models.DateField(
blank=True, null=True, help_text="User birthday in YYYY-MM-DD format"
)
language = models.CharField(
max_length=100,
blank=True,
help_text="Space-separated language codes (ISO 639-1)",
)
pinned = models.CharField(
max_length=50, blank=True, help_text="Pinned post ID (timestamp)"
)
version = models.CharField(
max_length=50,
blank=True,
help_text="Version identifier for tracking changes (hash of content)",
)
last_updated = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-last_updated"]
def __str__(self):
return f"{self.nick} ({self.feed})"
class ProfileLink(models.Model):
"""
Links associated with a profile (LINK field in social.org)
"""
profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="links")
url = models.URLField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["profile", "url"]
class ProfileContact(models.Model):
"""
Contact information for a profile (CONTACT field in social.org)
"""
profile = models.ForeignKey(
Profile, on_delete=models.CASCADE, related_name="contacts"
)
contact_type = models.CharField(
max_length=50, help_text="Type of contact (email, xmpp, matrix, etc.)"
)
contact_value = models.CharField(max_length=200, help_text="Contact information")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["profile", "contact_type", "contact_value"]
class Follow(models.Model):
"""
Represents a follow relationship between profiles
"""
follower = models.ForeignKey(
Profile, on_delete=models.CASCADE, related_name="following"
)
followed = models.ForeignKey(
Profile, on_delete=models.CASCADE, related_name="followers"
)
nickname = models.CharField(
max_length=100,
blank=True,
help_text="Optional nickname for the followed profile",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["follower", "followed"]
def __str__(self):
return f"{self.follower.nick} follows {self.followed.nick}"
class Post(models.Model):
"""
Represents a post in Org Social
"""
profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="posts")
post_id = models.CharField(
max_length=50, help_text="Unique timestamp identifier (RFC 3339 format)"
)
content = models.TextField(help_text="Post content")
# Optional properties
language = models.CharField(
max_length=10, blank=True, help_text="Language code (LANG property)"
)
tags = models.CharField(
max_length=500, blank=True, help_text="Space-separated tags"
)
client = models.CharField(
max_length=100, blank=True, help_text="Client application used"
)
reply_to = models.CharField(
max_length=300,
blank=True,
help_text="ID of post being replied to (URL#ID format)",
)
mood = models.CharField(
max_length=10, blank=True, help_text="Mood indicator (emoji)"
)
group = models.CharField(
max_length=100, blank=True, help_text="Group name (GROUP property)"
)
include = models.CharField(
max_length=300,
blank=True,
help_text="Post being boosted/shared (URL#ID format)",
)
# Poll related fields
poll_end = models.DateTimeField(
blank=True, null=True, help_text="End time for polls"
)
# Metadata
created_at = models.DateTimeField(
default=timezone.now,
help_text="Creation timestamp, parsed from post_id when available",
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ["profile", "post_id"]
ordering = ["-created_at"]
def __str__(self):
return f"{self.profile.nick}: {self.content[:50]}..."
@property
def is_poll(self):
return self.poll_end is not None
@property
def is_reply(self):
return bool(self.reply_to)
@property
def is_boost(self):
return bool(self.include)
class PollOption(models.Model):
"""
Options for a poll post
"""
post = models.ForeignKey(
Post, on_delete=models.CASCADE, related_name="poll_options"
)
option_text = models.CharField(max_length=200)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["order"]
unique_together = ["post", "option_text"]
def __str__(self):
return f"{self.post.profile.nick} poll: {self.option_text}"
class PollVote(models.Model):
"""
Represents a vote on a poll
"""
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="poll_votes")
poll_post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name="votes_received",
help_text="The original poll post",
)
poll_option = models.CharField(max_length=200, help_text="Selected poll option")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["post", "poll_post"]
def __str__(self):
return f"{self.post.profile.nick} voted {self.poll_option}"
class Mention(models.Model):
"""
Represents a mention in a post
"""
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="mentions")
mentioned_profile = models.ForeignKey(
Profile,
on_delete=models.CASCADE,
related_name="incoming_mentions",
help_text="The profile that was mentioned",
)
nickname = models.CharField(
max_length=100, blank=True, help_text="Nickname used in the mention"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["post", "mentioned_profile"]
ordering = ["-created_at"]
def __str__(self):
return f"{self.post.profile.nick} mentioned {self.mentioned_profile.nick}"
class Feed(models.Model):
"""
Represents a feed URL
"""
url = models.URLField()
created_at = models.DateTimeField(auto_now_add=True)
last_successful_fetch = models.DateTimeField(
null=True,
blank=True,
help_text="Last time this feed was successfully fetched with HTTP 200",
)
def __str__(self):
return self.url
class OutgoingWebmention(models.Model):
"""
Outbox of Webmentions sent by the relay on behalf of feed authors.
The (source, target) pair is unique, which guarantees each link found
in a post is notified at most once no matter how many times the feed
is rescanned.
"""
STATUS_PENDING = "pending"
STATUS_SENT = "sent"
STATUS_FAILED = "failed"
STATUS_NO_ENDPOINT = "no_endpoint"
STATUS_CHOICES = [
(STATUS_PENDING, "Pending"),
(STATUS_SENT, "Sent"),
(STATUS_FAILED, "Failed"),
(STATUS_NO_ENDPOINT, "No endpoint"),
]
source = models.URLField(
max_length=500, help_text="Post URL (feed#post_id) containing the link"
)
target = models.URLField(max_length=500, help_text="External URL the post links to")
endpoint = models.URLField(
max_length=500, blank=True, help_text="Discovered Webmention endpoint"
)
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING
)
attempts = models.PositiveSmallIntegerField(default=0)
response_code = models.IntegerField(
null=True, blank=True, help_text="HTTP status of the last delivery attempt"
)
last_attempt_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["source", "target"]
ordering = ["created_at"]
def __str__(self):
return f"{self.source} -> {self.target} ({self.status})"
class RelayMetadata(models.Model):
"""
Global metadata for the relay - used for HTTP caching headers.
This model ensures all endpoints return consistent ETag and Last-Modified headers.
Updated by the scan_feeds task after each scan.
"""
key = models.CharField(max_length=50, unique=True, primary_key=True)
etag = models.CharField(max_length=16, help_text="Global ETag for HTTP caching")
last_modified = models.DateTimeField(
help_text="Global Last-Modified timestamp for HTTP caching"
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Relay Metadata"
verbose_name_plural = "Relay Metadata"
def __str__(self):
return f"RelayMetadata({self.key}): ETag={self.etag}"
@classmethod
def get_global_metadata(cls):
"""
Get or create the global relay metadata.
Returns a tuple of (etag, last_modified).
"""
import hashlib
from django.utils import timezone
metadata, created = cls.objects.get_or_create(
key="global",
defaults={
"etag": hashlib.md5(str(timezone.now()).encode()).hexdigest()[:8],
"last_modified": timezone.now(),
},
)
return metadata.etag, metadata.last_modified
@classmethod
def update_global_metadata(cls):
"""
Update the global ETag and Last-Modified.
Called by scan_feeds task after scanning all feeds.
"""
import hashlib
from django.utils import timezone
now = timezone.now()
etag = hashlib.md5(str(now.timestamp()).encode()).hexdigest()[:8]
cls.objects.update_or_create(
key="global", defaults={"etag": etag, "last_modified": now}
)
logger.info(f"Updated global relay metadata: ETag={etag}, Last-Modified={now}")