proxy avatars

this fixes some potential issues when connecting to a non-https mastodon
server

you shouldn't connect to a non-https mastodon server in general but you
know, whatever
This commit is contained in:
codl 2017-09-16 13:58:02 +02:00
parent e4b443ce45
commit 5e1ce21c82
No known key found for this signature in database
GPG Key ID: 6CD7C8891ED1233A
7 changed files with 77 additions and 7 deletions

5
app.py
View File

@ -10,6 +10,7 @@ from lib.auth import get_viewer
import os import os
import mimetypes import mimetypes
import lib.brotli import lib.brotli
import lib.img_proxy
app = Flask(__name__) app = Flask(__name__)
@ -82,7 +83,7 @@ limiter = Limiter(app, key_func=rate_limit_key)
@app.after_request @app.after_request
def install_security_headers(resp): def install_security_headers(resp):
csp = ("default-src 'none';" csp = ("default-src 'none';"
"img-src 'self' https:;" "img-src 'self';"
"style-src 'self' 'unsafe-inline';" "style-src 'self' 'unsafe-inline';"
"frame-ancestors 'none';" "frame-ancestors 'none';"
) )
@ -113,3 +114,5 @@ def install_security_headers(resp):
mimetypes.add_type('image/webp', '.webp') mimetypes.add_type('image/webp', '.webp')
lib.brotli.brotli(app) lib.brotli.brotli(app)
imgproxy = lib.img_proxy.ImgProxyCache()

View File

@ -14,7 +14,7 @@ class BrotliCache(object):
self.expire = expire self.expire = expire
self.redis.client_setname('brotlicache') self.redis.client_setname('brotlicache')
def compress(self, cache_key, lock_key, body, mode=brotli_.MODE_GENERIC): def compress_and_cache(self, cache_key, lock_key, body, mode=brotli_.MODE_GENERIC):
encbody = brotli_.compress(body, mode=mode) encbody = brotli_.compress(body, mode=mode)
self.redis.set(cache_key, encbody, px=int(self.expire*1000)) self.redis.set(cache_key, encbody, px=int(self.expire*1000))
self.redis.delete(lock_key) self.redis.delete(lock_key)
@ -38,7 +38,7 @@ class BrotliCache(object):
if response.content_type.startswith('text/') if response.content_type.startswith('text/')
else brotli_.MODE_GENERIC) else brotli_.MODE_GENERIC)
t = Thread( t = Thread(
target=self.compress, target=self.compress_and_cache,
args=(cache_key, lock_key, body, mode)) args=(cache_key, lock_key, body, mode))
t.start() t.start()
if self.timeout > 0: if self.timeout > 0:

49
lib/img_proxy.py Normal file
View File

@ -0,0 +1,49 @@
import requests
import threading
import redis
from flask import make_response, abort
class ImgProxyCache(object):
def __init__(self, redis_uri='redis://', timeout=1, expire=60*60*6,
prefix='img_proxy'):
self.redis = redis.StrictRedis.from_url(redis_uri)
self.timeout = timeout
self.expire = expire
self.prefix = prefix
self.redis.client_setname('img_proxy')
def key(self, keytype, url):
return '{prefix}:{keytype}:{url}'.format(
prefix=self.prefix,
keytype=keytype,
url=url)
def fetch_and_cache(self, url):
resp = requests.get(url)
if(resp.status_code != 200):
return
mime = resp.headers.get('content-type', 'application/octet-stream')
self.redis.set(self.key('mime', url), mime, px=self.expire)
self.redis.set(self.key('body', url), resp.content, px=self.expire)
def respond(self, url):
x_imgproxy_cache = 'HIT'
mime = self.redis.get(self.key('mime', url))
body = self.redis.get(self.key('body', url))
if not body or not mime:
x_imgproxy_cache = 'MISS'
if self.redis.set(
self.key('lock', url), 1, nx=True, ex=10*self.timeout):
t = threading.Thread(target=self.fetch_and_cache, args=(url,))
t.start()
t.join(self.timeout)
mime = self.redis.get(self.key('mime', url))
body = self.redis.get(self.key('body', url))
if not body or not mime:
return abort(404)
resp = make_response(body, 200)
resp.headers.set('content-type', mime)
resp.headers.set('x-imgproxy-cache', x_imgproxy_cache)
return resp

