Compare commits

...

3 commits

Author SHA1 Message Date
Izalia Mae 6f03e2ad4c fix linter issues 2024-02-23 20:04:31 -05:00
Izalia Mae cd43aae535 add dev commands 2024-02-23 19:58:35 -05:00
Izalia Mae a08d1c9612 use jinja for web pages 2024-02-23 19:19:44 -05:00
18 changed files with 532 additions and 79 deletions

View file

@ -1,2 +1,5 @@
include frontend/base.haml
include frontend/style.css
include data/statements.sql include data/statements.sql
include data/swagger.yaml include data/swagger.yaml
include frontend/page/home.haml

View file

@ -1,3 +1,4 @@
flake8 == 7.0.0 flake8 == 7.0.0
pyinstaller == 6.3.0 pyinstaller == 6.3.0
pylint == 3.0 pylint == 3.0
watchdog == 4.0.0

View file

@ -13,6 +13,7 @@ a = Analysis(
binaries=[], binaries=[],
datas=[ datas=[
('relay/data', 'relay/data'), ('relay/data', 'relay/data'),
('relay/frontend', 'relay/frontend'),
(aiohttp_swagger_path, 'aiohttp_swagger') (aiohttp_swagger_path, 'aiohttp_swagger')
], ],
hiddenimports=[ hiddenimports=[

View file

@ -2,10 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import multiprocessing import multiprocessing
import os
import signal import signal
import subprocess
import sys
import time import time
import traceback import traceback
import typing import typing
@ -23,6 +20,7 @@ from .config import Config
from .database import get_database from .database import get_database
from .http_client import HttpClient from .http_client import HttpClient
from .misc import check_open_port, get_resource from .misc import check_open_port, get_resource
from .template import Template
from .views import VIEWS from .views import VIEWS
from .views.api import handle_api_path from .views.api import handle_api_path
@ -56,12 +54,13 @@ class Application(web.Application):
self['client'] = HttpClient() self['client'] = HttpClient()
self['cache'] = get_cache(self) self['cache'] = get_cache(self)
self['cache'].setup() self['cache'].setup()
self['template'] = Template(self)
self['push_queue'] = multiprocessing.Queue() self['push_queue'] = multiprocessing.Queue()
self['workers'] = [] self['workers'] = []
self.cache.setup() self.cache.setup()
self.on_response_prepare.append(handle_access_log) # self.on_response_prepare.append(handle_access_log)
self.on_cleanup.append(handle_cleanup) self.on_cleanup.append(handle_cleanup)
for path, view in VIEWS: for path, view in VIEWS:
@ -125,10 +124,15 @@ class Application(web.Application):
if self["running"]: if self["running"]:
return return
if not check_open_port(self.config.listen, self.config.port): domain = self.config.domain
return logging.error(f'A server is already running on port {self.config.port}') host = self.config.listen
port = self.config.port
logging.info(f'Starting webserver at {self.config.domain} ({self.config.listen}:{self.config.port})') if not check_open_port(host, port):
logging.error(f'A server is already running on {host}:{port}')
return
logging.info(f'Starting webserver at {domain} ({host}:{port})')
asyncio.run(self.handle_run()) asyncio.run(self.handle_run())
@ -156,7 +160,7 @@ class Application(web.Application):
self['cleanup_thread'] = CacheCleanupThread(self) self['cleanup_thread'] = CacheCleanupThread(self)
self['cleanup_thread'].start() self['cleanup_thread'].start()
for i in range(self.config.workers): for _ in range(self.config.workers):
worker = PushWorker(self['push_queue']) worker = PushWorker(self['push_queue'])
worker.start() worker.start()
@ -179,7 +183,7 @@ class Application(web.Application):
await site.stop() await site.stop()
for worker in self['workers']: for worker in self['workers']: # pylint: disable=not-an-iterable
worker.stop() worker.stop()
self.set_signal_handler(False) self.set_signal_handler(False)

View file

@ -163,7 +163,30 @@ class Config:
def save(self) -> None: def save(self) -> None:
self.path.parent.mkdir(exist_ok = True, parents = True) self.path.parent.mkdir(exist_ok = True, parents = True)
config = { with self.path.open('w', encoding = 'utf-8') as fd:
yaml.dump(self.to_dict(), fd, sort_keys = False)
def set(self, key: str, value: Any) -> None:
if key not in DEFAULTS:
raise KeyError(key)
if key in {'port', 'pg_port', 'workers'} and not isinstance(value, int):
if (value := int(value)) < 1:
if key == 'port':
value = 8080
elif key == 'pg_port':
value = 5432
elif key == 'workers':
value = len(os.sched_getaffinity(0))
setattr(self, key, value)
def to_dict(self) -> dict[str, Any]:
return {
'listen': self.listen, 'listen': self.listen,
'port': self.port, 'port': self.port,
'domain': self.domain, 'domain': self.domain,
@ -187,24 +210,3 @@ class Config:
'refix': self.rd_prefix 'refix': self.rd_prefix
} }
} }
with self.path.open('w', encoding = 'utf-8') as fd:
yaml.dump(config, fd, sort_keys = False)
def set(self, key: str, value: Any) -> None:
if key not in DEFAULTS:
raise KeyError(key)
if key in {'port', 'pg_port', 'workers'} and not isinstance(value, int):
if (value := int(value)) < 1:
if key == 'port':
value = 8080
elif key == 'pg_port':
value = 5432
elif key == 'workers':
value = len(os.sched_getaffinity(0))
setattr(self, key, value)

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import typing import typing
from .. import logger as logging from .. import logger as logging
@ -10,12 +11,52 @@ if typing.TYPE_CHECKING:
from typing import Any from typing import Any
THEMES = {
'default': {
'text': '#DDD',
'background': '#222',
'primary': '#D85',
'primary-hover': '#DA8',
'section-background': '#333',
'table-background': '#444',
'border': '#444',
'message-text': '#DDD',
'message-background': '#335',
'message-border': '#446'
},
'pink': {
'text': '#DDD',
'background': '#222',
'primary': '#D69',
'primary-hover': '#D36',
'section-background': '#333',
'table-background': '#444',
'border': '#444',
'message-text': '#DDD',
'message-background': '#335',
'message-border': '#446'
},
'blue': {
'text': '#DDD',
'background': '#222',
'primary': '#69D',
'primary-hover': '#36D',
'section-background': '#333',
'table-background': '#444',
'border': '#444',
'message-text': '#DDD',
'message-background': '#335',
'message-border': '#446'
}
}
CONFIG_DEFAULTS: dict[str, tuple[str, Any]] = { CONFIG_DEFAULTS: dict[str, tuple[str, Any]] = {
'schema-version': ('int', 20240206), 'schema-version': ('int', 20240206),
'log-level': ('loglevel', logging.LogLevel.INFO), 'log-level': ('loglevel', logging.LogLevel.INFO),
'name': ('str', 'ActivityRelay'), 'name': ('str', 'ActivityRelay'),
'note': ('str', 'Make a note about your instance here.'), 'note': ('str', 'Make a note about your instance here.'),
'private-key': ('str', None), 'private-key': ('str', None),
'theme': ('str', 'default'),
'whitelist-enabled': ('bool', False) 'whitelist-enabled': ('bool', False)
} }
@ -24,6 +65,7 @@ CONFIG_CONVERT: dict[str, tuple[Callable, Callable]] = {
'str': (str, str), 'str': (str, str),
'int': (str, int), 'int': (str, int),
'bool': (str, boolean), 'bool': (str, boolean),
'json': (json.dumps, json.loads),
'loglevel': (lambda x: x.name, logging.LogLevel.parse) 'loglevel': (lambda x: x.name, logging.LogLevel.parse)
} }

