This commit is contained in:
Izalia Mae 2020-03-07 15:42:15 -05:00
parent bd7ef6c31c
commit 34833acae5
13 changed files with 158 additions and 337 deletions

View file

@ -1 +1,10 @@
'''heck'''
from os.path import dirname, abspath
from IzzyLib import logging, template
logging.setConfig({'level': 'debug'})
logging.debug(f'Config: {logging.getConfig()}')
templates = abspath(dirname(__file__))+'/templates'
template.addSearchPath(templates)
template.setup()

View file

@ -1,111 +0,0 @@
import re
from datetime import datetime
from collections import OrderedDict
def parse_ttl(ttl):
m = re.match(r'^(\d+)([smhdw]?)$', ttl)
if not m:
logging.warning(f'Invalid TTL: {ttl}. Setting to default: 1h')
amount = 1
unit = 'h'
else:
amount = m.group(1)
unit = m.group(2)
units = {
's': 1,
'm': 60,
'h': 60 * 60,
'd': 24 * 60 * 60,
'w': 7 * 24 * 60 * 60,
}
if unit:
multiplier = units[unit]
else:
multiplier = 1
return multiplier * int(amount)
class TTLCache:
def __init__(self, ttl='1h', maxsize=1024):
self.items = OrderedDict()
self.ttl = parse_ttl(ttl)
self.maxsize = maxsize
def invalidate(self, key):
if key in self.items:
del self.items[key]
def store(self, key, value):
timestamp = int(datetime.timestamp(datetime.now()))
item = self.items.get(key)
while len(self.items) >= self.maxsize and self.maxsize != 0:
self.items.popitem(last=False)
if item == None:
data = {'data': value}
self.items[key] = data
elif self.items[key]['timestamp'] + self.ttl < timestamp:
del self.items[key]
self.items[key]['timestamp'] = timestamp + self.ttl
self.items.move_to_end(key)
def fetch(self, key):
item = self.items.get(key)
if item != None:
timestamp = int(datetime.timestamp(datetime.now()))
if timestamp >= self.items[key]['timestamp']:
del self.items[key]
else:
self.items[key]['timestamp'] = timestamp + self.ttl
self.items.move_to_end(key)
return self.items[key]['data']
class LRUCache:
def __init__(self, maxsize=1024):
self.items = OrderedDict()
self.maxsize = maxsize
def invalidate(self, key):
if key in self.items:
del self.items[key]
return True
return False
def store(self, key, value):
while len(self.items) >= self.maxsize and self.maxsize != 0:
self.items.popitem(last=False)
if (key in self.items) == False:
self.items[key] = value
self.items.move_to_end(key)
def fetch(self, key):
if key in self.items:
return self.items[key]
return

View file