View File

@ -1,4 +1,5 @@
from json import dumps from json import dumps
from flask import url_for
def account(acc): def account(acc):
@ -13,7 +14,8 @@ def account(acc):
eligible_for_delete_estimate=acc.estimate_eligible_for_delete(), eligible_for_delete_estimate=acc.estimate_eligible_for_delete(),
display_name=acc.display_name, display_name=acc.display_name,
screen_name=acc.screen_name, screen_name=acc.screen_name,
avatar_url=acc.avatar_url, avatar_url=url_for('avatar', urlhash=acc.avatar_url_hash()),
avatar_url_orig=acc.avatar_url,
id=acc.id, id=acc.id,
service=acc.service, service=acc.service,
policy_enabled=acc.policy_enabled, policy_enabled=acc.policy_enabled,

View File

@ -3,6 +3,7 @@ from datetime import timedelta, datetime, timezone
from app import db from app import db
import secrets import secrets
from lib.interval import decompose_interval from lib.interval import decompose_interval
import hashlib
class TimestampMixin(object): class TimestampMixin(object):
@ -119,6 +120,11 @@ class Account(TimestampMixin, RemoteIDMixin):
def touch_refresh(self): def touch_refresh(self):
self.last_refresh = db.func.now() self.last_refresh = db.func.now()
def avatar_url_hash(self):
if not self.avatar_url:
return None
return hashlib.sha1(self.avatar_url.encode('utf-8')).hexdigest()
@db.validates('policy_keep_younger', 'policy_delete_every') @db.validates('policy_keep_younger', 'policy_delete_every')
def validate_intervals(self, key, value): def validate_intervals(self, key, value):
if not (value == timedelta(0) or value >= timedelta(minutes=1)): if not (value == timedelta(0) or value >= timedelta(minutes=1)):

View File

@ -1,12 +1,12 @@
from flask import render_template, url_for, redirect, request, g,\ from flask import render_template, url_for, redirect, request, g,\
make_response make_response, abort
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import lib.twitter import lib.twitter
import lib.mastodon import lib.mastodon
from lib.auth import require_auth, csrf,\ from lib.auth import require_auth, csrf,\
get_viewer get_viewer
from model import Session, TwitterArchive, MastodonApp, MastodonInstance from model import Session, TwitterArchive, MastodonApp, MastodonInstance
from app import app, db, sentry, limiter from app import app, db, sentry, limiter, imgproxy
import tasks import tasks
from zipfile import BadZipFile from zipfile import BadZipFile
from twitter import TwitterError from twitter import TwitterError
@ -288,3 +288,13 @@ def dismiss():
get_viewer().reason = None get_viewer().reason = None
db.session.commit() db.session.commit()
return redirect(url_for('index')) return redirect(url_for('index'))
@app.route('/avatar/<urlhash>')
@require_auth
def avatar(urlhash):
viewer = get_viewer()
if (not viewer.avatar_url or not urlhash == viewer.avatar_url_hash()):
return abort(404)
return imgproxy.respond(viewer.avatar_url)

View File

@ -8,7 +8,7 @@
<section class=viewer> <section class=viewer>
<p>Hello, <p>Hello,
<img class=avatar id=avatar src="{{g.viewer.account.avatar_url}}"/> <img class=avatar id=avatar src="{{url_for('avatar', urlhash=g.viewer.account.avatar_url_hash())}}"/>
<span id='display-name' title='@{{g.viewer.account.screen_name}}'>{{g.viewer.account.display_name or g.viewer.account.screen_name}}</span>! <span id='display-name' title='@{{g.viewer.account.screen_name}}'>{{g.viewer.account.display_name or g.viewer.account.screen_name}}</span>!
<a href="{{url_for('logout')}}">Log out</a> <a href="{{url_for('logout')}}">Log out</a>
</p> </p>