Custom emojis support

This commit is contained in:
Thomas Sileo 2019-08-20 22:16:47 +02:00
parent 5ce114c2e1
commit 181328d518
16 changed files with 193 additions and 17 deletions

14
app.py
View file

@ -70,6 +70,7 @@ from core.shared import login_required
from core.shared import noindex from core.shared import noindex
from core.shared import paginated_query from core.shared import paginated_query
from utils.blacklist import is_blacklisted from utils.blacklist import is_blacklisted
from utils.emojis import EMOJIS
from utils.key import get_secret_key from utils.key import get_secret_key
from utils.template_filters import filters from utils.template_filters import filters
@ -214,7 +215,9 @@ def _log_sig():
req_verified, actor_id = verify_request( req_verified, actor_id = verify_request(
request.method, request.path, request.headers, None request.method, request.path, request.headers, None
) )
app.logger.info(f"authenticated fetch: {req_verified}: {actor_id}") app.logger.info(
f"authenticated fetch: {req_verified}: {actor_id} {request.headers}"
)
except Exception: except Exception:
app.logger.exception("failed to verify authenticated fetch") app.logger.exception("failed to verify authenticated fetch")
@ -235,7 +238,7 @@ def robots_txt():
return Response(response=ROBOTS_TXT, headers={"Content-Type": "text/plain"}) return Response(response=ROBOTS_TXT, headers={"Content-Type": "text/plain"})
@app.route("/microblogpub-0.0.jsonld") @app.route("/microblogpub-0.1.jsonld")
def microblogpub_jsonld(): def microblogpub_jsonld():
"""Returns our AP context (embedded in activities @context).""" """Returns our AP context (embedded in activities @context)."""
return Response( return Response(
@ -497,6 +500,13 @@ def outbox():
return Response(status=201, headers={"Location": activity_id}) return Response(status=201, headers={"Location": activity_id})
@app.route("/emoji/<name>")
def ap_emoji(name):
if name in EMOJIS:
return jsonify(**{**EMOJIS[name].to_dict(), "@context": config.DEFAULT_CTX})
abort(404)
@app.route("/outbox/<item_id>") @app.route("/outbox/<item_id>")
def outbox_detail(item_id): def outbox_detail(item_id):
doc = DB.activities.find_one( doc = DB.activities.find_one(

View file

@ -37,6 +37,7 @@ from core.shared import noindex
from core.shared import p from core.shared import p
from core.shared import paginated_query from core.shared import paginated_query
from utils import now from utils import now
from utils.emojis import EMOJIS_BY_NAME
from utils.lookup import lookup from utils.lookup import lookup
blueprint = flask.Blueprint("admin", __name__) blueprint = flask.Blueprint("admin", __name__)
@ -252,6 +253,7 @@ def admin_new() -> _Response:
thread=thread, thread=thread,
visibility=ap.Visibility, visibility=ap.Visibility,
emojis=config.EMOJIS.split(" "), emojis=config.EMOJIS.split(" "),
custom_emojis=EMOJIS_BY_NAME,
) )

View file

@ -43,6 +43,7 @@ from core.shared import _Response
from core.shared import csrf from core.shared import csrf
from core.shared import login_required from core.shared import login_required
from core.tasks import Tasks from core.tasks import Tasks
from utils import emojis
from utils import now from utils import now
blueprint = flask.Blueprint("api", __name__) blueprint = flask.Blueprint("api", __name__)
@ -398,6 +399,9 @@ def api_new_note() -> _Response:
content, tags = parse_markdown(source) content, tags = parse_markdown(source)
# Check for custom emojis
tags = tags + emojis.tags(content)
to: List[str] = [] to: List[str] = []
cc: List[str] = [] cc: List[str] = []
@ -467,6 +471,8 @@ def api_new_question() -> _Response:
raise ValueError("missing content") raise ValueError("missing content")
content, tags = parse_markdown(source) content, tags = parse_markdown(source)
tags = tags + emojis.tags(content)
cc = [ID + "/followers"] cc = [ID + "/followers"]
for tag in tags: for tag in tags:

View file

@ -147,6 +147,7 @@ def task_cache_object() -> _Response:
activity = ap.fetch_remote_activity(iri) activity = ap.fetch_remote_activity(iri)
app.logger.info(f"activity={activity!r}") app.logger.info(f"activity={activity!r}")
obj = activity.get_object() obj = activity.get_object()
Tasks.cache_emojis(obj)
# Refetch the object actor (without cache) # Refetch the object actor (without cache)
obj_actor = ap.fetch_remote_activity(obj.get_actor().id, no_cache=True) obj_actor = ap.fetch_remote_activity(obj.get_actor().id, no_cache=True)
@ -367,6 +368,22 @@ def task_cache_actor_icon() -> _Response:
return "" return ""
@blueprint.route("/task/cache_emoji", methods=["POST"])
def task_cache_emoji() -> _Response:
task = p.parse(flask.request)
app.logger.info(f"task={task!r}")
iri = task.payload["iri"]
url = task.payload["url"]
try:
MEDIA_CACHE.cache_emoji(url, iri)
except Exception as exc:
err = f"failed to cache emoji {url} at {iri}"
app.logger.exception(err)
raise TaskError() from exc
return ""
@blueprint.route("/task/forward_activity", methods=["POST"]) @blueprint.route("/task/forward_activity", methods=["POST"])
def task_forward_activity() -> _Response: def task_forward_activity() -> _Response:
task = p.parse(flask.request) task = p.parse(flask.request)

View file

@ -3,6 +3,7 @@ import os
import subprocess import subprocess
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from pathlib import Path
import yaml import yaml
from itsdangerous import JSONWebSignatureSerializer from itsdangerous import JSONWebSignatureSerializer
@ -11,11 +12,14 @@ from little_boxes.activitypub import DEFAULT_CTX as AP_DEFAULT_CTX
from pymongo import MongoClient from pymongo import MongoClient
import sass import sass
from utils.emojis import _load_emojis
from utils.key import KEY_DIR from utils.key import KEY_DIR
from utils.key import get_key from utils.key import get_key
from utils.key import get_secret_key from utils.key import get_secret_key
from utils.media import MediaCache from utils.media import MediaCache
ROOT_DIR = Path(__file__).parent.absolute()
class ThemeStyle(Enum): class ThemeStyle(Enum):
LIGHT = "light" LIGHT = "light"
@ -75,7 +79,7 @@ with open(os.path.join(KEY_DIR, "me.yml")) as f:
DEFAULT_CTX = [ DEFAULT_CTX = [
AP_DEFAULT_CTX, AP_DEFAULT_CTX,
f"{BASE_URL}/microblogpub-0.0.jsonld", f"{BASE_URL}/microblogpub-0.1.jsonld",
{"@language": "und"}, {"@language": "und"},
] ]
@ -164,3 +168,6 @@ BLACKLIST = conf.get("blacklist", [])
# By default, we keep 14 of inbox data ; outbox is kept forever (along with bookmarked stuff, outbox replies, liked...) # By default, we keep 14 of inbox data ; outbox is kept forever (along with bookmarked stuff, outbox replies, liked...)
DAYS_TO_KEEP = 14 DAYS_TO_KEEP = 14
# Load custom emojis (stored in static/emojis)
_load_emojis(ROOT_DIR, BASE_URL)

View file

@ -628,6 +628,7 @@ def update_cached_actor(actor: ap.BaseActivity) -> None:
# {"meta.object_id": actor.id}, {"$set": {"meta.object": actor.to_dict(embed=True)}} # {"meta.object_id": actor.id}, {"$set": {"meta.object": actor.to_dict(embed=True)}}
# ) # )
_cache_actor_icon(actor) _cache_actor_icon(actor)
Tasks.cache_emojis(actor)
def handle_question_reply(create: ap.Create, question: ap.Question) -> None: def handle_question_reply(create: ap.Create, question: ap.Question) -> None:

View file

@ -110,9 +110,11 @@ def _create_process_inbox(create: ap.Create, new_meta: _NewMeta) -> None:
_logger.info(f"process_inbox activity={create!r}") _logger.info(f"process_inbox activity={create!r}")
# If it's a `Quesiion`, trigger an async task for updating it later (by fetching the remote and updating the # If it's a `Quesiion`, trigger an async task for updating it later (by fetching the remote and updating the
# local copy) # local copy)
question = create.get_object() obj = create.get_object()
if question.has_type(ap.ActivityType.QUESTION): if obj.has_type(ap.ActivityType.QUESTION):
Tasks.fetch_remote_question(question) Tasks.fetch_remote_question(obj)
Tasks.cache_emojis(obj)
handle_replies(create) handle_replies(create)

View file

@ -12,6 +12,7 @@ MICROBLOGPUB = {
"toot": "http://joinmastodon.org/ns#", "toot": "http://joinmastodon.org/ns#",
"totalItems": "as:totalItems", "totalItems": "as:totalItems",
"value": "schema:value", "value": "schema:value",
"Emoji": "toot:Emoji",
}, },
] ]
} }

