Basic OpenGraph support (#267)

Creates an OpenGraph template include in base.html including the basic tags expected on all pages.

Then allows any page to add additional expected tags via `context`.

Currently, profiles and posts are enriched to show complete opengraph metadata, and render correctly in Discord.

Note: This does not show posts in Slack like Twitter/Mastodon do. I believe this is due to Slack preferring oembed when present, which is a mastodon API endpoint we may need to create at some point.
This commit is contained in:
Corry Haines 2022-12-26 09:39:33 -08:00 committed by GitHub
parent dab8dd59a7
commit b53504fe64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 102 additions and 12 deletions

View File

@ -838,6 +838,22 @@ class Post(StatorModel):
raise ValueError("Actor on delete does not match object") raise ValueError("Actor on delete does not match object")
post.delete() post.delete()
### OpenGraph API ###
def to_opengraph_dict(self) -> dict:
return {
"og:title": f"{self.author.name} (@{self.author.handle})",
"og:type": "article",
"og:published_time": (self.published or self.created).isoformat(),
"og:modified_time": (
self.edited or self.published or self.created
).isoformat(),
"og:description": (self.summary or self.safe_content_local()),
"og:image:url": self.author.local_icon_url().absolute,
"og:image:height": 85,
"og:image:width": 85,
}
### Mastodon API ### ### Mastodon API ###
def to_mastodon_json(self, interactions=None): def to_mastodon_json(self, interactions=None):

View File

@ -0,0 +1,23 @@
from django import template
register = template.Library()
@register.filter
def dict_merge(base: dict, defaults: dict):
"""
Merges two input dictionaries, returning the merged result.
`input|dict_merge:defaults`
The defaults are overridden by any key present in the `input` dict.
"""
if not (isinstance(base, dict) or isinstance(defaults, dict)):
raise ValueError("Filter inputs must be dictionaries")
result = {}
result.update(defaults)
result.update(base)
return result

View File

@ -39,11 +39,15 @@ class Individual(TemplateView):
# Show normal page # Show normal page
return super().get(request) return super().get(request)
def get_context_data(self): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
ancestors, descendants = PostService(self.post_obj).context( ancestors, descendants = PostService(self.post_obj).context(
self.request.identity self.request.identity
) )
return {
context.update(
{
"identity": self.identity, "identity": self.identity,
"post": self.post_obj, "post": self.post_obj,
"interactions": PostInteraction.get_post_interactions( "interactions": PostInteraction.get_post_interactions(
@ -54,6 +58,9 @@ class Individual(TemplateView):
"ancestors": ancestors, "ancestors": ancestors,
"descendants": descendants, "descendants": descendants,
} }
)
return context
def serve_object(self): def serve_object(self):
# If this not a local post, redirect to its canonical URI # If this not a local post, redirect to its canonical URI

View File

@ -8,4 +8,10 @@ def config_context(request):
request.identity.config_identity if request.identity else None request.identity.config_identity if request.identity else None
), ),
"top_section": request.path.strip("/").split("/")[0], "top_section": request.path.strip("/").split("/")[0],
"opengraph_defaults": {
"og:site_name": Config.system.site_name,
"og:type": "website",
"og:title": Config.system.site_name,
"og:url": request.build_absolute_uri(),
},
} }

14
templates/_opengraph.html Normal file
View File

@ -0,0 +1,14 @@
{% load opengraph %}
{% with opengraph_merged=opengraph_local|dict_merge:opengraph_defaults %}
<!-- Begin OpenGraph tagging -->
{% for key, value in opengraph_merged.items %}
<meta content="{{ value|striptags }}" property="{{ key }}"/>
{% if key == "og:description" %}
{# Mastodon duplicates this one tag without the og: prefix. Not sure why #}
<meta content="{{ value|striptags }}" property="description"/>
{% endif %}
{% endfor %}
{% block opengraph_extra %}
{% endblock %}
<!-- End OpenGraph tagging -->
{% endwith %}

View File

@ -2,6 +2,10 @@
{% block title %}Post by {{ post.author.html_name_or_handle }}{% endblock %} {% block title %}Post by {{ post.author.html_name_or_handle }}{% endblock %}
{% block opengraph %}
{% include "_opengraph.html" with opengraph_local=post.to_opengraph_dict %}
{% endblock %}
{% block content %} {% block content %}
{% for ancestor in ancestors reversed %} {% for ancestor in ancestors reversed %}
{% include "activities/_post.html" with post=ancestor reply=True link_original=False %} {% include "activities/_post.html" with post=ancestor reply=True link_original=False %}

View File

@ -26,6 +26,9 @@
{% if config_identity.custom_css %} {% if config_identity.custom_css %}
<style>{{ config_identity.custom_css }}</style> <style>{{ config_identity.custom_css }}</style>
{% endif %} {% endif %}
{% block opengraph %}
{% include "_opengraph.html" with opengraph_local=opengraph_defaults %}
{% endblock %}
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body class="{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> <body class="{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>

View File

@ -2,6 +2,10 @@
{% block title %}{{ identity }}{% endblock %} {% block title %}{{ identity }}{% endblock %}
{% block opengraph %}
{% include "_opengraph.html" with opengraph_local=identity.to_opengraph_dict %}
{% endblock %}
{% block extra_head %} {% block extra_head %}
{% if identity.local %} {% if identity.local %}
<link rel="alternate" type="application/rss+xml" title="RSS feed for {{ identity.name }}" href="rss/" /> <link rel="alternate" type="application/rss+xml" title="RSS feed for {{ identity.name }}" href="rss/" />

View File

@ -735,6 +735,19 @@ class Identity(StatorModel):
await sync_to_async(self.save)() await sync_to_async(self.save)()
return True return True
### OpenGraph API ###
def to_opengraph_dict(self) -> dict:
return {
"og:title": f"{self.name} (@{self.handle})",
"og:type": "profile",
"og:description": self.summary,
"og:profile:username": self.handle,
"og:image:url": self.local_icon_url().absolute,
"og:image:height": 85,
"og:image:width": 85,
}
### Mastodon Client API ### ### Mastodon Client API ###
def to_mastodon_json(self): def to_mastodon_json(self):