mirror of
https://git.pleroma.social/pleroma/relay.git
synced 2024-11-10 02:17:59 +00:00
Compare commits
13 commits
b068f4f91e
...
b8b6dda131
Author | SHA1 | Date | |
---|---|---|---|
b8b6dda131 | |||
98b7e33288 | |||
d579ec634d | |||
02564c7295 | |||
e1ab01e4e2 | |||
a966f9c1cf | |||
2be96a8ca5 | |||
6018af1e68 | |||
c961fadc9a | |||
94f4d32314 | |||
f488d661d7 | |||
8b738dd365 | |||
3114e48371 |
|
@ -1,6 +1,3 @@
|
||||||
relay.example.org {
|
relay.example.com {
|
||||||
gzip
|
reverse_proxy / http://localhost:8080
|
||||||
proxy / 127.0.0.1:8080 {
|
|
||||||
transparent
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,9 +8,12 @@ import traceback
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
from aiohttp.web import StaticResource
|
||||||
from aiohttp_swagger import setup_swagger
|
from aiohttp_swagger import setup_swagger
|
||||||
from aputils.signer import Signer
|
from aputils.signer import Signer
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from mimetypes import guess_type
|
||||||
|
from pathlib import Path
|
||||||
from queue import Empty
|
from queue import Empty
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
|
|
||||||
|
@ -68,8 +71,6 @@ class Application(web.Application):
|
||||||
for path, view in VIEWS:
|
for path, view in VIEWS:
|
||||||
self.router.add_view(path, view)
|
self.router.add_view(path, view)
|
||||||
|
|
||||||
self.add_routes([web.static('/static', get_resource('frontend/static'))])
|
|
||||||
|
|
||||||
setup_swagger(
|
setup_swagger(
|
||||||
self,
|
self,
|
||||||
ui_version = 3,
|
ui_version = 3,
|
||||||
|
@ -130,7 +131,7 @@ class Application(web.Application):
|
||||||
data = [
|
data = [
|
||||||
"default-src 'none'",
|
"default-src 'none'",
|
||||||
f"script-src 'nonce-{request['hash']}'",
|
f"script-src 'nonce-{request['hash']}'",
|
||||||
f"style-src 'nonce-{request['hash']}'",
|
f"style-src 'self' 'nonce-{request['hash']}'",
|
||||||
"form-action 'self'",
|
"form-action 'self'",
|
||||||
"connect-src 'self'",
|
"connect-src 'self'",
|
||||||
"img-src 'self'",
|
"img-src 'self'",
|
||||||
|
@ -145,6 +146,16 @@ class Application(web.Application):
|
||||||
self['push_queue'].put((inbox, message, instance))
|
self['push_queue'].put((inbox, message, instance))
|
||||||
|
|
||||||
|
|
||||||
|
def register_static_routes(self) -> None:
|
||||||
|
if self['dev']:
|
||||||
|
static = StaticResource('/static', get_resource('frontend/static'))
|
||||||
|
|
||||||
|
else:
|
||||||
|
static = CachedStaticResource('/static', get_resource('frontend/static'))
|
||||||
|
|
||||||
|
self.router.register_resource(static)
|
||||||
|
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
if self["running"]:
|
if self["running"]:
|
||||||
return
|
return
|
||||||
|
@ -157,6 +168,8 @@ class Application(web.Application):
|
||||||
logging.error(f'A server is already running on {host}:{port}')
|
logging.error(f'A server is already running on {host}:{port}')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self.register_static_routes()
|
||||||
|
|
||||||
logging.info(f'Starting webserver at {domain} ({host}:{port})')
|
logging.info(f'Starting webserver at {domain} ({host}:{port})')
|
||||||
asyncio.run(self.handle_run())
|
asyncio.run(self.handle_run())
|
||||||
|
|
||||||
|
@ -223,6 +236,39 @@ class Application(web.Application):
|
||||||
self['cache'].close()
|
self['cache'].close()
|
||||||
|
|
||||||
|
|
||||||
|
class CachedStaticResource(StaticResource):
|
||||||
|
def __init__(self, prefix: str, path: Path):
|
||||||
|
StaticResource.__init__(self, prefix, path)
|
||||||
|
|
||||||
|
self.cache: dict[Path, bytes] = {}
|
||||||
|
|
||||||
|
for filename in path.rglob('*'):
|
||||||
|
if filename.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
rel_path = str(filename.relative_to(path))
|
||||||
|
|
||||||
|
with filename.open('rb') as fd:
|
||||||
|
logging.debug('Loading static resource "%s"', rel_path)
|
||||||
|
self.cache[rel_path] = fd.read()
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
rel_url = request.match_info['filename']
|
||||||
|
|
||||||
|
if Path(rel_url).anchor:
|
||||||
|
raise web.HTTPForbidden()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return web.Response(
|
||||||
|
body = self.cache[rel_url],
|
||||||
|
content_type = guess_type(rel_url)[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
raise web.HTTPNotFound()
|
||||||
|
|
||||||
|
|
||||||
class CacheCleanupThread(Thread):
|
class CacheCleanupThread(Thread):
|
||||||
def __init__(self, app: Application):
|
def __init__(self, app: Application):
|
||||||
Thread.__init__(self)
|
Thread.__init__(self)
|
||||||
|
@ -287,8 +333,8 @@ async def handle_response_headers(request: web.Request, handler: Callable) -> Re
|
||||||
resp.headers['Server'] = 'ActivityRelay'
|
resp.headers['Server'] = 'ActivityRelay'
|
||||||
|
|
||||||
# Still have to figure out how csp headers work
|
# Still have to figure out how csp headers work
|
||||||
# if resp.content_type == 'text/html':
|
if resp.content_type == 'text/html':
|
||||||
# resp.headers['Content-Security-Policy'] = Application.DEFAULT.get_csp(request)
|
resp.headers['Content-Security-Policy'] = Application.DEFAULT.get_csp(request)
|
||||||
|
|
||||||
if not request.app['dev'] and request.path.endswith(('.css', '.js')):
|
if not request.app['dev'] and request.path.endswith(('.css', '.js')):
|
||||||
# cache for 2 weeks
|
# cache for 2 weeks
|
||||||
|
|
24
relay/dev.py
24
relay/dev.py
|
@ -9,6 +9,7 @@ from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from . import logger as logging
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
|
@ -86,10 +87,11 @@ def cli_build():
|
||||||
|
|
||||||
|
|
||||||
@cli.command('run')
|
@cli.command('run')
|
||||||
def cli_run():
|
@click.option('--dev', '-d', is_flag = True)
|
||||||
|
def cli_run(dev: bool):
|
||||||
print('Starting process watcher')
|
print('Starting process watcher')
|
||||||
|
|
||||||
handler = WatchHandler()
|
handler = WatchHandler(dev)
|
||||||
handler.run_proc()
|
handler.run_proc()
|
||||||
|
|
||||||
watcher = Observer()
|
watcher = Observer()
|
||||||
|
@ -112,12 +114,13 @@ def cli_run():
|
||||||
|
|
||||||
class WatchHandler(PatternMatchingEventHandler):
|
class WatchHandler(PatternMatchingEventHandler):
|
||||||
patterns = ['*.py']
|
patterns = ['*.py']
|
||||||
cmd = [sys.executable, '-m', 'relay', 'run', '-d']
|
cmd = [sys.executable, '-m', 'relay', 'run']
|
||||||
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, dev: bool):
|
||||||
PatternMatchingEventHandler.__init__(self)
|
PatternMatchingEventHandler.__init__(self)
|
||||||
|
|
||||||
|
self.dev: bool = dev
|
||||||
self.proc = None
|
self.proc = None
|
||||||
self.last_restart = None
|
self.last_restart = None
|
||||||
|
|
||||||
|
@ -126,7 +129,7 @@ class WatchHandler(PatternMatchingEventHandler):
|
||||||
if self.proc.poll() is not None:
|
if self.proc.poll() is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f'Terminating process {self.proc.pid}')
|
logging.info(f'Terminating process {self.proc.pid}')
|
||||||
self.proc.terminate()
|
self.proc.terminate()
|
||||||
sec = 0.0
|
sec = 0.0
|
||||||
|
|
||||||
|
@ -135,11 +138,11 @@ class WatchHandler(PatternMatchingEventHandler):
|
||||||
sec += 0.1
|
sec += 0.1
|
||||||
|
|
||||||
if sec >= 5:
|
if sec >= 5:
|
||||||
print('Failed to terminate. Killing process...')
|
logging.error('Failed to terminate. Killing process...')
|
||||||
self.proc.kill()
|
self.proc.kill()
|
||||||
break
|
break
|
||||||
|
|
||||||
print('Process terminated')
|
logging.info('Process terminated')
|
||||||
|
|
||||||
|
|
||||||
def run_proc(self, restart=False):
|
def run_proc(self, restart=False):
|
||||||
|
@ -152,10 +155,13 @@ class WatchHandler(PatternMatchingEventHandler):
|
||||||
|
|
||||||
self.kill_proc()
|
self.kill_proc()
|
||||||
|
|
||||||
self.proc = subprocess.Popen(self.cmd, stdin = subprocess.PIPE)
|
cmd = [*self.cmd, '-d'] if self.dev else self.cmd
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(cmd, stdin = subprocess.PIPE)
|
||||||
self.last_restart = timestamp
|
self.last_restart = timestamp
|
||||||
|
|
||||||
print(f'Started process with PID {self.proc.pid}')
|
logging.info('Started process with PID %i', self.proc.pid)
|
||||||
|
logging.info('Command: %s', ' '.join(cmd))
|
||||||
|
|
||||||
|
|
||||||
def on_any_event(self, event):
|
def on_any_event(self, event):
|
||||||
|
|
|
@ -11,9 +11,11 @@
|
||||||
%title << {{config.name}}: {{page}}
|
%title << {{config.name}}: {{page}}
|
||||||
%meta(charset="UTF-8")
|
%meta(charset="UTF-8")
|
||||||
%meta(name="viewport" content="width=device-width, initial-scale=1")
|
%meta(name="viewport" content="width=device-width, initial-scale=1")
|
||||||
%link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css" nonce="{{view.request['hash']}}")
|
%link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css" nonce="{{view.request['hash']}}" class="theme")
|
||||||
%link(rel="stylesheet" type="text/css" href="/static/style.css" nonce="{{view.request['hash']}}")
|
%link(rel="stylesheet" type="text/css" href="/static/style.css" nonce="{{view.request['hash']}}")
|
||||||
|
%link(rel="stylesheet" type="text/css" href="/static/toast.css" nonce="{{view.request['hash']}}")
|
||||||
%script(type="application/javascript" src="/static/menu.js" nonce="{{view.request['hash']}}", defer)
|
%script(type="application/javascript" src="/static/menu.js" nonce="{{view.request['hash']}}", defer)
|
||||||
|
%script(type="application/javascript" src="/static/toast.js" nonce="{{view.request['hash']}}", defer)
|
||||||
%script(type="application/javascript" src="/static/api.js" nonce="{{view.request['hash']}}", defer)
|
%script(type="application/javascript" src="/static/api.js" nonce="{{view.request['hash']}}", defer)
|
||||||
-block head
|
-block head
|
||||||
|
|
||||||
|
@ -37,6 +39,8 @@
|
||||||
-else
|
-else
|
||||||
{{menu_item("Login", "/login")}}
|
{{menu_item("Login", "/login")}}
|
||||||
|
|
||||||
|
%ul#notifications
|
||||||
|
|
||||||
#container
|
#container
|
||||||
#header.section
|
#header.section
|
||||||
%span#menu-open << ⁞
|
%span#menu-open << ⁞
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
-macro new_checkbox(name, checked)
|
-macro new_checkbox(name, checked)
|
||||||
-if checked
|
-if checked
|
||||||
%input(id="{{name}}" name="{{name}}" type="checkbox" checked)
|
%input(id="{{name}}" type="checkbox" checked)
|
||||||
|
|
||||||
-else
|
-else
|
||||||
%input(id="{{name}}" name="{{name}}" type="checkbox")
|
%input(id="{{name}}" type="checkbox")
|
||||||
|
|
||||||
|
|
||||||
-macro new_select(name, selected, items)
|
-macro new_select(name, selected, items)
|
||||||
%select(id="{{name}}" name="{{name}}")
|
%select(id="{{name}}")
|
||||||
-for item in items
|
-for item in items
|
||||||
-if item == selected
|
-if item == selected
|
||||||
%option(value="{{item}}" selected) -> =item.title()
|
%option(value="{{item}}" selected) -> =item.title()
|
||||||
|
|
|
@ -11,10 +11,10 @@
|
||||||
|
|
||||||
.grid-2col
|
.grid-2col
|
||||||
%label(for="name") << Name
|
%label(for="name") << Name
|
||||||
%input(id = "name" name="name" placeholder="Relay Name" value="{{config.name or ''}}")
|
%input(id = "name" placeholder="Relay Name" value="{{config.name or ''}}")
|
||||||
|
|
||||||
%label(for="description") << Description
|
%label(for="note") << Description
|
||||||
%textarea(id="description" name="note" value="{{config.note or ''}}") << {{config.note}}
|
%textarea(id="note" value="{{config.note or ''}}") << {{config.note}}
|
||||||
|
|
||||||
%label(for="theme") << Color Theme
|
%label(for="theme") << Color Theme
|
||||||
=func.new_select("theme", config.theme, themes)
|
=func.new_select("theme", config.theme, themes)
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
-set page="Domain Bans"
|
-set page="Domain Bans"
|
||||||
|
|
||||||
-block head
|
-block head
|
||||||
%script(type="application/javascript" src="/static/domain_ban.js" nonce="{{view.request['hash']}}")
|
%script(type="application/javascript" src="/static/domain_ban.js" nonce="{{view.request['hash']}}" defer)
|
||||||
|
|
||||||
-block content
|
-block content
|
||||||
%details.section
|
%details.section
|
||||||
|
@ -17,7 +17,7 @@
|
||||||
%label(for="new-note") << Admin Note
|
%label(for="new-note") << Admin Note
|
||||||
%textarea(id="new-note") << {{""}}
|
%textarea(id="new-note") << {{""}}
|
||||||
|
|
||||||
%input(type="button" value="Ban Domain" onclick="ban();")
|
%input#new-ban(type="button" value="Ban Domain")
|
||||||
|
|
||||||
%fieldset.section
|
%fieldset.section
|
||||||
%legend << Domain Bans
|
%legend << Domain Bans
|
||||||
|
@ -44,10 +44,10 @@
|
||||||
%label.note(for="{{ban.domain}}-note") << Note
|
%label.note(for="{{ban.domain}}-note") << Note
|
||||||
%textarea.note(id="{{ban.domain}}-note") << {{ban.note or ""}}
|
%textarea.note(id="{{ban.domain}}-note") << {{ban.note or ""}}
|
||||||
|
|
||||||
%input(type="button" value="Update" onclick="update_ban('{{ban.domain}}')")
|
%input.update-ban(type="button" value="Update")
|
||||||
|
|
||||||
%td.date
|
%td.date
|
||||||
=ban.created.strftime("%Y-%m-%d")
|
=ban.created.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
%td.remove
|
%td.remove
|
||||||
%a(href="#" onclick="unban('{{ban.domain}}')" title="Unban domain") << ✖
|
%a(href="#" title="Unban domain") << ✖
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
-set page="Instances"
|
-set page="Instances"
|
||||||
|
|
||||||
-block head
|
-block head
|
||||||
%script(type="application/javascript" src="/static/instance.js" nonce="{{view.request['hash']}}")
|
%script(type="application/javascript" src="/static/instance.js" nonce="{{view.request['hash']}}" defer)
|
||||||
|
|
||||||
-block content
|
-block content
|
||||||
%details.section
|
%details.section
|
||||||
|
@ -20,7 +20,7 @@
|
||||||
%label(for="new-software") << Software
|
%label(for="new-software") << Software
|
||||||
%input(id="new-software" placeholder="software")
|
%input(id="new-software" placeholder="software")
|
||||||
|
|
||||||
%input(type="button" value="Add Instance", onclick="add_instance()")
|
%input#add-instance(type="button" value="Add Instance")
|
||||||
|
|
||||||
-if requests
|
-if requests
|
||||||
%fieldset.section.requests
|
%fieldset.section.requests
|
||||||
|
@ -48,10 +48,10 @@
|
||||||
=request.created.strftime("%Y-%m-%d")
|
=request.created.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
%td.approve
|
%td.approve
|
||||||
%a(href="#" onclick="req_response('{{request.domain}}', true)" title="Approve Request") << ✓
|
%a(href="#" title="Approve Request") << ✓
|
||||||
|
|
||||||
%td.deny
|
%td.deny
|
||||||
%a(href="#" onclick="req_response('{{request.domain}}', false)" title="Deny Request") << ✖
|
%a(href="#" title="Deny Request") << ✖
|
||||||
|
|
||||||
%fieldset.section.instances
|
%fieldset.section.instances
|
||||||
%legend << Instances
|
%legend << Instances
|
||||||
|
@ -78,4 +78,4 @@
|
||||||
=instance.created.strftime("%Y-%m-%d")
|
=instance.created.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
%td.remove
|
%td.remove
|
||||||
%a(href="#" onclick="del_instance('{{instance.domain}}')" title="Remove Instance") << ✖
|
%a(href="#" title="Remove Instance") << ✖
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
-set page="Software Bans"
|
-set page="Software Bans"
|
||||||
|
|
||||||
-block head
|
-block head
|
||||||
%script(type="application/javascript" src="/static/software_ban.js" nonce="{{view.request['hash']}}")
|
%script(type="application/javascript" src="/static/software_ban.js" nonce="{{view.request['hash']}}" defer)
|
||||||
|
|
||||||
-block content
|
-block content
|
||||||
%details.section
|
%details.section
|
||||||
|
@ -17,13 +17,13 @@
|
||||||
%label(for="new-note") << Admin Note
|
%label(for="new-note") << Admin Note
|
||||||
%textarea(id="new-note") << {{""}}
|
%textarea(id="new-note") << {{""}}
|
||||||
|
|
||||||
%input(type="submit" value="Ban Software" onclick="ban()")
|
%input#new-ban(type="button" value="Ban Software")
|
||||||
|
|
||||||
%fieldset.section
|
%fieldset.section
|
||||||
%legend << Software Bans
|
%legend << Software Bans
|
||||||
|
|
||||||
.data-table
|
.data-table
|
||||||
%table
|
%table#bans
|
||||||
%thead
|
%thead
|
||||||
%tr
|
%tr
|
||||||
%td.name << Name
|
%td.name << Name
|
||||||
|
@ -44,10 +44,10 @@
|
||||||
%label.note(for="{{ban.name}}-note") << Note
|
%label.note(for="{{ban.name}}-note") << Note
|
||||||
%textarea.note(id="{{ban.name}}-note") << {{ban.note or ""}}
|
%textarea.note(id="{{ban.name}}-note") << {{ban.note or ""}}
|
||||||
|
|
||||||
%input(type="button" value="Update" onclick="update_ban('{{ban.name}}')")
|
%input.update-ban(type="button" value="Update")
|
||||||
|
|
||||||
%td.date
|
%td.date
|
||||||
=ban.created.strftime("%Y-%m-%d")
|
=ban.created.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
%td.remove
|
%td.remove
|
||||||
%a(href="#" onclick="unban('{{ban.name}}')" title="Unban name") << ✖
|
%a(href="#" title="Unban name") << ✖
|
||||||
|
|
|
@ -2,25 +2,25 @@
|
||||||
-set page="Users"
|
-set page="Users"
|
||||||
|
|
||||||
-block head
|
-block head
|
||||||
%script(type="application/javascript" src="/static/user.js" nonce="{{view.request['hash']}}")
|
%script(type="application/javascript" src="/static/user.js" nonce="{{view.request['hash']}}" defer)
|
||||||
|
|
||||||
-block content
|
-block content
|
||||||
%details.section
|
%details.section
|
||||||
%summary << Add User
|
%summary << Add User
|
||||||
#add-item
|
#add-item
|
||||||
%label(for="new-username") << Username
|
%label(for="new-username") << Username
|
||||||
%input(id="new-username" name="username" placeholder="Username")
|
%input(id="new-username" name="username" placeholder="Username" autocomplete="off")
|
||||||
|
|
||||||
%label(for="new-password") << Password
|
%label(for="new-password") << Password
|
||||||
%input(id="new-password" type="password" placeholder="Password")
|
%input(id="new-password" type="password" placeholder="Password" autocomplete="off")
|
||||||
|
|
||||||
%label(for="new-password2") << Password Again
|
%label(for="new-password2") << Password Again
|
||||||
%input(id="new-password2" type="password" placeholder="Password Again")
|
%input(id="new-password2" type="password" placeholder="Password Again" autocomplete="off")
|
||||||
|
|
||||||
%label(for="new-handle") << Handle
|
%label(for="new-handle") << Handle
|
||||||
%input(id="new-handle" type="email" placeholder="handle")
|
%input(id="new-handle" type="email" placeholder="handle" autocomplete="off")
|
||||||
|
|
||||||
%input(type="button" value="Add User" onclick="add_user()")
|
%input#new-user(type="button" value="Add User")
|
||||||
|
|
||||||
%fieldset.section
|
%fieldset.section
|
||||||
%legend << Users
|
%legend << Users
|
||||||
|
@ -47,4 +47,4 @@
|
||||||
=user.created.strftime("%Y-%m-%d")
|
=user.created.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
%td.remove
|
%td.remove
|
||||||
%a(href="#" onclick="del_user('{{user.username}}')" title="Remove User") << ✖
|
%a(href="#" title="Remove User") << ✖
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
-set page="Whitelist"
|
-set page="Whitelist"
|
||||||
|
|
||||||
-block head
|
-block head
|
||||||
%script(type="application/javascript" src="/static/whitelist.js" nonce="{{view.request['hash']}}")
|
%script(type="application/javascript" src="/static/whitelist.js" nonce="{{view.request['hash']}}" defer)
|
||||||
|
|
||||||
-block content
|
-block content
|
||||||
%details.section
|
%details.section
|
||||||
|
@ -11,7 +11,7 @@
|
||||||
%label(for="new-domain") << Domain
|
%label(for="new-domain") << Domain
|
||||||
%input(type="domain" id="new-domain" placeholder="Domain")
|
%input(type="domain" id="new-domain" placeholder="Domain")
|
||||||
|
|
||||||
%input(type="button" value="Add Domain", onclick="add_whitelist()")
|
%input#new-item(type="button" value="Add Domain")
|
||||||
|
|
||||||
%fieldset.data-table.section
|
%fieldset.data-table.section
|
||||||
%legend << Whitelist
|
%legend << Whitelist
|
||||||
|
@ -33,4 +33,4 @@
|
||||||
=item.created.strftime("%Y-%m-%d")
|
=item.created.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
%td.remove
|
%td.remove
|
||||||
%a(href="#" onclick="del_whitelist('{{item.domain}}')" title="Remove whitlisted domain") << ✖
|
%a(href="#" title="Remove whitlisted domain") << ✖
|
||||||
|
|
|
@ -1,15 +1,18 @@
|
||||||
-extends "base.haml"
|
-extends "base.haml"
|
||||||
-set page="Login"
|
-set page="Login"
|
||||||
|
|
||||||
|
-block head
|
||||||
|
%script(type="application/javascript" src="/static/login.js" nonce="{{view.request['hash']}}" defer)
|
||||||
|
|
||||||
-block content
|
-block content
|
||||||
%fieldset.section
|
%fieldset.section
|
||||||
%legend << Login
|
%legend << Login
|
||||||
|
|
||||||
%form(action="/login" method="POST")
|
.grid-2col
|
||||||
.grid-2col
|
%label(for="username") << Username
|
||||||
%label(for="username") << Username
|
%input(id="username" name="username" placeholder="Username" value="{{username or ''}}")
|
||||||
%input(id="username" name="username" placeholder="Username" value="{{username or ''}}")
|
|
||||||
|
|
||||||
%label(for="password") << Password
|
%label(for="password") << Password
|
||||||
%input(id="password" name="password" placeholder="Password" type="password")
|
%input(id="password" name="password" placeholder="Password" type="password")
|
||||||
|
|
||||||
%input(type="submit" value="Login")
|
%input.submit(type="button" value="Login")
|
||||||
|
|
|
@ -30,6 +30,8 @@ function append_table_row(table, row_name, row) {
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return table_row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
const elems = [
|
const elems = [
|
||||||
document.querySelector("#name"),
|
document.querySelector("#name"),
|
||||||
document.querySelector("#description"),
|
document.querySelector("#note"),
|
||||||
document.querySelector("#theme"),
|
document.querySelector("#theme"),
|
||||||
document.querySelector("#log-level"),
|
document.querySelector("#log-level"),
|
||||||
document.querySelector("#whitelist-enabled"),
|
document.querySelector("#whitelist-enabled"),
|
||||||
|
@ -18,7 +18,7 @@ async function handle_config_change(event) {
|
||||||
await request("POST", "v1/config", params);
|
await request("POST", "v1/config", params);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error);
|
toast(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +26,12 @@ async function handle_config_change(event) {
|
||||||
document.querySelector("#header .title").innerHTML = params.value;
|
document.querySelector("#header .title").innerHTML = params.value;
|
||||||
document.querySelector("title").innerHTML = params.value;
|
document.querySelector("title").innerHTML = params.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.key === "theme") {
|
||||||
|
document.querySelector("link.theme").href = `/theme/${params.value}.css`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast("Updated config", "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -6,13 +6,25 @@ function create_ban_object(domain, reason, note) {
|
||||||
text += `<textarea id="${domain}-reason" class="reason">${reason}</textarea>\n`;
|
text += `<textarea id="${domain}-reason" class="reason">${reason}</textarea>\n`;
|
||||||
text += `<label for="${domain}-note" class="note">Note</label>\n`;
|
text += `<label for="${domain}-note" class="note">Note</label>\n`;
|
||||||
text += `<textarea id="${domain}-note" class="note">${note}</textarea>\n`;
|
text += `<textarea id="${domain}-note" class="note">${note}</textarea>\n`;
|
||||||
text += `<input type="button" value="Update" onclick="update_ban(\"${domain}\"")">`;
|
text += `<input class="update-ban" type="button" value="Update">`;
|
||||||
text += '</details>';
|
text += '</details>';
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function add_row_listeners(row) {
|
||||||
|
row.querySelector(".update-ban").addEventListener("click", async (event) => {
|
||||||
|
await update_ban(row.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.querySelector(".remove a").addEventListener("click", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await unban(row.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function ban() {
|
async function ban() {
|
||||||
var table = document.querySelector("table");
|
var table = document.querySelector("table");
|
||||||
var elems = {
|
var elems = {
|
||||||
|
@ -23,12 +35,12 @@ async function ban() {
|
||||||
|
|
||||||
var values = {
|
var values = {
|
||||||
domain: elems.domain.value.trim(),
|
domain: elems.domain.value.trim(),
|
||||||
reason: elems.reason.value,
|
reason: elems.reason.value.trim(),
|
||||||
note: elems.note.value
|
note: elems.note.value.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.domain === "") {
|
if (values.domain === "") {
|
||||||
alert("Domain is required");
|
toast("Domain is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,21 +48,24 @@ async function ban() {
|
||||||
var ban = await request("POST", "v1/domain_ban", values);
|
var ban = await request("POST", "v1/domain_ban", values);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err);
|
toast(err);
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
append_table_row(document.getElementById("instances"), ban.domain, {
|
var row = append_table_row(document.querySelector("table"), ban.domain, {
|
||||||
domain: create_ban_object(ban.domain, ban.reason, ban.note),
|
domain: create_ban_object(ban.domain, ban.reason, ban.note),
|
||||||
date: get_date_string(ban.created),
|
date: get_date_string(ban.created),
|
||||||
remove: `<a href="#" onclick="unban('${ban.domain}')" title="Unban domain">✖</a>`
|
remove: `<a href="#" title="Unban domain">✖</a>`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
add_row_listeners(row);
|
||||||
|
|
||||||
elems.domain.value = null;
|
elems.domain.value = null;
|
||||||
elems.reason.value = null;
|
elems.reason.value = null;
|
||||||
elems.note.value = null;
|
elems.note.value = null;
|
||||||
|
|
||||||
document.querySelector("details.section").open = false;
|
document.querySelector("details.section").open = false;
|
||||||
|
toast("Banned domain", "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -72,11 +87,12 @@ async function update_ban(domain) {
|
||||||
await request("PATCH", "v1/domain_ban", values)
|
await request("PATCH", "v1/domain_ban", values)
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error);
|
toast(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
row.querySelector("details").open = false;
|
row.querySelector("details").open = false;
|
||||||
|
toast("Updated baned domain", "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -85,9 +101,23 @@ async function unban(domain) {
|
||||||
await request("DELETE", "v1/domain_ban", {"domain": domain});
|
await request("DELETE", "v1/domain_ban", {"domain": domain});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error);
|
toast(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById(domain).remove();
|
document.getElementById(domain).remove();
|
||||||
|
toast("Unbanned domain", "message");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.querySelector("#new-ban").addEventListener("click", async (event) => {
|
||||||
|
await ban();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (var row of document.querySelector("fieldset.section table").rows) {
|
||||||
|
if (!row.querySelector(".update-ban")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_row_listeners(row);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,24 @@
|
||||||
|
function add_instance_listeners(row) {
|
||||||
|
row.querySelector(".remove a").addEventListener("click", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await del_instance(row.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function add_request_listeners(row) {
|
||||||
|
row.querySelector(".approve a").addEventListener("click", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await req_response(row.id, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.querySelector(".deny a").addEventListener("click", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await req_response(row.id, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function add_instance() {
|
async function add_instance() {
|
||||||
var elems = {
|
var elems = {
|
||||||
actor: document.getElementById("new-actor"),
|
actor: document.getElementById("new-actor"),
|
||||||
|
@ -14,7 +35,7 @@ async function add_instance() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.actor === "") {
|
if (values.actor === "") {
|
||||||
alert("Actor is required");
|
toast("Actor is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,23 +43,26 @@ async function add_instance() {
|
||||||
var instance = await request("POST", "v1/instance", values);
|
var instance = await request("POST", "v1/instance", values);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err);
|
toast(err);
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
append_table_row(document.getElementById("instances"), instance.domain, {
|
row = append_table_row(document.getElementById("instances"), instance.domain, {
|
||||||
domain: `<a href="https://${instance.domain}/" target="_new">${instance.domain}</a>`,
|
domain: `<a href="https://${instance.domain}/" target="_new">${instance.domain}</a>`,
|
||||||
software: instance.software,
|
software: instance.software,
|
||||||
date: get_date_string(instance.created),
|
date: get_date_string(instance.created),
|
||||||
remove: `<a href="#" onclick="del_instance('${instance.domain}')" title="Remove Instance">✖</a>`
|
remove: `<a href="#" title="Remove Instance">✖</a>`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
add_instance_listeners(row);
|
||||||
|
|
||||||
elems.actor.value = null;
|
elems.actor.value = null;
|
||||||
elems.inbox.value = null;
|
elems.inbox.value = null;
|
||||||
elems.followid.value = null;
|
elems.followid.value = null;
|
||||||
elems.software.value = null;
|
elems.software.value = null;
|
||||||
|
|
||||||
document.querySelector("details.section").open = false;
|
document.querySelector("details.section").open = false;
|
||||||
|
toast("Added instance", "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -47,7 +71,7 @@ async function del_instance(domain) {
|
||||||
await request("DELETE", "v1/instance", {"domain": domain});
|
await request("DELETE", "v1/instance", {"domain": domain});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error);
|
toast(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,7 +89,7 @@ async function req_response(domain, accept) {
|
||||||
await request("POST", "v1/request", params);
|
await request("POST", "v1/request", params);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error);
|
toast(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,12 +106,39 @@ async function req_response(domain, accept) {
|
||||||
instances = await request("GET", `v1/instance`, null);
|
instances = await request("GET", `v1/instance`, null);
|
||||||
instances.forEach((instance) => {
|
instances.forEach((instance) => {
|
||||||
if (instance.domain === domain) {
|
if (instance.domain === domain) {
|
||||||
append_table_row(document.getElementById("instances"), instance.domain, {
|
row = append_table_row(document.getElementById("instances"), instance.domain, {
|
||||||
domain: `<a href="https://${instance.domain}/" target="_new">${instance.domain}</a>`,
|
domain: `<a href="https://${instance.domain}/" target="_new">${instance.domain}</a>`,
|
||||||
software: instance.software,
|
software: instance.software,
|
||||||
date: get_date_string(instance.created),
|
date: get_date_string(instance.created),
|
||||||
remove: `<a href="#" onclick="del_instance('${instance.domain}')" title="Remove Instance">✖</a>`
|
remove: `<a href="#" title="Remove Instance">✖</a>`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
add_instance_listeners(row);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
toast("Removed instance", "message");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.querySelector("#add-instance").addEventListener("click", async (event) => {
|
||||||
|
await add_instance();
|
||||||
|
})
|
||||||
|
|
||||||
|
for (var row of document.querySelector("#instances").rows) {
|
||||||
|
if (!row.querySelector(".remove a")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_instance_listeners(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.querySelector("#requests")) {
|
||||||
|
for (var row of document.querySelector("#requests").rows) {
|
||||||
|
if (!row.querySelector(".approve a")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_request_listeners(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
29
relay/frontend/static/login.js
Normal file
29
relay/frontend/static/login.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
async function login(event) {
|
||||||
|
fields = {
|
||||||
|
username: document.querySelector("#username"),
|
||||||
|
password: document.querySelector("#password")
|
||||||
|
}
|
||||||
|
|
||||||
|
values = {
|
||||||
|
username: fields.username.value.trim(),
|
||||||
|
password: fields.password.value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.username === "" | values.password === "") {
|
||||||
|
toast("Username and/or password field is blank");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request("POST", "v1/token", values);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
toast(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.location = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.querySelector(".submit").addEventListener("click", login);
|
|
@ -6,17 +6,26 @@ function create_ban_object(name, reason, note) {
|
||||||
text += `<textarea id="${name}-reason" class="reason">${reason}</textarea>\n`;
|
text += `<textarea id="${name}-reason" class="reason">${reason}</textarea>\n`;
|
||||||
text += `<label for="${name}-note" class="note">Note</label>\n`;
|
text += `<label for="${name}-note" class="note">Note</label>\n`;
|
||||||
text += `<textarea id="${name}-note" class="note">${note}</textarea>\n`;
|
text += `<textarea id="${name}-note" class="note">${note}</textarea>\n`;
|
||||||
text += `<input type="button" value="Update" onclick="update_ban(\"${name}\"")">`;
|
text += `<input class="update-ban" type="button" value="Update">`;
|
||||||
text += '</details>';
|
text += '</details>';
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function ban() {
|
function add_row_listeners(row) {
|
||||||
var table = document.querySelector("table");
|
row.querySelector(".update-ban").addEventListener("click", async (event) => {
|
||||||
var row = table.insertRow(-1);
|
await update_ban(row.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.querySelector(".remove a").addEventListener("click", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await unban(row.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function ban() {
|
||||||
var elems = {
|
var elems = {
|
||||||
name: document.getElementById("new-name"),
|
name: document.getElementById("new-name"),
|
||||||
reason: document.getElementById("new-reason"),
|
reason: document.getElementById("new-reason"),
|
||||||
|
@ -30,7 +39,7 @@ async function ban() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.name === "") {
|
if (values.name === "") {
|
||||||
alert("Domain is required");
|
toast("Domain is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,21 +47,24 @@ async function ban() {
|
||||||
var ban = await request("POST", "v1/software_ban", values);
|
var ban = await request("POST", "v1/software_ban", values);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err);
|
toast(err);
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
append_table_row(document.getElementById("instances"), ban.name, {
|
var row = append_table_row(document.getElementById("bans"), ban.name, {
|
||||||
name: create_ban_object(ban.name, ban.reason, ban.note),
|
name: create_ban_object(ban.name, ban.reason, ban.note),
|
||||||
date: get_date_string(ban.created),
|
date: get_date_string(ban.created),
|
||||||
remove: `<a href="#" onclick="unban('${ban.domain}')" title="Unban software">✖</a>`
|
remove: `<a href="#" title="Unban software">✖</a>`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
add_row_listeners(row);
|
||||||
|
|
||||||
elems.name.value = null;
|
elems.name.value = null;
|
||||||
elems.reason.value = null;
|
elems.reason.value = null;
|
||||||
elems.note.value = null;
|
elems.note.value = null;
|
||||||
|
|
||||||
document.querySelector("details.section").open = false;
|
document.querySelector("details.section").open = false;
|
||||||
|
toast("Banned software", "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -74,11 +86,12 @@ async function update_ban(name) {
|
||||||
await request("PATCH", "v1/software_ban", values)
|
await request("PATCH", "v1/software_ban", values)
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error);
|
toast(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
row.querySelector("details").open = false;
|
row.querySelector("details").open = false;
|
||||||
|
toast("Updated software ban", "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -87,9 +100,23 @@ async function unban(name) {
|
||||||
await request("DELETE", "v1/software_ban", {"name": name});
|
await request("DELETE", "v1/software_ban", {"name": name});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error);
|
toast(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById(name).remove();
|
document.getElementById(name).remove();
|
||||||
|
toast("Unbanned software", "message");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.querySelector("#new-ban").addEventListener("click", async (event) => {
|
||||||
|
await ban();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (var row of document.querySelector("#bans").rows) {
|
||||||
|
if (!row.querySelector(".update-ban")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_row_listeners(row);
|
||||||
}
|
}
|
||||||
|
|
68
relay/frontend/static/toast.css
Normal file
68
relay/frontend/static/toast.css
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
#notifications {
|
||||||
|
position: fixed;
|
||||||
|
top: 40px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#notifications li {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
list-style: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px;;
|
||||||
|
margin-bottom: var(--spacing);
|
||||||
|
animation: show_toast 0.3s ease forwards;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto max-content;
|
||||||
|
grid-gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notifications a {
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: 1em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notifications li.hide {
|
||||||
|
animation: hide_toast 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes show_toast {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateX(-5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
80% {
|
||||||
|
transform: translateX(0%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes hide_toast {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateX(0%);
|
||||||
|
}
|
||||||
|
|
||||||
|
80% {
|
||||||
|
transform: translateX(-5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(calc(100% + 20px));
|
||||||
|
}
|
||||||
|
}
|
26
relay/frontend/static/toast.js
Normal file
26
relay/frontend/static/toast.js
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
const notifications = document.querySelector("#notifications")
|
||||||
|
|
||||||
|
|
||||||
|
function remove_toast(toast) {
|
||||||
|
toast.classList.add("hide");
|
||||||
|
|
||||||
|
if (toast.timeoutId) {
|
||||||
|
clearTimeout(toast.timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(text, type="error", timeout=5) {
|
||||||
|
const toast = document.createElement("li");
|
||||||
|
toast.className = `section ${type}`
|
||||||
|
toast.innerHTML = `<span class=".text">${text}</span><a href="#">✖</span>`
|
||||||
|
|
||||||
|
toast.querySelector("a").addEventListener("click", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await remove_toast(toast);
|
||||||
|
});
|
||||||
|
|
||||||
|
notifications.appendChild(toast);
|
||||||
|
toast.timeoutId = setTimeout(() => remove_toast(toast), timeout * 1000);
|
||||||
|
}
|
|
@ -1,3 +1,11 @@
|
||||||
|
function add_row_listeners(row) {
|
||||||
|
row.querySelector(".remove a").addEventListener("click", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await del_user(row.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function add_user() {
|
async function add_user() {
|
||||||
var elems = {
|
var elems = {
|
||||||
username: document.getElementById("new-username"),
|
username: document.getElementById("new-username"),
|
||||||
|
@ -14,12 +22,12 @@ async function add_user() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.username === "" | values.password === "" | values.password2 === "") {
|
if (values.username === "" | values.password === "" | values.password2 === "") {
|
||||||
alert("Username, password, and password2 are required");
|
toast("Username, password, and password2 are required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.password !== values.password2) {
|
if (values.password !== values.password2) {
|
||||||
alert("Passwords do not match");
|
toast("Passwords do not match");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,23 +35,26 @@ async function add_user() {
|
||||||
var user = await request("POST", "v1/user", values);
|
var user = await request("POST", "v1/user", values);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err);
|
toast(err);
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
append_table_row(document.getElementById("users"), user.username, {
|
var row = append_table_row(document.querySelector("fieldset.section table"), user.username, {
|
||||||
domain: user.username,
|
domain: user.username,
|
||||||
handle: user.handle,
|
handle: user.handle ? self.handle : "n/a",
|
||||||
date: get_date_string(user.created),
|
date: get_date_string(user.created),
|
||||||
remove: `<a href="#" onclick="del_user('${user.username}')" title="Delete User">✖</a>`
|
remove: `<a href="#" title="Delete User">✖</a>`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
add_row_listeners(row);
|
||||||
|
|
||||||
elems.username.value = null;
|
elems.username.value = null;
|
||||||
elems.password.value = null;
|
elems.password.value = null;
|
||||||
elems.password2.value = null;
|
elems.password2.value = null;
|
||||||
elems.handle.value = null;
|
elems.handle.value = null;
|
||||||
|
|
||||||
document.querySelector("details.section").open = false;
|
document.querySelector("details.section").open = false;
|
||||||
|
toast("Created user", "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -52,9 +63,23 @@ async function del_user(username) {
|
||||||
await request("DELETE", "v1/user", {"username": username});
|
await request("DELETE", "v1/user", {"username": username});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error);
|
toast(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById(username).remove();
|
document.getElementById(username).remove();
|
||||||
|
toast("Deleted user", "message");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.querySelector("#new-user").addEventListener("click", async (event) => {
|
||||||
|
await add_user();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (var row of document.querySelector("#users").rows) {
|
||||||
|
if (!row.querySelector(".remove a")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_row_listeners(row);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,17 @@
|
||||||
|
function add_row_listeners(row) {
|
||||||
|
row.querySelector(".remove a").addEventListener("click", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await del_whitelist(row.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function add_whitelist() {
|
async function add_whitelist() {
|
||||||
var domain_elem = document.getElementById("new-domain");
|
var domain_elem = document.getElementById("new-domain");
|
||||||
var domain = domain_elem.value.trim();
|
var domain = domain_elem.value.trim();
|
||||||
|
|
||||||
if (domain === "") {
|
if (domain === "") {
|
||||||
alert("Domain is required");
|
toast("Domain is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,18 +19,21 @@ async function add_whitelist() {
|
||||||
var item = await request("POST", "v1/whitelist", {"domain": domain});
|
var item = await request("POST", "v1/whitelist", {"domain": domain});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err);
|
toast(err);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
append_table_row(document.getElementById("whitelist"), item.domain, {
|
var row = append_table_row(document.getElementById("whitelist"), item.domain, {
|
||||||
domain: item.domain,
|
domain: item.domain,
|
||||||
date: get_date_string(item.created),
|
date: get_date_string(item.created),
|
||||||
remove: `<a href="#" onclick="del_whitelist('${item.domain}')" title="Remove whitelisted domain">✖</a>`
|
remove: `<a href="#" title="Remove whitelisted domain">✖</a>`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
add_row_listeners(row);
|
||||||
|
|
||||||
domain_elem.value = null;
|
domain_elem.value = null;
|
||||||
document.querySelector("details.section").open = false;
|
document.querySelector("details.section").open = false;
|
||||||
|
toast("Added domain", "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -31,9 +42,23 @@ async function del_whitelist(domain) {
|
||||||
await request("DELETE", "v1/whitelist", {"domain": domain});
|
await request("DELETE", "v1/whitelist", {"domain": domain});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error);
|
toast(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById(domain).remove();
|
document.getElementById(domain).remove();
|
||||||
|
toast("Removed domain", "message");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.querySelector("#new-item").addEventListener("click", async (event) => {
|
||||||
|
await add_whitelist();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (var row of document.querySelector("fieldset.section table").rows) {
|
||||||
|
if (!row.querySelector(".remove a")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_row_listeners(row);
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,7 +81,19 @@ class Login(View):
|
||||||
|
|
||||||
token = conn.put_token(data['username'])
|
token = conn.put_token(data['username'])
|
||||||
|
|
||||||
return Response.new({'token': token['code']}, ctype = 'json')
|
resp = Response.new({'token': token['code']}, ctype = 'json')
|
||||||
|
resp.set_cookie(
|
||||||
|
'user-token',
|
||||||
|
token['code'],
|
||||||
|
max_age = 60 * 60 * 24 * 365,
|
||||||
|
domain = self.config.domain,
|
||||||
|
path = '/',
|
||||||
|
secure = True,
|
||||||
|
httponly = False,
|
||||||
|
samesite = 'lax'
|
||||||
|
)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
async def delete(self, request: Request) -> Response:
|
async def delete(self, request: Request) -> Response:
|
||||||
|
|
|
@ -72,47 +72,6 @@ class Login(View):
|
||||||
return Response.new(data, ctype = 'html')
|
return Response.new(data, ctype = 'html')
|
||||||
|
|
||||||
|
|
||||||
async def post(self, request: Request) -> Response:
|
|
||||||
form = await request.post()
|
|
||||||
params = {}
|
|
||||||
|
|
||||||
with self.database.session(True) as conn:
|
|
||||||
if not (user := conn.get_user(form['username'])):
|
|
||||||
params = {
|
|
||||||
'username': form['username'],
|
|
||||||
'error': 'User not found'
|
|
||||||
}
|
|
||||||
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
conn.hasher.verify(user['hash'], form['password'])
|
|
||||||
|
|
||||||
except VerifyMismatchError:
|
|
||||||
params = {
|
|
||||||
'username': form['username'],
|
|
||||||
'error': 'Invalid password'
|
|
||||||
}
|
|
||||||
|
|
||||||
if params:
|
|
||||||
data = self.template.render('page/login.haml', self, **params)
|
|
||||||
return Response.new(data, ctype = 'html')
|
|
||||||
|
|
||||||
token = conn.put_token(user['username'])
|
|
||||||
resp = Response.new_redir(request.query.getone('redir', '/'))
|
|
||||||
resp.set_cookie(
|
|
||||||
'user-token',
|
|
||||||
token['code'],
|
|
||||||
max_age = 60 * 60 * 24 * 365,
|
|
||||||
domain = self.config.domain,
|
|
||||||
path = '/',
|
|
||||||
secure = True,
|
|
||||||
httponly = False,
|
|
||||||
samesite = 'lax'
|
|
||||||
)
|
|
||||||
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/logout')
|
@register_route('/logout')
|
||||||
class Logout(View):
|
class Logout(View):
|
||||||
async def get(self, request: Request) -> Response:
|
async def get(self, request: Request) -> Response:
|
||||||
|
@ -153,104 +112,6 @@ class AdminInstances(View):
|
||||||
return Response.new(data, ctype = 'html')
|
return Response.new(data, ctype = 'html')
|
||||||
|
|
||||||
|
|
||||||
async def post(self, request: Request) -> Response:
|
|
||||||
post = await request.post()
|
|
||||||
data: dict[str, str] = {key: value for key, value in post.items()} # type: ignore
|
|
||||||
|
|
||||||
if not data.get('actor') and not data.get('domain'):
|
|
||||||
return await self.get(request, error = 'Missing actor and/or domain')
|
|
||||||
|
|
||||||
if not data.get('domain'):
|
|
||||||
data['domain'] = urlparse(data['actor']).netloc
|
|
||||||
|
|
||||||
if not data.get('software'):
|
|
||||||
nodeinfo = await self.client.fetch_nodeinfo(data['domain'])
|
|
||||||
|
|
||||||
if nodeinfo is None:
|
|
||||||
return await self.get(request, error = 'Failed to fetch nodeinfo')
|
|
||||||
|
|
||||||
data['software'] = nodeinfo.sw_name
|
|
||||||
|
|
||||||
if not data.get('actor') and data['software'] in ACTOR_FORMATS:
|
|
||||||
data['actor'] = ACTOR_FORMATS[data['software']].format(domain = data['domain'])
|
|
||||||
|
|
||||||
if not data.get('inbox') and data['actor']:
|
|
||||||
actor: Message | None = await self.client.get(data['actor'], True, Message)
|
|
||||||
|
|
||||||
if actor is None:
|
|
||||||
return await self.get(request, error = 'Failed to fetch actor')
|
|
||||||
|
|
||||||
data['inbox'] = actor.shared_inbox
|
|
||||||
|
|
||||||
with self.database.session(True) as conn:
|
|
||||||
conn.put_inbox(**data)
|
|
||||||
|
|
||||||
return await self.get(request, message = "Added new inbox")
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/instances/delete/{domain}')
|
|
||||||
class AdminInstancesDelete(View):
|
|
||||||
async def get(self, request: Request, domain: str) -> Response:
|
|
||||||
with self.database.session(True) as conn:
|
|
||||||
if not conn.get_inbox(domain):
|
|
||||||
return await AdminInstances(request).get(request, error = 'Instance not found')
|
|
||||||
|
|
||||||
conn.del_inbox(domain)
|
|
||||||
|
|
||||||
return await AdminInstances(request).get(request, message = 'Removed instance')
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/instances/approve/{domain}')
|
|
||||||
class AdminInstancesApprove(View):
|
|
||||||
async def get(self, request: Request, domain: str) -> Response:
|
|
||||||
try:
|
|
||||||
with self.database.session(True) as conn:
|
|
||||||
instance = conn.put_request_response(domain, True)
|
|
||||||
|
|
||||||
except KeyError:
|
|
||||||
return await AdminInstances(request).get(request, error = 'Instance not found')
|
|
||||||
|
|
||||||
message = Message.new_response(
|
|
||||||
host = self.config.domain,
|
|
||||||
actor = instance['actor'],
|
|
||||||
followid = instance['followid'],
|
|
||||||
accept = True
|
|
||||||
)
|
|
||||||
|
|
||||||
self.app.push_message(instance['inbox'], message, instance)
|
|
||||||
|
|
||||||
if instance['software'] != 'mastodon':
|
|
||||||
message = Message.new_follow(
|
|
||||||
host = self.config.domain,
|
|
||||||
actor = instance['actor']
|
|
||||||
)
|
|
||||||
|
|
||||||
self.app.push_message(instance['inbox'], message, instance)
|
|
||||||
|
|
||||||
return await AdminInstances(request).get(request, message = 'Request accepted')
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/instances/deny/{domain}')
|
|
||||||
class AdminInstancesDeny(View):
|
|
||||||
async def get(self, request: Request, domain: str) -> Response:
|
|
||||||
try:
|
|
||||||
with self.database.session(True) as conn:
|
|
||||||
instance = conn.put_request_response(domain, False)
|
|
||||||
|
|
||||||
except KeyError:
|
|
||||||
return await AdminInstances(request).get(request, error = 'Instance not found')
|
|
||||||
|
|
||||||
message = Message.new_response(
|
|
||||||
host = self.config.domain,
|
|
||||||
actor = instance['actor'],
|
|
||||||
followid = instance['followid'],
|
|
||||||
accept = False
|
|
||||||
)
|
|
||||||
|
|
||||||
self.app.push_message(instance['inbox'], message, instance)
|
|
||||||
return await AdminInstances(request).get(request, message = 'Request denied')
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/whitelist')
|
@register_route('/admin/whitelist')
|
||||||
class AdminWhitelist(View):
|
class AdminWhitelist(View):
|
||||||
async def get(self,
|
async def get(self,
|
||||||
|
@ -273,34 +134,6 @@ class AdminWhitelist(View):
|
||||||
return Response.new(data, ctype = 'html')
|
return Response.new(data, ctype = 'html')
|
||||||
|
|
||||||
|
|
||||||
async def post(self, request: Request) -> Response:
|
|
||||||
data = await request.post()
|
|
||||||
|
|
||||||
if not data['domain']:
|
|
||||||
return await self.get(request, error = 'Missing domain')
|
|
||||||
|
|
||||||
with self.database.session(True) as conn:
|
|
||||||
if conn.get_domain_whitelist(data['domain']):
|
|
||||||
return await self.get(request, error = "Domain already in whitelist")
|
|
||||||
|
|
||||||
conn.put_domain_whitelist(data['domain'])
|
|
||||||
|
|
||||||
return await self.get(request, message = "Added/updated domain ban")
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/whitelist/delete/{domain}')
|
|
||||||
class AdminWhitlistDelete(View):
|
|
||||||
async def get(self, request: Request, domain: str) -> Response:
|
|
||||||
with self.database.session() as conn:
|
|
||||||
if not conn.get_domain_whitelist(domain):
|
|
||||||
msg = 'Whitelisted domain not found'
|
|
||||||
return await AdminWhitelist.run("GET", request, error = msg)
|
|
||||||
|
|
||||||
conn.del_domain_whitelist(domain)
|
|
||||||
|
|
||||||
return await AdminWhitelist.run("GET", request, message = 'Removed domain from whitelist')
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/domain_bans')
|
@register_route('/admin/domain_bans')
|
||||||
class AdminDomainBans(View):
|
class AdminDomainBans(View):
|
||||||
async def get(self,
|
async def get(self,
|
||||||
|
@ -323,42 +156,6 @@ class AdminDomainBans(View):
|
||||||
return Response.new(data, ctype = 'html')
|
return Response.new(data, ctype = 'html')
|
||||||
|
|
||||||
|
|
||||||
async def post(self, request: Request) -> Response:
|
|
||||||
data = await request.post()
|
|
||||||
|
|
||||||
if not data['domain']:
|
|
||||||
return await self.get(request, error = 'Missing domain')
|
|
||||||
|
|
||||||
with self.database.session(True) as conn:
|
|
||||||
if conn.get_domain_ban(data['domain']):
|
|
||||||
conn.update_domain_ban(
|
|
||||||
data['domain'],
|
|
||||||
data.get('reason'),
|
|
||||||
data.get('note')
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
conn.put_domain_ban(
|
|
||||||
data['domain'],
|
|
||||||
data.get('reason'),
|
|
||||||
data.get('note')
|
|
||||||
)
|
|
||||||
|
|
||||||
return await self.get(request, message = "Added/updated domain ban")
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/domain_bans/delete/{domain}')
|
|
||||||
class AdminDomainBansDelete(View):
|
|
||||||
async def get(self, request: Request, domain: str) -> Response:
|
|
||||||
with self.database.session() as conn:
|
|
||||||
if not conn.get_domain_ban(domain):
|
|
||||||
return await AdminDomainBans.run("GET", request, error = 'Domain ban not found')
|
|
||||||
|
|
||||||
conn.del_domain_ban(domain)
|
|
||||||
|
|
||||||
return await AdminDomainBans.run("GET", request, message = 'Unbanned domain')
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/software_bans')
|
@register_route('/admin/software_bans')
|
||||||
class AdminSoftwareBans(View):
|
class AdminSoftwareBans(View):
|
||||||
async def get(self,
|
async def get(self,
|
||||||
|
@ -381,42 +178,6 @@ class AdminSoftwareBans(View):
|
||||||
return Response.new(data, ctype = 'html')
|
return Response.new(data, ctype = 'html')
|
||||||
|
|
||||||
|
|
||||||
async def post(self, request: Request) -> Response:
|
|
||||||
data = await request.post()
|
|
||||||
|
|
||||||
if not data['name']:
|
|
||||||
return await self.get(request, error = 'Missing name')
|
|
||||||
|
|
||||||
with self.database.session(True) as conn:
|
|
||||||
if conn.get_software_ban(data['name']):
|
|
||||||
conn.update_software_ban(
|
|
||||||
data['name'],
|
|
||||||
data.get('reason'),
|
|
||||||
data.get('note')
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
conn.put_software_ban(
|
|
||||||
data['name'],
|
|
||||||
data.get('reason'),
|
|
||||||
data.get('note')
|
|
||||||
)
|
|
||||||
|
|
||||||
return await self.get(request, message = "Added/updated software ban")
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/software_bans/delete/{name}')
|
|
||||||
class AdminSoftwareBansDelete(View):
|
|
||||||
async def get(self, request: Request, name: str) -> Response:
|
|
||||||
with self.database.session() as conn:
|
|
||||||
if not conn.get_software_ban(name):
|
|
||||||
return await AdminSoftwareBans.run("GET", request, error = 'Software ban not found')
|
|
||||||
|
|
||||||
conn.del_software_ban(name)
|
|
||||||
|
|
||||||
return await AdminSoftwareBans.run("GET", request, message = 'Unbanned software')
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/users')
|
@register_route('/admin/users')
|
||||||
class AdminUsers(View):
|
class AdminUsers(View):
|
||||||
async def get(self,
|
async def get(self,
|
||||||
|
@ -439,37 +200,6 @@ class AdminUsers(View):
|
||||||
return Response.new(data, ctype = 'html')
|
return Response.new(data, ctype = 'html')
|
||||||
|
|
||||||
|
|
||||||
async def post(self, request: Request) -> Response:
|
|
||||||
data = await request.post()
|
|
||||||
required_fields = {'username', 'password', 'password2'}
|
|
||||||
|
|
||||||
if not all(data.get(field) for field in required_fields):
|
|
||||||
return await self.get(request, error = 'Missing username and/or password')
|
|
||||||
|
|
||||||
if data['password'] != data['password2']:
|
|
||||||
return await self.get(request, error = 'Passwords do not match')
|
|
||||||
|
|
||||||
with self.database.session(True) as conn:
|
|
||||||
if conn.get_user(data['username']):
|
|
||||||
return await self.get(request, error = "User already exists")
|
|
||||||
|
|
||||||
conn.put_user(data['username'], data['password'], data['handle'])
|
|
||||||
|
|
||||||
return await self.get(request, message = "Added user")
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/users/delete/{name}')
|
|
||||||
class AdminUsersDelete(View):
|
|
||||||
async def get(self, request: Request, name: str) -> Response:
|
|
||||||
with self.database.session() as conn:
|
|
||||||
if not conn.get_user(name):
|
|
||||||
return await AdminUsers.run("GET", request, error = 'User not found')
|
|
||||||
|
|
||||||
conn.del_user(name)
|
|
||||||
|
|
||||||
return await AdminUsers.run("GET", request, message = 'User deleted')
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/admin/config')
|
@register_route('/admin/config')
|
||||||
class AdminConfig(View):
|
class AdminConfig(View):
|
||||||
async def get(self, request: Request, message: str | None = None) -> Response:
|
async def get(self, request: Request, message: str | None = None) -> Response:
|
||||||
|
@ -483,23 +213,6 @@ class AdminConfig(View):
|
||||||
return Response.new(data, ctype = 'html')
|
return Response.new(data, ctype = 'html')
|
||||||
|
|
||||||
|
|
||||||
async def post(self, request: Request) -> Response:
|
|
||||||
form = dict(await request.post())
|
|
||||||
data = ConfigData()
|
|
||||||
|
|
||||||
for key in ConfigData.USER_KEYS():
|
|
||||||
data.set(key, form.get(key.replace('_', '-')))
|
|
||||||
|
|
||||||
with self.database.session(True) as conn:
|
|
||||||
for key, value in data.to_dict().items():
|
|
||||||
if key in ConfigData.SYSTEM_KEYS():
|
|
||||||
continue
|
|
||||||
|
|
||||||
conn.put_config(key, value)
|
|
||||||
|
|
||||||
return await self.get(request, message = 'Updated config')
|
|
||||||
|
|
||||||
|
|
||||||
@register_route('/style.css')
|
@register_route('/style.css')
|
||||||
class StyleCss(View):
|
class StyleCss(View):
|
||||||
async def get(self, request: Request) -> Response:
|
async def get(self, request: Request) -> Response:
|
||||||
|
|
Loading…
Reference in a new issue