View file

@ -8,10 +8,17 @@ from datetime import datetime, timezone
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import uuid4 from uuid import uuid4
from .config import CONFIG_DEFAULTS, get_default_type, get_default_value, serialize, deserialize from .config import (
CONFIG_DEFAULTS,
THEMES,
get_default_type,
get_default_value,
serialize,
deserialize
)
from .. import logger as logging from .. import logger as logging
from ..misc import get_app from ..misc import boolean, get_app
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
@ -95,6 +102,13 @@ class Connection(SqlConnection):
value = logging.LogLevel.parse(value) value = logging.LogLevel.parse(value)
logging.set_level(value) logging.set_level(value)
elif key == 'whitelist-enabled':
value = boolean(value)
elif key == 'theme':
if value not in THEMES:
raise ValueError(f'"{value}" is not a valid theme')
params = { params = {
'key': key, 'key': key,
'value': serialize(key, value) if value is not None else None, 'value': serialize(key, value) if value is not None else None,

153
relay/dev.py Normal file
View file

@ -0,0 +1,153 @@
import click
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
try:
from watchdog.observers import Observer
from watchdog.events import PatternMatchingEventHandler
except ImportError:
class PatternMatchingEventHandler:
pass
SCRIPT = Path(__file__).parent
REPO = SCRIPT.parent
IGNORE_EXT = {
'.py',
'.pyc'
}
@click.group('cli')
def cli():
'Useful commands for development'
@cli.command('install')
def cli_install():
cmd = [
sys.executable, '-m', 'pip', 'install',
'-r', 'requirements.txt',
'-r', 'dev-requirements.txt'
]
subprocess.run(cmd, check = False)
@cli.command('lint')
@click.argument('path', required = False, default = 'relay')
def cli_lint(path):
subprocess.run([sys.executable, '-m', 'flake8', path], check = False)
subprocess.run([sys.executable, '-m', 'pylint', path], check = False)
@cli.command('manifest-gen')
def cli_manifest_install():
paths = []
for path in SCRIPT.rglob('*'):
if path.suffix.lower() in IGNORE_EXT or not path.is_file():
continue
paths.append(path)
with REPO.joinpath('MANIFEST.in').open('w', encoding = 'utf-8') as fd:
for path in paths:
fd.write(f'include {str(path.relative_to(SCRIPT))}\n')
@cli.command('build')
def cli_build():
cmd = [sys.executable, '-m', 'PyInstaller', 'relay.spec']
subprocess.run(cmd, check = False)
@cli.command('run')
def cli_run():
print('Starting process watcher')
handler = WatchHandler()
handler.run_proc()
watcher = Observer()
watcher.schedule(handler, str(SCRIPT), recursive=True)
watcher.start()
try:
while True:
handler.proc.stdin.write(sys.stdin.read().encode('UTF-8'))
handler.proc.stdin.flush()
except KeyboardInterrupt:
pass
handler.kill_proc()
watcher.stop()
watcher.join()
class WatchHandler(PatternMatchingEventHandler):
patterns = ['*.py']
cmd = [sys.executable, '-m', 'relay', 'run']
def __init__(self):
PatternMatchingEventHandler.__init__(self)
self.proc = None
self.last_restart = None
def kill_proc(self):
if self.proc.poll() is not None:
return
print(f'Terminating process {self.proc.pid}')
self.proc.terminate()
sec = 0.0
while self.proc.poll() is None:
time.sleep(0.1)
sec += 0.1
if sec >= 5:
print('Failed to terminate. Killing process...')
self.proc.kill()
break
print('Process terminated')
def run_proc(self, restart=False):
timestamp = datetime.timestamp(datetime.now())
self.last_restart = timestamp if not self.last_restart else 0
if restart and self.proc.pid != '':
if timestamp - 3 < self.last_restart:
return
self.kill_proc()
# pylint: disable=consider-using-with
self.proc = subprocess.Popen(self.cmd, stdin = subprocess.PIPE)
self.last_restart = timestamp
print(f'Started process with PID {self.proc.pid}')
def on_any_event(self, event):
if event.event_type not in ['modified', 'created', 'deleted']:
return
print(event.src_path)
self.run_proc(restart = True)
if __name__ == '__main__':
cli()

16
relay/frontend/base.haml Normal file
View file

@ -0,0 +1,16 @@
!!!
%html
%head
%title << {{config.name}}: {{page}}
%meta(charset="UTF-8")
%meta(name="viewport" content="width=device-width, initial-scale=1")
%link(rel="stylesheet" type="text/css" href="/style.css")
-block head
%body
#container
#header.section
%a(href="https://{{domain}}/") -> =config.name
#content
-block content

View file

@ -0,0 +1,34 @@
-extends "base.haml"
-set page = "Home"
-block content
.section
=config.note
.section
%p
This is an Activity Relay for fediverse instances.
%p
You may subscribe to this relay with the address:
%a(href="https://{{domain}}/actor") << https://{{domain}}/actor</a>
%p
To host your own relay, you may download the code at
%a(href="https://git.pleroma.social/pleroma/relay") << git.pleroma.social/pleroma/relay
-if config["whitelist-enabled"]
%p.section.message
Note: The whitelist is enabled on this instance. Ask the admin to add your instance
before joining.
#instances.section
%table
%thead
%tr
%td.instance << Instance
%td.date << Joined
%tbody
-for instance in instances
%tr
%td.instance -> %a(href="https://{{instance.domain}}/" target="_new") -> =instance.domain
%td.date -> =instance.created.strftime("%Y-%m-%d")

151
relay/frontend/style.css Normal file
View file

@ -0,0 +1,151 @@
:root {
--text: {{theme["text"]}};
--background: {{theme["background"]}};
--primary: {{theme["primary"]}};
--primary-hover: {{theme["primary-hover"]}};
--section-background: {{theme["section-background"]}};
--table-background: {{theme["table-background"]}};
--border: {{theme["border"]}};
--message-text: {{theme["message-text"]}};
--message-background: {{theme["message-background"]}};
--message-border: {{theme["message-border"]}};
--spacing: 10px;
}
body {
color: var(--text);
background-color: #222;
margin: var(--spacing);
font-family: sans serif;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
color: var(--primary-hover);
text-decoration: underline;
}
p {
line-height: 1em;
margin: 0px;
}
p:not(:first-child) {
margin-top: 1em;
}
p:not(:last-child) {
margin-bottom: 1em;
}
table {
border-spacing: 0px;
/* border-radius: 10px; */
border-collapse: collapse;
}
td {
border: 1px solid var(--primary);
}
/*thead td:first-child {
border-top-left-radius: 10px;
}
thead td:last-child {
border-top-right-radius: 10px;
}
tbody tr:last-child td:first-child {
border-bottom-left-radius: 10px;
}
tbody tr:last-child td:last-child {
border-bottom-right-radius: 10px;
}*/
table td {
padding: 5px;
}
table thead td {
background-color: var(--primary);
color: var(--table-background)
}
table tbody td {
background-color: var(--table-background);
}
#container {
width: 1024px;
margin: 0px auto;
}
#header {
text-align: center;
}
#header a {
font-size: 3em;
}
#instances table {
width: 100%;
}
#instances .instance {
width: 100%;
}
#instances .date {
width: max-content;
text-align: right;
}
#instances thead td {
text-align: center !important;
}
.message {
color: var(--message-text) !important;
background-color: var(--message-background) !important;
border: 1px solid var(--message-border) !important;
}
.section {
background-color: var(--section-background);
padding: var(--spacing);
border: 1px solid var(--border);
border-radius: 10px;
}
.section:not(:first-child) {
margin-top: var(--spacing);
}
.section:not(:last-child) {
margin-bottom: var(--spacing);
}
@media (max-width: 1026px) {
body {
margin: 0px;
}
#container {
width: unset;
margin: unset;
}
.section {
border-width: 0px;
border-radius: 0px;
}
}