@ -1,47 +1,27 @@
import sys
import os
import logging as logger
from os import environ as env
from os.path import isdir, isfile, abspath, dirname, basename
from envbash import load_envbash
from .functions import bool_check
from envbash import load_envbash
from IzzyLib import logging
from IzzyLib.misc import boolean
VERSION = '0.2.2'
full_path = abspath(sys.executable) if getattr(sys, 'frozen', False) else abspath(__file__)
script_path = getattr(sys, '_MEIPASS', dirname(abspath(__file__)))
script_name = basename(full_path)
stor_path = abspath(f'{script_path}/../data')
stor_path = abspath(f'{os.getcwd()}/data')
if not isdir(stor_path):
os.makedirs(stor_path, exist_ok=True)
if not bool_check(env.get('LOGDATE', 'yes').lower()):
log_date = ''
else:
log_date = '[%(asctime)s] '
logging = logger.getLogger()
logging.setLevel(logger.DEBUG)
log_format = f'{log_date}%(levelname)s: %(message)s'
logger.addLevelName(5, 'VERBOSE')
logger.addLevelName(30, 'WARN')
logger.addLevelName(50, 'CRIT')
console = logger.StreamHandler()
console.name = 'Console Log'
console.level = logger.INFO
console.formatter = logger.Formatter(log_format, '%Y-%m-%d %H:%M:%S')
logging.addHandler(console)
if not boolean(env.get('LOGDATE', 'yes').lower()):
logging.setConfig({'date': False})
if not isfile(f'{stor_path}/production.env'):
@ -54,6 +34,7 @@ if not isfile(f'{stor_path}/production.env'):
#PAWS_PORT=3001
#PAWS_DOMAIN=bappypaws.example.com
#PAWS_NORSS=true
#MASTOPATH=/home/mastodon/glitch-soc
#MASTOHOST=localhost:3000
@ -71,6 +52,7 @@ 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)),
'mastopath': env.get('MASTOPATH', os.getcwd()),
'mastohost': env.get('MASTOHOST', 'localhost:3000')
}
@ -86,7 +68,7 @@ else:
MASTOCONFIG={
'domain': env.get('WEB_DOMAIN', env.get('LOCAL_DOMAIN', 'localhost:3000')),
'auth_fetch': bool_check(env.get('AUTHORIZED_FETCH')),
'auth_fetch': boolean(env.get('AUTHORIZED_FETCH')),
'dbhost': env.get('DB_HOST', '/var/run/postgresql'),
'dbport': int(env.get('DB_PORT', 5432)),
'dbname': env.get('DB_NAME', 'mastodon_production'),

View file

@ -4,6 +4,8 @@ from datetime import datetime
from urllib.parse import urlparse
from json.decoder import JSONDecodeError
from IzzyLib import logging
from IzzyLib.cache import LRUCache
from DBUtils.PooledPg import PooledPg as DB
from tinydb import TinyDB, Query, where
from tinydb_smartcache import SmartCacheTable
@ -12,9 +14,8 @@ from tldextract import extract
from Crypto.PublicKey import RSA
from mastodon import Mastodon
from .config import stor_path, logging, MASTOCONFIG as mdb
from .config import stor_path, MASTOCONFIG as mdb
from .functions import bool_check
from .cache import LRUCache
def jsondb():
@ -217,20 +218,37 @@ def whitelist(action, instance):
return 'InvalidAction'
def cleanup_users():
timestamp = datetime.now()
def cleanup_users(invalid_check=None):
timestamp = datetime.timestamp(datetime.now())
invalid_offset = 60 * 15
with trans(pawsdb.users) as tr:
for user in pawsdb.users.all():
if not user.get('timestamp'):
tr.update({'timestamp': timestamp})
logging.debug(f'adding timestamp for user: {user["handle"]}@{user["domain"]}')
tr.update({'timestamp': timestamp}, doc_ids=[user.doc_id])
continue
if invalid_check:
if user['timestamp'] < timestamp - invalid_offset and not user['token']:
logging.debug(f'old and incomplete access token for {user["handle"]}@{user["domain"]}')
tr.remove(doc_ids=[user.doc_id])
continue
else:
client = Mastodon(api_base_url=user['domain'], access_token=user['token'])
print(client.me())
if not user['token']:
logging.debug(f'no access token for {user["handle"]}@{user["domain"]}')
continue
tr.remove((query.offset == None) & (Query.timestamp < timestamp - invalid_offset))
client = Mastodon(api_base_url=user['domain'], access_token=user['token'])
try:
client.me()
logging.debug(f'valid token for {user["handle"]}@{user["domain"]}')
except Mastodon.MastodonUnauthorizedError:
logging.debug(f'invalid token for {user["handle"]}@{user["domain"]}')
tr.remove(doc_ids=[user.doc_id])
pawsdb = jsondb()

View file

@ -1,28 +1,11 @@
import re
import json
import logging
import socket
import os
import aiohttp
import validators
import json, socket, os, re
import aiohttp, validators
from http import client as http
from urllib.parse import urlparse
from colour import Color
from aiohttp_jinja2 import render_template as render
error_codes = {
400: 'BadRequest',
404: 'NotFound',
401: 'Unauthorized',
403: 'Forbidden',
404: 'NotFound',
500: 'InternalServerError',
504: 'GatewayTimeout'
}
from IzzyLib import logging
from IzzyLib.template import aiohttpTemplate
def bool_check(value):
@ -36,25 +19,23 @@ def bool_check(value):
return value
def error(code, error):
def error(request, code, msg, isjson=False):
if isjson or request.get('jsonreq'):
return json_error(code, msg)
else:
return http_error(request, code, msg)
def json_error(code, error):
error_body = json.dumps({'error': error})
cont_type = 'application/json'
if code == 418:
return HTTPTeapot(body=error_body, content_type=cont_type)
elif code not in error_codes.keys():
logging.error(f'Hey! You specified a wrong error code: {code} {error}')
error_body = json.dumps({'error': 'DevError'})
return aiohttp.web.HTTPInternalServerError(body=error_body, content_type=cont_type)
return eval('aiohttp.web.HTTP'+error_codes[code]+'(body=error_body, content_type=cont_type)')
return aiohttp.web.Response(body=error_body, content_type=cont_type, status=code)
def http_error(request, code, msg):
return render('pages/error.html', request, {'msg': msg, 'code': str(code)}, status=code)
data = {'msg': msg, 'code': str(code)}
return aiohttpTemplate('pages/error.html', data, request, status=code)
def fed_domain(user, domain):
@ -156,55 +137,3 @@ def css_ts():
layout = css_check('layout')
return color + layout
class color:
def __init__(self):
self.check = lambda color: Color(f'#{str(color)}' if re.search(r'^(?:[0-9a-fA-F]{3}){1,2}$', color) else color)
def multi(self, multiplier):
if multiplier >= 1:
return 1
elif multiplier <= 0:
return 0
return multiplier
def lighten(self, color, multiplier):
col = self.check(color)
col.luminance += ((1 - col.luminance) * self.multi(multiplier))
return col.hex_l
def darken(self, color, multiplier):
col = self.check(color)
col.luminance -= (col.luminance * self.multi(multiplier))
return col.hex_l
def saturate(self, color, multiplier):
col = self.check(color)
col.saturation += ((1 - col.saturation) * self.multi(multiplier))
return col.hex_l
def desaturate(self, color, multiplier):
col = self.check(color)
col.saturation -= (col.saturation * self.multi(multiplier))
return col.hex_l
def rgba(self, color, transparency):
col = self.check(color)
red = col.red*255
green = col.green*255
blue = col.blue*255
trans = self.multi(transparency)
return f'rgba({red:0.2f}, {green:0.2f}, {blue:0.2f}, {trans:0.2f})'

View file

@ -1,6 +1,5 @@
import asyncio
import json
import logging
import binascii
import base64
import traceback
@ -10,7 +9,8 @@ import aiohttp
from urllib.parse import urlparse, quote_plus
from random import choice
from aiohttp_jinja2 import render_template as render
from IzzyLib import logging
from IzzyLib.misc import boolean
from aiohttp.http_exceptions import *
from aiohttp.client_exceptions import *
from Crypto.PublicKey import RSA
@ -55,10 +55,7 @@ async def passthrough(request, headers):
is_json = True if request.get('reqtype') == 'json' else False
try:
# I don't think I need this, but I'll leave it here just in case
#async with aiohttp.request(reqtype, f'https://{MASTOCONFIG["domain"]}/{path}{query}', headers=headers, data=post) as resp:
async with aiohttp.request(request.method, f'http://{mastohost}{request.path}?{distill_query(request.query)}', headers=headers, data=data) as resp:
async with aiohttp.request(request.method, f'http://{mastohost}{request.path}?{request.query_string}', headers=headers, data=data) as resp:
data = await resp.read()
if resp.status not in [200, 202]:
@ -89,71 +86,68 @@ async def http_filter(app, handler):
token = request.cookies.get('paws_token')
user_data = None if not token else pawsdb.users.get(query.token == token)
user = (user_data['handle'], user_data['instance']) if user_data and user_data.get('instance') else None
real_ip = request.headers.get('X-Real-Ip')
real_ip = request.headers.get('X-Real-Ip', request.remote)
ua_ip = dig(ua_domain)
request['jsonreq'] = True if 'json' in request.headers.get('Accept', '') or request.path.endswith('.json') else False
# Disable rss feeds
if request.path.endswith('.rss'):
return error(403, 'RSS feeds disabled')
if PAWSCONFIG['disable_rss'] and request.path.endswith('.rss'):
return error(request, 403, 'RSS feeds disabled')
# add logged in user data to the request for the frontend
request['user'] = user_data
if request.path in ['/paws/actor', '/paws/inbox', '/.well-known/webfinger'] and request.host != paws_host:
return aiohttp.web.HTTPFound(f'http://{masto_host}/paws')
return aiohttp.web.HTTPFound('/paws')
# try to find the domain for the request
if not domain:
raise error(401, 'Can\'t find instance domain')
return error(request, 401, 'Can\'t find instance domain')
# block nazis and general garbage
for agent in blocked_agents:
if agent in ua:
logging.info(f'Blocked garbage: {domain}')
raise HTTPTeapot(body='418 This teapot kills fascists', content_type='text/plain')
return error(request, 418, '418 This teapot kills fascists')
# block any suspended instances
if ban_check(domain):
logging.info(f'Blocked instance: {domain}')
raise error(403, 'Forbidden')
return error(request, 403, 'Forbidden')
# block any suspended users
if banned_user_check(user):
logging.info(f'Blocked user: {domain}')
return http_error(request, 403, 'Access Denied')
return error(request, 403, 'Access Denied')
if any(map(request.path.startswith, auth_paths)) and request.method == 'GET':
if 'json' in request.headers.get('Accept', '') or request.path.endswith('.json'):
request['reqtype'] = 'json'
# Check signatures if auth fetches are off
if not user_check(request.path) and not MASTOCONFIG['auth_fetch']:
if signature:
actor = parse_sig(signature)
# Check signatures if auth fetches are off
if not user_check(request.path) and not MASTOCONFIG['auth_fetch']:
if signature:
actor = parse_sig(signature)
if not (await validate(actor, request)):
logging.info(f'Signature validation failed for: {actor}')
return error(request, 401, 'signature check failed, signature did not match key')
if not (await validate(actor, request)):
logging.info(f'Signature validation failed for: {actor}')
raise error(401, 'signature check failed, signature did not match key')
elif real_ip == ua_ip and wl_check(domain):
logging.info(f'Letting {domain} through')
elif real_ip == ua_ip and wl_check(domain):
logging.info(f'Letting {domain} through')
else:
msg = 'missing signature'
logging.warning(msg)
raise error(401, msg)
else:
request['reqtype'] = 'html'
else:
msg = 'missing signature'
logging.warning(msg)
return error(request, 401, msg)
if not request['jsonreq']:
if not token or not user_data:
return aiohttp.web.HTTPFound(f'https://{masto_host}/paws/login?redir={quote_plus(request.path)}')
return aiohttp.web.HTTPFound(f'/paws/login?redir={quote_plus(request.path)}')
split_path = request.path.split('/')
user = split_path[1].replace('@', '') if request.path.startswith('/@') else split_path[2]
if user_ban_check(user.lower(), (user_data['handle'].lower(), user_data['instance'])) or user_domain_ban_check(user.lower(), user_data['instance']):
return http_error(request, 403, 'Access Denied')
return error(request, 403, 'Access Denied')
if signature and wl_check(domain):
logging.warning(f'{domain} has started signing requests and can be removed from the whitelist')
@ -213,4 +207,11 @@ async def http_server_header(request, response):
response.headers['msg'] = value
async def http_access_log(request, response):
uagent = request.headers.get('user-agent')
client_ip = request.headers.get('X-Real-IP', request.remote)
logging.info(f'{client_ip} {request.method} {request.path_qs} {response.status} "{uagent}"')
__all__ = ['http_signatures_middleware', 'http_auth_middleware', 'http_filter_middleware', 'http_trailing_slash', 'http_server_header']

View file

@ -1,12 +1,12 @@
import os
import json
import sys
import logging
import time
import traceback
import http.client as http
from IzzyLib import logging
from urllib.parse import urlencode, urlparse
from mastodon import Mastodon

View file

@ -1,24 +1,24 @@
import os, sys, asyncio, aiohttp, aiohttp_jinja2, jinja2
from IzzyLib import logging, template, color
from ipaddress import ip_address as address
from urllib.parse import urlparse
from mastodon import Mastodon
from .config import PAWSCONFIG, MASTOCONFIG, VERSION, script_path, logging
from .functions import color, css_ts
from .config import PAWSCONFIG, MASTOCONFIG, VERSION, script_path
from .functions import css_ts
from .database import cleanup_users
from . import middleware
from . import middleware, views
def webserver():
from . import views
web = aiohttp.web.Application(middlewares=[
middleware.http_filter,
middleware.http_trailing_slash
])
web.on_response_prepare.append(middleware.http_server_header)
web.on_response_prepare.append(middleware.http_access_log)
web.add_routes([
aiohttp.web.route('GET', '/paws', views.get_paws),
@ -38,30 +38,13 @@ def webserver():
#aiohttp.web.route('GET', '/paws/imgay', views.get_gay)
])
async def global_vars(request):
return {
'VERSION': VERSION,
'len': len,
'css_ts': css_ts,
'request': request,
'domain': MASTOCONFIG['domain'],
'urlparse': urlparse,
'lighten': color().lighten,
'darken': color().darken,
'saturate': color().saturate,
'desaturate': color().desaturate,
'rgba': color().rgba
}
aiohttp_jinja2.setup(
web,
loader=jinja2.FileSystemLoader(f'{script_path}/templates'),
autoescape=jinja2.select_autoescape(['html', 'css']),
context_processors=[global_vars],
lstrip_blocks=True,
trim_blocks=True
)
template.addEnv({
'css_ts': css_ts,
'domain': MASTOCONFIG['domain'],
'VERSION': VERSION,
'len': len,
'urlparse': urlparse
})
return web
@ -108,19 +91,24 @@ async def start_webserver():
async def cleanup_tokens():
while True
logging.info('Cleaning up tokens')
while True:
await asyncio.sleep(60*60)
logging.debug('Cleaning up tokens')
cleanup_users()
await asyncio.sleep(60*60)
async def cleanup_invalid_users():
while True:
logging.debug('Cleaning up invalid users')
cleanup_users(True)
await asyncio.sleep(60*15)
def main():
try:
loop = asyncio.get_event_loop()
asyncio.ensure_future(start_webserver())
asyncio.ensure_future(cleanup_tokens())
asyncio.ensure_future(cleanup_invalid_users())
loop.run_forever()
except KeyboardInterrupt:

View file

@ -3,8 +3,9 @@ import aiohttp.web
import binascii
import base64
import json
import logging
from IzzyLib import logging
from IzzyLib.cache import LRUCache, TTLCache
from aiohttp.http_exceptions import *
from Crypto.PublicKey import RSA
from Crypto.Hash import SHA, SHA256, SHA512
@ -15,7 +16,6 @@ from .database import keys
class cache:
from .cache import LRUCache, TTLCache
messages = LRUCache()
actors = TTLCache()
keys = LRUCache()

View file

@ -16,7 +16,7 @@
<tr class="instance">
<td class="col1"><a href="https://{{instance}}/about" target="_new">{{instance}}</a></td>
<td class="col2">
<form action="https://{{domain}}/paws/action/remove" method="post">
<form action="/paws/action/remove" method="post">
<input name="name" value="{{instance}}" hidden>
<input type="submit" value="X">
</form>
@ -27,7 +27,7 @@
<tr><td>none</td><td></td></tr>
{% endif %}
<tr class="instance">
<form action="https://{{domain}}/paws/action/add" method="post">
<form action="/paws/action/add" method="post">
<td class="col1"><input type="text" name="name" placeholder="bofa.lol"></td>
<td class="col2">
<input type="submit" value="Add">

View file

@ -1,17 +1,17 @@
import aiohttp
import random
import traceback
import logging
from aiohttp_jinja2 import render_template as render
from IzzyLib import logging
from IzzyLib.cache import TTLCache
from IzzyLib.template import aiohttpTemplate
from urllib.parse import quote_plus, unquote_plus, urlparse
from datetime import datetime
from .database import pawsdb, trans, query, where, keys, ban_check, get_user, get_toot, admin_check, whitelist
from .functions import error, http_error, fed_domain, domain_check, css_ts
from .functions import error, fed_domain, domain_check, css_ts
from .oauth import create_app, login
from .config import MASTOCONFIG, PAWSCONFIG
from .cache import TTLCache
paws_host = PAWSCONFIG['domain']
@ -19,9 +19,7 @@ masto_host = MASTOCONFIG['domain']
async def get_home(request):
'heck2'
return render('pages/home.html', request, {})
return aiohttpTemplate('pages/home.html', {}, request)
async def get_paws(request):
@ -37,7 +35,8 @@ async def get_paws(request):
else:
whitelist = None
return render('pages/panel.html', request, {'admin': admin, 'whitelist': whitelist})
data = {'admin': admin, 'whitelist': whitelist}
return aiohttpTemplate('pages/panel.html', data, request)
async def post_paws(request):
@ -50,20 +49,20 @@ async def post_paws(request):
admin = admin_check(user_data['handle']) if user_data else None
if not admin:
return http_error(request, 403, 'Not an admin')
return error(request, 403, 'Not an admin')
if None in [action, domain]:
return http_error(request, 400, 'Missing action or doamin')
return error(request, 400, 'Missing action or doamin')
domain = urlparse(domain.replace(' ', ''))
parsed_domain = domain.netloc if domain.netloc != '' else domain.path
if action not in ['add', 'remove']:
http_error(request, 400, 'Invalid action')
error(request, 400, 'Invalid action')
whitelist(action, parsed_domain)
return aiohttp.web.HTTPFound(f'https://{masto_host}/paws')
return aiohttp.web.HTTPFound('/paws')
async def get_login(request):
@ -75,9 +74,10 @@ async def get_login(request):
logging.warning(token)
if token and pawsdb.users.get(query.token == token):
return aiohttp.web.HTTPFound(f'https://{masto_host}/paws')
return aiohttp.web.HTTPFound('/paws')
return render('pages/login.html', request, {'redir': redir, 'numid': numid})
data = {'redir': redir, 'numid': numid}
return aiohttpTemplate('pages/login.html', data, request)
async def post_login(request):
@ -92,15 +92,15 @@ async def post_login(request):
domain = domain_check(domain)
if not domain:
return http_error(request, 200, 'Invalid domain')
return error(request, 400, 'Invalid domain')
if ban_check(domain):
return http_error(request, 403, 'Instance banned')
return error(request, 403, 'Instance banned')
appid, appsecret, redir_url = create_app(domain)
if appid == 'error':
return http_error(request, 500, appsecret)
return error(request, 500, appsecret)
with trans(pawsdb.users) as tr:
@ -109,7 +109,8 @@ async def post_login(request):
'domain': data['domain'],
'appid': appid,
'appsecret': appsecret,
'token': None
'token': None,
'timestamp': datetime.timestamp(datetime.now())
})
response = aiohttp.web.HTTPFound(redir_url)
@ -129,7 +130,7 @@ async def get_auth(request):
code = parms.get('code')
if None in [numid, code]:
return http_error(request, 500, 'Missing temporary userid or auth code')
return error(request, 500, 'Missing temporary userid or auth code')
logging.warning(redir)
@ -140,12 +141,21 @@ async def get_auth(request):
token, userinfo = login(user, code)
if token == 'error':
return http_error(request, 500, userinfo)
return error(request, 500, userinfo)
instance = fed_domain(userinfo['username'], user['domain'])
with trans(pawsdb.users) as tr:
tr.update({'handle': userinfo['username'], 'instance': instance, 'token': token, 'appid': None, 'appsecret': None}, where('handle') == numid)
tr.update(
{
'handle': userinfo['username'],
'instance': instance,
'token': token,
'appid': None,
'appsecret': None
},
where('handle') == numid
)
response = aiohttp.web.HTTPFound(redir)
response.set_cookie('paws_token', token, max_age=60*60*24*14)
@ -169,9 +179,7 @@ async def get_logout(request):
async def get_style(request):
maxage = 60*60*24*7
response = render('color.css', request, {})
response.headers['Content-type'] = 'text/css'
#response.headers['Last-Modified'] = datetime.utcfromtimestamp(css_ts()).strftime('%a, %d %b %Y %H:%M:%S GMT')
response = aiohttpTemplate('color.css', {}, request, content_type='text/css')
response.headers['Cache-Control'] = 'public,max-age={maxage},immutable'
return response
@ -210,7 +218,7 @@ async def get_actor(request):
rsakey = keys('default')
if not rsakey:
return http_error(request, 500, 'Missing actor keys')
return error(request, 500, 'Missing actor keys')
PUBKEY = rsakey['pubkey']

View file

@ -1,4 +1,4 @@
exec = ./server.py
exec = python3 -m paws
watch_ext = py, env
ignore_dirs = build, data
ignore_files = reload.py, test.py, heck.py

View file

@ -1,4 +1,4 @@
# todo: module version numbers
git+https://git.barkshark.xyz/izaliamae/izzylib.git@0.1
dbutils
pygresql
tinydb
@ -10,7 +10,4 @@ tldextract
envbash
ipaddress
mastodon.py
aiohttp_jinja2
colour
validators>=0.14.1