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 paginated_query
from utils.blacklist import is_blacklisted
from utils.emojis import EMOJIS
from utils.key import get_secret_key
from utils.template_filters import filters
@ -214,7 +215,9 @@ def _log_sig():
req_verified, actor_id = verify_request(
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:
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"})
@app.route("/microblogpub-0.0.jsonld")
@app.route("/microblogpub-0.1.jsonld")
def microblogpub_jsonld():
"""Returns our AP context (embedded in activities @context)."""
return Response(
@ -497,6 +500,13 @@ def outbox():
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>")
def outbox_detail(item_id):
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 paginated_query
from utils import now
from utils.emojis import EMOJIS_BY_NAME
from utils.lookup import lookup
blueprint = flask.Blueprint("admin", __name__)
@ -252,6 +253,7 @@ def admin_new() -> _Response:
thread=thread,
visibility=ap.Visibility,
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 login_required
from core.tasks import Tasks
from utils import emojis
from utils import now
blueprint = flask.Blueprint("api", __name__)
@ -398,6 +399,9 @@ def api_new_note() -> _Response:
content, tags = parse_markdown(source)
# Check for custom emojis
tags = tags + emojis.tags(content)
to: List[str] = []
cc: List[str] = []
@ -467,6 +471,8 @@ def api_new_question() -> _Response:
raise ValueError("missing content")
content, tags = parse_markdown(source)
tags = tags + emojis.tags(content)
cc = [ID + "/followers"]
for tag in tags:

View file

@ -147,6 +147,7 @@ def task_cache_object() -> _Response:
activity = ap.fetch_remote_activity(iri)
app.logger.info(f"activity={activity!r}")
obj = activity.get_object()
Tasks.cache_emojis(obj)
# Refetch the object actor (without cache)
obj_actor = ap.fetch_remote_activity(obj.get_actor().id, no_cache=True)
@ -367,6 +368,22 @@ def task_cache_actor_icon() -> _Response:
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"])
def task_forward_activity() -> _Response:
task = p.parse(flask.request)

View file

@ -3,6 +3,7 @@ import os
import subprocess
from datetime import datetime
from enum import Enum
from pathlib import Path
import yaml
from itsdangerous import JSONWebSignatureSerializer
@ -11,11 +12,14 @@ from little_boxes.activitypub import DEFAULT_CTX as AP_DEFAULT_CTX
from pymongo import MongoClient
import sass
from utils.emojis import _load_emojis
from utils.key import KEY_DIR
from utils.key import get_key
from utils.key import get_secret_key
from utils.media import MediaCache
ROOT_DIR = Path(__file__).parent.absolute()
class ThemeStyle(Enum):
LIGHT = "light"
@ -75,7 +79,7 @@ with open(os.path.join(KEY_DIR, "me.yml")) as f:
DEFAULT_CTX = [
AP_DEFAULT_CTX,
f"{BASE_URL}/microblogpub-0.0.jsonld",
f"{BASE_URL}/microblogpub-0.1.jsonld",
{"@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...)
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)}}
# )
_cache_actor_icon(actor)
Tasks.cache_emojis(actor)
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}")
# If it's a `Quesiion`, trigger an async task for updating it later (by fetching the remote and updating the
# local copy)
question = create.get_object()
if question.has_type(ap.ActivityType.QUESTION):
Tasks.fetch_remote_question(question)
obj = create.get_object()
if obj.has_type(ap.ActivityType.QUESTION):
Tasks.fetch_remote_question(obj)
Tasks.cache_emojis(obj)
handle_replies(create)

View file

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

View file

@ -4,6 +4,7 @@ from datetime import timezone
from typing import Any
from typing import Dict
from little_boxes import activitypub as ap
from poussetaches import PousseTaches
from config import MEDIA_CACHE
@ -32,7 +33,21 @@ class Tasks:
if MEDIA_CACHE.is_actor_icon_cached(icon_url):
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
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; }
.emoji {
width: 20px;
height: 20px;
}
.custom-emoji {
width: 25px;
height: 25px;
}
</style>
{% block headers %}{% endblock %}

View file

@ -32,6 +32,9 @@
{% for emoji in emojis %}
<span class="ji">{{ emoji | emojify | safe }}</span>
{% 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>
<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 %}
</span>
<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>
</div>
</a>
@ -53,8 +53,9 @@
<div class="note-wrapper">
<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>
<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>
<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) | 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 %}
<span style="float:right;width: 25%;text-align: right;overflow: hidden;white-space: nowrap;text-overflow: ellipsis;display: block;">
@ -64,7 +65,7 @@
{% endif %}
</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') %}
<div class="note-video">
<video controls preload="metadata" src="{{ obj.url | get_video_url }}" width="480">
@ -76,7 +77,7 @@
{% if obj | has_type(['Article', 'Page']) %}
{{ obj.name }} <a href="{{ obj | url_or_id | get_url }}">{{ obj | url_or_id | get_url }}</a>
{% elif obj | has_type('Question') %}
{{ obj.content | clean | safe }}
{{ obj.content | clean | replace_custom_emojis(obj) | safe }}
<ul style="list-style:none;padding:0;">
@ -145,7 +146,7 @@
{% else %}
{{ obj.content | clean | safe }}
{{ obj.content | clean | replace_custom_emojis(obj) | safe }}
{% endif %}
</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 gzip import GzipFile
from io import BytesIO
from shutil import copyfileobj
from typing import Any
from typing import Dict
from typing import Optional
from typing import Tuple
import gridfs
import piexif
@ -31,13 +34,26 @@ def is_video(filename):
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."""
out = BytesIO()
with requests.get(url, stream=True, headers={"User-Agent": user_agent}) as resp:
resp.raise_for_status()
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:
@ -54,6 +70,7 @@ class Kind(Enum):
ACTOR_ICON = "actor_icon"
UPLOAD = "upload"
OG_IMAGE = "og"
EMOJI = "emoji"
class MediaCache(object):
@ -173,6 +190,26 @@ class MediaCache(object):
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:
# Remove EXIF metadata
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):
try:
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:
if url.startswith(BASE_URL):
return url
k = (url, size, kind)
cached = _FILE_URL_CACHE.get(k)
if cached:
@ -249,8 +271,6 @@ def _get_file_url(url, size, kind) -> str:
return out
_logger.error(f"cache not available for {url}/{size}/{kind}")
if url.startswith(BASE_URL):
return url
p = urlparse(url)
return f"/p/{p.scheme}" + p._replace(scheme="").geturl()[1:]