View file

@ -4,6 +4,7 @@ from datetime import timezone
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from little_boxes import activitypub as ap
from poussetaches import PousseTaches from poussetaches import PousseTaches
from config import MEDIA_CACHE from config import MEDIA_CACHE
@ -32,7 +33,21 @@ class Tasks:
if MEDIA_CACHE.is_actor_icon_cached(icon_url): if MEDIA_CACHE.is_actor_icon_cached(icon_url):
return None return None
p.push({"icon_url": icon_url, "actor_iri": actor_iri}, "/task/cache_actor_icon") @staticmethod
def cache_emoji(url: str, iri: str) -> None:
if MEDIA_CACHE.is_emoji_cached(iri):
return None
p.push({"url": url, "iri": iri}, "/task/cache_emoji")
@staticmethod
def cache_emojis(activity: ap.BaseActivity) -> None:
for emoji in activity.get_emojis():
try:
Tasks.cache_emoji(emoji.get_icon_url(), emoji.id)
except KeyError:
# TODO(tsileo): log invalid emoji
pass
@staticmethod @staticmethod
def post_to_remote_inbox(payload: str, recp: str) -> None: def post_to_remote_inbox(payload: str, recp: str) -> None:

2
static/emojis/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -15,6 +15,11 @@
.icon { color: #555; } .icon { color: #555; }
.emoji { .emoji {
width: 20px; width: 20px;
height: 20px;
}
.custom-emoji {
width: 25px;
height: 25px;
} }
</style> </style>
{% block headers %}{% endblock %} {% block headers %}{% endblock %}

View file

@ -32,6 +32,9 @@
{% for emoji in emojis %} {% for emoji in emojis %}
<span class="ji">{{ emoji | emojify | safe }}</span> <span class="ji">{{ emoji | emojify | safe }}</span>
{% endfor %} {% endfor %}
{% for emoji in custom_emojis.values() %}
<span class="ji"><img src="{{emoji.get_icon_url()}}" alt="{{emoji.name}}" title="{{emoji.name}}" class="custom-emoji"></span>
{% endfor %}
</p> </p>
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on">{{ content }}</textarea> <textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on">{{ content }}</textarea>

View file

@ -8,7 +8,7 @@
<img class="actor-icon" src="{{ follower.icon.url | get_actor_icon_url(size) }}" style="width:{{ size }}px;">{% endif %} <img class="actor-icon" src="{{ follower.icon.url | get_actor_icon_url(size) }}" style="width:{{ size }}px;">{% endif %}
</span> </span>
<div class="actor-inline"> <div class="actor-inline">
<div style="font-weight:bold">{{ follower.name or follower.preferredUsername }}</div> <div style="font-weight:bold">{{ (follower.name or follower.preferredUsername) | clean | replace_custom_emojis(follower) | safe }}</div>
<small class="lcolor">@{{ follower.preferredUsername }}@{{ follower | url_or_id | get_url | domain }}</small> <small class="lcolor">@{{ follower.preferredUsername }}@{{ follower | url_or_id | get_url | domain }}</small>
</div> </div>
</a> </a>
@ -53,8 +53,9 @@
<div class="note-wrapper"> <div class="note-wrapper">
<div style="clear:both;height:20px;"> <div style="clear:both;height:20px;">
<a href="{{ actor | url_or_id | get_url }}" style="margin:0;text-decoration:none;margin: 0;text-decoration: none;display: block;width: 75%;overflow: hidden;white-space: nowrap;text-overflow: ellipsis;float: left;" class="no-hover"><strong>{{ actor.name or actor.preferredUsername }}</strong> <a href="{{ actor | url_or_id | get_url }}" style="margin:0;text-decoration:none;margin: 0;text-decoration: none;display: block;width: 75%;overflow: hidden;white-space: nowrap;text-overflow: ellipsis;float: left;" class="no-hover">
<span class="l">@{% if not no_color and obj.id | is_from_outbox %}<span class="pcolor">{{ actor.preferredUsername }}</span>{% else %}{{ actor.preferredUsername }}{% endif %}@{% if not no_color and obj.id | is_from_outbox %}<span class="pcolor">{{ actor | url_or_id | get_url | domain }}</span>{% else %}{{ actor | url_or_id | get_url | domain }}{% endif %}</span></a> <strong>{{ (actor.name or actor.preferredUsername) | clean | replace_custom_emojis(actor) | safe }}</strong>
<span class="l">@{% if not no_color and obj.id | is_from_outbox %}<span class="pcolor">{{ actor.preferredUsername | clean | replace_custom_emojis(actor) | safe }}</span>{% else %}{{ actor.preferredUsername | clean | replace_custom_emojis(actor) | safe }}{% endif %}@{% if not no_color and obj.id | is_from_outbox %}<span class="pcolor">{{ actor | url_or_id | get_url | domain }}</span>{% else %}{{ actor | url_or_id | get_url | domain }}{% endif %}</span></a>
{% if not perma %} {% if not perma %}
<span style="float:right;width: 25%;text-align: right;overflow: hidden;white-space: nowrap;text-overflow: ellipsis;display: block;"> <span style="float:right;width: 25%;text-align: right;overflow: hidden;white-space: nowrap;text-overflow: ellipsis;display: block;">
@ -64,7 +65,7 @@
{% endif %} {% endif %}
</div> </div>
{% if obj.summary %}<p class="p-summary">{{ obj.summary | clean | safe }}</p>{% endif %} {% if obj.summary %}<p class="p-summary">{{ obj.summary | clean | replace_custom_emojis(obj) | safe }}</p>{% endif %}
{% if obj | has_type('Video') %} {% if obj | has_type('Video') %}
<div class="note-video"> <div class="note-video">
<video controls preload="metadata" src="{{ obj.url | get_video_url }}" width="480"> <video controls preload="metadata" src="{{ obj.url | get_video_url }}" width="480">
@ -76,7 +77,7 @@
{% if obj | has_type(['Article', 'Page']) %} {% if obj | has_type(['Article', 'Page']) %}
{{ obj.name }} <a href="{{ obj | url_or_id | get_url }}">{{ obj | url_or_id | get_url }}</a> {{ obj.name }} <a href="{{ obj | url_or_id | get_url }}">{{ obj | url_or_id | get_url }}</a>
{% elif obj | has_type('Question') %} {% elif obj | has_type('Question') %}
{{ obj.content | clean | safe }} {{ obj.content | clean | replace_custom_emojis(obj) | safe }}
<ul style="list-style:none;padding:0;"> <ul style="list-style:none;padding:0;">
@ -145,7 +146,7 @@
{% else %} {% else %}
{{ obj.content | clean | safe }} {{ obj.content | clean | replace_custom_emojis(obj) | safe }}
{% endif %} {% endif %}
</div> </div>

47
utils/emojis.py Normal file
View file

@ -0,0 +1,47 @@
import mimetypes
import re
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Dict
from typing import List
from typing import Set
from little_boxes import activitypub as ap
EMOJI_REGEX = re.compile(r"(:[\d\w]+:)")
EMOJIS: Dict[str, ap.Emoji] = {}
EMOJIS_BY_NAME: Dict[str, ap.Emoji] = {}
def _load_emojis(root_dir: Path, base_url: str) -> None:
if EMOJIS:
return
for emoji in (root_dir / "static" / "emojis").iterdir():
mt = mimetypes.guess_type(emoji.name)[0]
if mt and mt.startswith("image/"):
name = emoji.name.split(".")[0]
ap_emoji = ap.Emoji(
name=f":{name}:",
updated=ap.format_datetime(datetime.fromtimestamp(0.0).astimezone()),
id=f"{base_url}/emoji/{name}",
icon={
"mediaType": mt,
"type": ap.ActivityType.IMAGE.value,
"url": f"{base_url}/static/emojis/{emoji.name}",
},
)
EMOJIS[emoji.name] = ap_emoji
EMOJIS_BY_NAME[ap_emoji.name] = ap_emoji
def tags(content: str) -> List[Dict[str, Any]]:
tags: List[Dict[str, Any]] = []
added: Set[str] = set()
for e in re.findall(EMOJI_REGEX, content):
if e not in added and e in EMOJIS_BY_NAME:
tags.append(EMOJIS_BY_NAME[e].to_dict())
added.add(e)
return tags

View file

@ -5,8 +5,11 @@ from enum import unique
from functools import lru_cache from functools import lru_cache
from gzip import GzipFile from gzip import GzipFile
from io import BytesIO from io import BytesIO
from shutil import copyfileobj
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import Optional
from typing import Tuple
import gridfs import gridfs
import piexif import piexif
@ -31,13 +34,26 @@ def is_video(filename):
return False return False
def load(url: str, user_agent: str) -> Image: def _load(url: str, user_agent: str) -> Tuple[BytesIO, Optional[str]]:
"""Initializes a `PIL.Image` from the URL.""" """Initializes a `PIL.Image` from the URL."""
out = BytesIO()
with requests.get(url, stream=True, headers={"User-Agent": user_agent}) as resp: with requests.get(url, stream=True, headers={"User-Agent": user_agent}) as resp:
resp.raise_for_status() resp.raise_for_status()
resp.raw.decode_content = True resp.raw.decode_content = True
return Image.open(BytesIO(resp.raw.read())) while 1:
buf = resp.raw.read()
if not buf:
break
out.write(buf)
out.seek(0)
return out, resp.headers.get("content-type")
def load(url: str, user_agent: str) -> Image:
"""Initializes a `PIL.Image` from the URL."""
out, _ = _load(url, user_agent)
return Image.open(out)
def to_data_uri(img: Image) -> str: def to_data_uri(img: Image) -> str:
@ -54,6 +70,7 @@ class Kind(Enum):
ACTOR_ICON = "actor_icon" ACTOR_ICON = "actor_icon"
UPLOAD = "upload" UPLOAD = "upload"
OG_IMAGE = "og" OG_IMAGE = "og"
EMOJI = "emoji"
class MediaCache(object): class MediaCache(object):
@ -173,6 +190,26 @@ class MediaCache(object):
kind=Kind.ACTOR_ICON.value, kind=Kind.ACTOR_ICON.value,
) )
def is_emoji_cached(self, url: str) -> bool:
return bool(self.fs.find_one({"url": url, "kind": Kind.EMOJI.value}))
def cache_emoji(self, url: str, iri: str) -> None:
if self.is_emoji_cached(url):
return
src, content_type = _load(url, self.user_agent)
with BytesIO() as buf:
with GzipFile(mode="wb", fileobj=buf) as g:
copyfileobj(src, g)
buf.seek(0)
self.fs.put(
buf,
url=url,
remote_id=iri,
size=None,
content_type=content_type or mimetypes.guess_type(url)[0],
kind=Kind.EMOJI.value,
)
def save_upload(self, obuf: BytesIO, filename: str) -> str: def save_upload(self, obuf: BytesIO, filename: str) -> str:
# Remove EXIF metadata # Remove EXIF metadata
if filename.lower().endswith(".jpg") or filename.lower().endswith(".jpeg"): if filename.lower().endswith(".jpg") or filename.lower().endswith(".jpeg"):