View file

@ -5,8 +5,6 @@ import asyncio
import click import click
import os import os
import platform import platform
import subprocess
import sys
import typing import typing
from aputils.signer import Signer from aputils.signer import Signer

View file

@ -25,6 +25,7 @@ if typing.TYPE_CHECKING:
IS_DOCKER = bool(os.environ.get('DOCKER_RUNNING')) IS_DOCKER = bool(os.environ.get('DOCKER_RUNNING'))
MIMETYPES = { MIMETYPES = {
'activity': 'application/activity+json', 'activity': 'application/activity+json',
'css': 'text/css',
'html': 'text/html', 'html': 'text/html',
'json': 'application/json', 'json': 'application/json',
'text': 'text/plain' 'text': 'text/plain'
@ -87,11 +88,11 @@ def get_resource(path: str) -> Path:
class JsonEncoder(json.JSONEncoder): class JsonEncoder(json.JSONEncoder):
def default(self, obj: Any) -> str: def default(self, o: Any) -> str:
if isinstance(obj, datetime): if isinstance(o, datetime):
return obj.isoformat() return o.isoformat()
return JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, o)
class Message(ApMessage): class Message(ApMessage):

49
relay/template.py Normal file
View file

@ -0,0 +1,49 @@
from __future__ import annotations
import typing
from hamlish_jinja.extension import HamlishExtension
from jinja2 import Environment, FileSystemLoader
from .database.config import THEMES
from .misc import get_resource
if typing.TYPE_CHECKING:
from typing import Any
from .application import Application
from .views.base import View
class Template(Environment):
def __init__(self, app: Application):
Environment.__init__(self,
autoescape = True,
trim_blocks = True,
lstrip_blocks = True,
extensions = [
HamlishExtension
],
loader = FileSystemLoader([
get_resource('frontend'),
app.config.path.parent.joinpath('template')
])
)
self.app = app
self.hamlish_enable_div_shortcut = True
self.hamlish_mode = 'indented'
def render(self, path: str, view: View | None = None, **context: Any) -> str:
with self.app.database.session(False) as s:
config = s.get_config_all()
new_context = {
'view': view,
'domain': self.app.config.domain,
'config': config,
'theme': THEMES.get(config['theme'], THEMES['default']),
**(context or {})
}
return self.get_template(path).render(new_context)

View file

@ -4,7 +4,6 @@ import typing
from aiohttp import web from aiohttp import web
from argon2.exceptions import VerifyMismatchError from argon2.exceptions import VerifyMismatchError
from datetime import datetime, timezone
from urllib.parse import urlparse from urllib.parse import urlparse
from .base import View, register_route from .base import View, register_route

View file

@ -17,6 +17,7 @@ if typing.TYPE_CHECKING:
from ..cache import Cache from ..cache import Cache
from ..config import Config from ..config import Config
from ..http_client import HttpClient from ..http_client import HttpClient
from ..template import Template
VIEWS = [] VIEWS = []
@ -91,6 +92,11 @@ class View(AbstractView):
return self.app.database return self.app.database
@property
def template(self) -> Template:
return self.app['template']
async def get_api_data(self, async def get_api_data(self,
required: list[str], required: list[str],
optional: list[str]) -> dict[str, str] | Response: optional: list[str]) -> dict[str, str] | Response:

View file

@ -10,49 +10,27 @@ if typing.TYPE_CHECKING:
from aiohttp.web import Request from aiohttp.web import Request
HOME_TEMPLATE = """
<html><head>
<title>ActivityPub Relay at {host}</title>
<style>
p {{ color: #FFFFFF; font-family: monospace, arial; font-size: 100%; }}
body {{ background-color: #000000; }}
a {{ color: #26F; }}
a:visited {{ color: #46C; }}
a:hover {{ color: #8AF; }}
</style>
</head>
<body>
<p>This is an Activity Relay for fediverse instances.</p>
<p>{note}</p>
<p>
You may subscribe to this relay with the address:
<a href="https://{host}/actor">https://{host}/actor</a>
</p>
<p>
To host your own relay, you may download the code at this address:
<a href="https://git.pleroma.social/pleroma/relay">
https://git.pleroma.social/pleroma/relay
</a>
</p>
<br><p>List of {count} registered instances:<br>{targets}</p>
</body></html>
"""
# pylint: disable=unused-argument # pylint: disable=unused-argument
@register_route('/') @register_route('/')
class HomeView(View): class HomeView(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
config = conn.get_config_all() instances = tuple(conn.execute('SELECT * FROM inboxes').all())
inboxes = tuple(conn.execute('SELECT * FROM inboxes').all())
text = HOME_TEMPLATE.format( # text = HOME_TEMPLATE.format(
host = self.config.domain, # host = self.config.domain,
note = config['note'], # note = config['note'],
count = len(inboxes), # count = len(inboxes),
targets = '<br>'.join(inbox['domain'] for inbox in inboxes) # targets = '<br>'.join(inbox['domain'] for inbox in inboxes)
) # )
return Response.new(text, ctype='html') data = self.template.render('page/home.haml', instances = instances)
return Response.new(data, ctype='html')
@register_route('/style.css')
class StyleCss(View):
async def get(self, request: Request) -> Response:
data = self.template.render('style.css')
return Response.new(data, ctype = 'css')

View file

@ -2,10 +2,11 @@ aiohttp>=3.9.1
aiohttp-swagger[performance]==1.0.16 aiohttp-swagger[performance]==1.0.16
aputils@https://git.barkshark.xyz/barkshark/aputils/archive/0.1.6a.tar.gz aputils@https://git.barkshark.xyz/barkshark/aputils/archive/0.1.6a.tar.gz
argon2-cffi==23.1.0 argon2-cffi==23.1.0
barkshark-sql@https://git.barkshark.xyz/barkshark/bsql/archive/0.1.1.tar.gz
click>=8.1.2 click>=8.1.2
hamlish-jinja@https://git.barkshark.xyz/barkshark/hamlish-jinja/archive/0.3.5.tar.gz
hiredis==2.3.2 hiredis==2.3.2
pyyaml>=6.0 pyyaml>=6.0
redis==5.0.1 redis==5.0.1
barkshark-sql@https://git.barkshark.xyz/barkshark/bsql/archive/0.1.1.tar.gz
importlib_resources==6.1.1;python_version<'3.9' importlib_resources==6.1.1;python_version<'3.9'