start on instance approval mode

This commit is contained in:
Izalia Mae 2020-03-16 21:29:56 -04:00
parent 9d1445c080
commit 67efd787c7
10 changed files with 272 additions and 55 deletions

View file

@ -36,6 +36,10 @@ if not isfile(f'{stor_path}/production.env'):
#PAWS_DOMAIN=bappypaws.example.com
#PAWS_NORSS=true
### Require approval for unknown instances. Set to true for all AP servers or a comma-separated list for specific server software
### Ex: mastodon,pleroma,miskey,activityrelay,unciarelay,other,unidentified
#PAWS_REQ_APPROVAL=false
#MASTOPATH=/home/mastodon/glitch-soc
#MASTOHOST=localhost:3000
'''
@ -48,13 +52,21 @@ if not isfile(f'{stor_path}/production.env'):
else:
load_envbash(f'{stor_path}/production.env')
req_approval = boolean(env.get('PAWS_REQ_APPROVAL', False), False)
if not isinstance(req_approval, bool):
req_approval = [sw.strip() for sw in req_approval.split(',')]
PAWSCONFIG = {
'host': env.get('PAWS_HOST', '127.0.0.1'),
'port': int(env.get('PAWS_PORT', 3001)),
'domain': env.get('PAWS_DOMAIN', 'bappypaws.example.com'),
'disable_rss': boolean(env.get('PAWS_DISABLE_RSS', True)),
'req_approval': req_approval,
'mastopath': env.get('MASTOPATH', os.getcwd()),
'mastohost': env.get('MASTOHOST', 'localhost:3000')
'mastohost': env.get('MASTOHOST', 'localhost:3000'),
}

View file

@ -15,9 +15,10 @@ from tinyrecord import transaction as trans
from tldextract import extract
from Crypto.PublicKey import RSA
from mastodon import Mastodon
from mastodon.Mastodon import MastodonUnauthorizedError, MastodonBadGatewayError
from mastodon.Mastodon import MastodonUnauthorizedError, MastodonBadGatewayError, MastodonNetworkError
from .config import stor_path, MASTOCONFIG as mdb
from .functions import fetch, get_nodeinfo
def jsondb():
@ -32,8 +33,7 @@ def jsondb():
class table:
keys = db.table('keys')
follows = db.table('follows')
accept = db.table('accept')
instances = db.table('instances')
users = db.table('users')
whitelist = db.table('whitelist')
@ -221,6 +221,55 @@ def whitelist(action, instance):
return 'InvalidAction'
def get_instances(domain=None, state=None):
if domain:
rows = table.instances.get(query.domain == domain)
elif state:
rows = table.instances.search(query.state == state)
else:
rows = table.instances.all()
return rows
def instances(action, instance, state=None):
domain = urlparse(instance).netloc if instance.startswith('http') else instance
req_data = table.requests.get(query.domain == domain)
if action == 'add':
if not state:
logging.debug('Instance state not specified')
return
if req_data:
logging.debug(f'Domain already in request list: {domain}')
return
ni_software = get_nodeinfo(domain)
if not ni_software:
logging.debug(f'Failed to get nodeinfo data from instance: {domain}')
data = {
'instance': domain,
'software': ni_software['name'],
'state': state,
'timestamp': datetime.timestamp(datetime.now())
}
with trans(table.instances) as tr:
tr.insert(data)
elif action == 'remove':
if not req_data:
logging.debug(f'Domain not in request list: {domain}')
with trans(table.instances) as tr:
tr.remove(doc_ids=[req_data.doc_id])
def cleanup_users(invalid_check=None):
timestamp = datetime.timestamp(datetime.now())
invalid_offset = 60 * 15
@ -253,7 +302,7 @@ def cleanup_users(invalid_check=None):
logging.debug(f'invalid token for {user["handle"]}@{user["domain"]}')
tr.remove(doc_ids=[user.doc_id])
except (urllib3.exceptions.NewConnectionError, MastodonBadGatewayError):
except (urllib3.exceptions.NewConnectionError, MastodonBadGatewayError, MastodonNetworkError):
logging.debug(f'failed to connect to domain for {user["handle"]}@{user["domain"]}')

View file

@ -1,11 +1,20 @@
import json, socket, os, re
import aiohttp, validators
import aiohttp, validators, urllib3
from http import client as http
from urllib.parse import urlparse
from IzzyLib import logging
from IzzyLib.template import aiohttpTemplate
from IzzyLib.cache import LRUCache
from .config import VERSION
httpclient = urllib3.PoolManager(num_pools=100, timeout=urllib3.Timeout(connect=15, read=15))
class cache:
url = LRUCache()
def error(request, code, msg, isjson=False):
@ -24,30 +33,55 @@ def json_error(code, error):
def http_error(request, code, msg):
data = {'msg': msg, 'code': str(code)}
return aiohttpTemplate('pages/error.html', data, request, status=code)
return aiohttpTemplate('error.html', data, request, status=code)
def fed_domain(user, domain):
headers = {'Accept': 'application/json'}
conn = http.HTTPSConnection(domain)
conn.request('GET', f'/.well-known/webfinger?resource=acct:{user}@{domain}', headers=headers)
response = conn.getresponse()
data = fetch(f'https://{domain}/.well-known/webfinger?resource=acct:{user}@{domain}')
if response.status != 200:
if not data:
logging.error(f'User doesn\'t exist: {user}@{domain}')
return
try:
data = json.loads(response.read())
except:
logging.debug(f'Invalid user: {user}@{domain}')
wf_data = data.get('subject')
user_data = wf_data.replace('acct:', '').split('@')
return user_data[1]
def get_nodeinfo(instance):
domain = urlparse(instance).netloc if instance.startswith('http') else instance
nodeinfo = fetch(f'https://{domain}/nodeinfo/2.0.json')
if not nodeinfo:
logging.debug('Wrong nodeinfo url. Finding correct one...')
wk_url = f'https://{domain}/.well-known/nodeinfo'
well_known = fetch(wk_url)
if not well_known:
logging.debug(f'failed to fetch {wk_url}')
return
ni_url = None
try:
for link in well_known['links']:
if link['rel'].startswith('http://nodeinfo.diaspora.software/ns/schema/2'):
ni_url = link['href']
except Exception as e:
logging.debug(f'Invalid nodeinfo data, Error: {e}')
return
nodeinfo = fetch(ni_url)
if not nodeinfo:
logging.debug(f'Failed to fetch nodeinfo for {domain}')
return nodeinfo['software'] if nodeinfo else None
def domain_check(domain):
stripped = domain.replace(' ', '')
return stripped if validators.domain(stripped) else None
@ -126,3 +160,50 @@ def css_ts():
layout = css_check('layout')
return color + layout
def fetch(url, cached=True):
if not url:
logging.debug(f'Fetch was not fed a url')
return
cached_data = cache.url.fetch(url)
if cached and cached_data:
logging.debug(f'Returning cached data for {url}')
return cached_data
headers = {
'User-Agent': f'PAWS/{VERSION}',
'Accept': 'application/json'
}
try:
logging.debug(f'Fetching new data for {url}')
response = httpclient.request('GET', url, headers=headers)
except Exception as e:
logging.debug(f'Failed to fetch {url}')
logging.debug(e)
return
if response.data == b'':
logging.debug(f'Received blank data while fetching url: {url}')
return
try:
data = json.loads(response.data)
if cached:
logging.debug(f'Caching {url}')
cache.url.store(url, data)
if data.get('error'):
return
return data
except Exception as e:
logging.debug(f'Failed to load data: {response.data}')
logging.debug(e)
return

View file

@ -16,9 +16,9 @@ from aiohttp.client_exceptions import *
from Crypto.PublicKey import RSA
from .signature import validate, pass_hash, sign_headers
from .functions import error, user_check, domain_check, parse_sig, parse_ua, dig, distill_query
from .functions import error, user_check, domain_check, parse_sig, parse_ua, dig, distill_query, get_nodeinfo, httpclient
from .config import MASTOCONFIG, PAWSCONFIG, VERSION, script_path
from .database import pawsdb, query, trans, ban_check, user_ban_check, banned_user_check, wl_check, keys, user_domain_ban_check
from .database import pawsdb, query, trans, ban_check, user_ban_check, banned_user_check, wl_check, keys, user_domain_ban_check, admin_check
paws_host = PAWSCONFIG['domain']
@ -51,25 +51,34 @@ auth_paths = [
async def passthrough(request, headers):
mastohost = PAWSCONFIG['mastohost']
data = await request.read() if request.body_exists else None
req_data = await request.read() if request.body_exists else None
is_json = request.get('jsonreq', False)
try:
async with aiohttp.request(request.method, f'http://{mastohost}{request.path}?{request.query_string}', headers=headers, data=data) as resp:
data = await resp.read()
resp = httpclient.request(request.method, f'http://{mastohost}{request.path}?{request.query_string}', body=req_data, headers=headers, redirect=False)
#async with aiohttp.request(request.method, f'http://{mastohost}{request.path}?{request.query_string}', headers=headers, data=data) as resp:
data = resp.data
if resp.status not in [200, 202]:
if data in [b'Request not signed', 'Request not signed']:
err_msg = 'Missing signature'
logging.debug(err_msg)
if resp.status not in [200, 202, 301]:
if data in [b'Request not signed', 'Request not signed']:
err_msg = 'Missing signature'
logging.debug(err_msg)
else:
err_msg = f'Recieved error {resp.status} from Mastodon'
logging.debug(err_msg)
else:
err_msg = f'Recieved error {resp.status} from Mastodon'
logging.debug(err_msg)
return error(request, resp.status, f'Failed to forward request')
return error(request, resp.status, f'Failed to forward request')
return aiohttp.web.HTTPOk(body=data, content_type=resp.content_type)
resp_headers = dict(resp.headers)
if resp_headers.get('Content-Encoding'):
del resp_headers['Content-Encoding']
if resp_headers.get('Transfer-Encoding'):
del resp_headers['Transfer-Encoding']
return aiohttp.web.Response(body=data, headers=resp_headers, status=resp.status)
except ClientConnectorError:
traceback.print_exc()
@ -89,6 +98,9 @@ async def http_filter(app, handler):
real_ip = request.headers.get('X-Real-Ip', request.remote)
ua_ip = dig(ua_domain)
nodeinfo = get_nodeinfo(domain)
software = nodeinfo['name'] if nodeinfo else None
request['jsonreq'] = True if 'json' in request.headers.get('Accept', '') or request.path.endswith('.json') else False
# Disable rss feeds
@ -97,6 +109,7 @@ async def http_filter(app, handler):
# add logged in user data to the request for the frontend
request['user'] = user_data
request['admin'] = admin_check(user_data['handle']) if user_data else None
if request.path in ['/paws/actor', '/paws/inbox', '/.well-known/webfinger'] and request.host != paws_host:
return aiohttp.web.HTTPFound('/paws')

View file

@ -90,7 +90,7 @@ def login(user, code):
except:
msg = 'Failed to fetch token'
logging.info(msg)
logging.error(msg)
return ('error', msg)
try:
@ -99,7 +99,7 @@ def login(user, code):
except:
msg = 'Failed to get user info'
logging.info(msg)
logging.error(msg)
return ('error', msg)
return (token, fetch_user)

View file

@ -8,7 +8,7 @@ from mastodon import Mastodon
from .config import PAWSCONFIG, MASTOCONFIG, VERSION, script_path
from .functions import css_ts
from .database import cleanup_users
from .database import cleanup_users, admin_check
from . import middleware, views, stor_path
@ -44,7 +44,8 @@ def webserver():
'domain': MASTOCONFIG['domain'],
'VERSION': VERSION,
'len': len,
'urlparse': urlparse
'urlparse': urlparse,
'admin_check': admin_check
})
return web

View file

@ -106,4 +106,12 @@ tr:not(.header):hover td a {
font-weight: bold;
}
/* Dropdown menus */
.menu {
background-color: var(--bg-color-dark);
box-shadow: var(--shadow);
}
{% include 'layout.css' %}

View file

@ -175,6 +175,39 @@ input[type=text]:focus {
}
/* Dropdown menu */
.menu {
position: fixed;
display: block;
right: 0;
padding: 5px;
text-align: center;
z-index: 10;
top: 0;
border-radius: 0 0 0 5px;
}
.menu a {
padding: 5px;
}
.menu .title {
font-weight: bold;
font-size: 14pt;
text-transform: uppercase;
}
.menu .item {
padding: 5px 0;
text-transform: uppercase;
font-size: 14pt;
}
.menu-right details[open] summary ~ * {
animation: sweep-left .5s ease-in-out;
}
/* mobile/small screen */
@media (max-width : 1000px) {
#content {

View file

@ -13,10 +13,34 @@
%meta{'name': 'viewport', 'content': 'width=device-width, initial-scale=1, shrink-to-fit=no'}
%body
%div{'class': 'menu menu-right'}
%details
%summary{'class': "title"}
%a
-if request.user:
{{request.user.handle}}
-else
Guest
.item
%a{'href': '/about', 'target': '_self'} Masto Home
.item
%a{'href': '/paws', 'target': '_self'} Paws Home
-if request.user
-if request.admin
.item
%a{'href': '/paws/whitelist', 'target': '_self'} Whitelist
.item
%a{'href': '/paws/instances', 'target': '_self'} Instances
.item
%a{'href': '/paws/logout', 'target': '_self'} Logout
-else
.item
%a{'href': '/paws/login', 'target': '_self'} Login
#content
#header
%h1{'id': 'name'}<
{{logo_svg}}
%h1{'id': 'name'}= logo_svg
#body
-block content

View file

@ -23,26 +23,20 @@ async def get_home(request):
async def get_paws(request):
token = request.cookies.get('paws_token')
user_data = pawsdb.users.get(query.token == token)
admin = admin_check(user_data['handle']) if user_data else None
if admin:
if request['admin']:
whitelist = [line['domain'] for line in pawsdb.whitelist.all()]
whitelist.sort()
else:
whitelist = None
data = {'admin': admin, 'whitelist': whitelist}
data = {'whitelist': whitelist}
return aiohttpTemplate('panel.html', data, request)
async def post_paws(request):
data = await request.post()
token = request.cookies.get('paws_token')
user_data = pawsdb.users.get(query.token == token)
user_data = request['user']
domain = data.get('name')
action = request.match_info['action']
@ -68,10 +62,9 @@ async def post_paws(request):
async def get_login(request):
parms = request.rel_url.query
redir = parms.get('redir')
token = request.cookies.get('paws_token')
numid = random.randint(1*1000000, 10*1000000-1)
if token and pawsdb.users.get(query.token == token):
if request['user']:
return aiohttp.web.HTTPFound('/paws')
data = {'redir': redir, 'numid': numid}
@ -104,7 +97,7 @@ async def post_login(request):
with trans(pawsdb.users) as tr:
tr.insert({
'handle': data['numid'],
'domain': data['domain'],
'domain': data['domain'].lower(),
'appid': appid,
'appsecret': appsecret,
'token': None,
@ -146,9 +139,9 @@ async def get_auth(request):
{
'handle': userinfo['username'],
'instance': instance,
'token': token,
'appid': None,
'appsecret': None
'appsecret': None,
'token': token
},
where('handle') == numid
)
@ -161,10 +154,13 @@ async def get_auth(request):
async def get_logout(request):
token = request.cookies.get('paws_token')
if not request['user']:
return aiohttp.web.HTTPFound('/paws/login')
token_id = request['user'].doc_id
with trans(pawsdb.users) as tr:
tr.remove(where('token') == token)
tr.remove(doc_ids=[token_id])
response = aiohttp.web.HTTPFound('/paws/login')
response.del_cookie('token')