View file

@ -91,6 +91,25 @@ ALLOWED_TAGS = [
] ]
@filters.app_template_filter()
def replace_custom_emojis(content, note):
print("\n" * 50)
print("custom_replace", note)
idx = {}
for tag in note.get("tag", []):
if tag.get("type") == "Emoji":
# try:
idx[tag["name"]] = _get_file_url(tag["icon"]["url"], None, Kind.EMOJI)
for emoji_name, emoji_url in idx.items():
content = content.replace(
emoji_name,
f'<img class="custom-emoji" src="{emoji_url}" title="{emoji_name}" alt="{emoji_name}">',
)
return content
def clean_html(html): def clean_html(html):
try: try:
return bleach.clean(html, tags=ALLOWED_TAGS, strip=True) return bleach.clean(html, tags=ALLOWED_TAGS, strip=True)
@ -237,6 +256,9 @@ _FILE_URL_CACHE = LRUCache(4096)
def _get_file_url(url, size, kind) -> str: def _get_file_url(url, size, kind) -> str:
if url.startswith(BASE_URL):
return url
k = (url, size, kind) k = (url, size, kind)
cached = _FILE_URL_CACHE.get(k) cached = _FILE_URL_CACHE.get(k)
if cached: if cached:
@ -249,8 +271,6 @@ def _get_file_url(url, size, kind) -> str:
return out return out
_logger.error(f"cache not available for {url}/{size}/{kind}") _logger.error(f"cache not available for {url}/{size}/{kind}")
if url.startswith(BASE_URL):
return url
p = urlparse(url) p = urlparse(url)
return f"/p/{p.scheme}" + p._replace(scheme="").geturl()[1:] return f"/p/{p.scheme}" + p._replace(scheme="").geturl()[1:]