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/swagger.yaml
include frontend/page/home.haml

View file

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

View file

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

View file

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

View file

@ -163,7 +163,30 @@ class Config:
def save(self) -> None:
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,
'port': self.port,
'domain': self.domain,
@ -187,24 +210,3 @@ class Config:
'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
import json
import typing
from .. import logger as logging
@ -10,12 +11,52 @@ if typing.TYPE_CHECKING:
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]] = {
'schema-version': ('int', 20240206),
'log-level': ('loglevel', logging.LogLevel.INFO),
'name': ('str', 'ActivityRelay'),
'note': ('str', 'Make a note about your instance here.'),
'private-key': ('str', None),
'theme': ('str', 'default'),
'whitelist-enabled': ('bool', False)
}
@ -24,6 +65,7 @@ CONFIG_CONVERT: dict[str, tuple[Callable, Callable]] = {
'str': (str, str),
'int': (str, int),
'bool': (str, boolean),
'json': (json.dumps, json.loads),
'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 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 ..misc import get_app
from ..misc import boolean, get_app
if typing.TYPE_CHECKING:
from collections.abc import Iterator
@ -95,6 +102,13 @@ class Connection(SqlConnection):
value = logging.LogLevel.parse(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 = {
'key': key,
'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 os
import platform
import subprocess
import sys
import typing
from aputils.signer import Signer

View file

@ -25,6 +25,7 @@ if typing.TYPE_CHECKING:
IS_DOCKER = bool(os.environ.get('DOCKER_RUNNING'))
MIMETYPES = {
'activity': 'application/activity+json',
'css': 'text/css',
'html': 'text/html',
'json': 'application/json',
'text': 'text/plain'
@ -87,11 +88,11 @@ def get_resource(path: str) -> Path:
class JsonEncoder(json.JSONEncoder):
def default(self, obj: Any) -> str:
if isinstance(obj, datetime):
return obj.isoformat()
def default(self, o: Any) -> str:
if isinstance(o, datetime):
return o.isoformat()
return JSONEncoder.default(self, obj)
return json.JSONEncoder.default(self, o)
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 argon2.exceptions import VerifyMismatchError
from datetime import datetime, timezone
from urllib.parse import urlparse
from .base import View, register_route

View file

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

View file

@ -10,49 +10,27 @@ if typing.TYPE_CHECKING:
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
@register_route('/')
class HomeView(View):
async def get(self, request: Request) -> Response:
with self.database.session() as conn:
config = conn.get_config_all()
inboxes = tuple(conn.execute('SELECT * FROM inboxes').all())
instances = tuple(conn.execute('SELECT * FROM inboxes').all())
text = HOME_TEMPLATE.format(
host = self.config.domain,
note = config['note'],
count = len(inboxes),
targets = '<br>'.join(inbox['domain'] for inbox in inboxes)
)
# text = HOME_TEMPLATE.format(
# host = self.config.domain,
# note = config['note'],
# count = len(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
aputils@https://git.barkshark.xyz/barkshark/aputils/archive/0.1.6a.tar.gz
argon2-cffi==23.1.0
barkshark-sql@https://git.barkshark.xyz/barkshark/bsql/archive/0.1.1.tar.gz
click>=8.1.2
hamlish-jinja@https://git.barkshark.xyz/barkshark/hamlish-jinja/archive/0.3.5.tar.gz
hiredis==2.3.2
pyyaml>=6.0
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'