diff --git a/relay/application.py b/relay/application.py
index 9a49433..6fc09ae 100644
--- a/relay/application.py
+++ b/relay/application.py
@@ -23,6 +23,7 @@ from .misc import check_open_port, get_resource
from .template import Template
from .views import VIEWS
from .views.api import handle_api_path
+from .views.frontend import handle_frontend_path
if typing.TYPE_CHECKING:
from tinysql import Database, Row
@@ -38,7 +39,8 @@ class Application(web.Application):
def __init__(self, cfgpath: str | None):
web.Application.__init__(self,
middlewares = [
- handle_api_path
+ handle_api_path,
+ handle_frontend_path
]
)
diff --git a/relay/database/config.py b/relay/database/config.py
index c961a0c..d49f1b5 100644
--- a/relay/database/config.py
+++ b/relay/database/config.py
@@ -22,7 +22,10 @@ THEMES = {
'border': '#444',
'message-text': '#DDD',
'message-background': '#335',
- 'message-border': '#446'
+ 'message-border': '#446',
+ 'error-text': '#DDD',
+ 'error-background': '#533',
+ 'error-border': '#644'
},
'pink': {
'text': '#DDD',
@@ -34,7 +37,10 @@ THEMES = {
'border': '#444',
'message-text': '#DDD',
'message-background': '#335',
- 'message-border': '#446'
+ 'message-border': '#446',
+ 'error-text': '#DDD',
+ 'error-background': '#533',
+ 'error-border': '#644'
},
'blue': {
'text': '#DDD',
@@ -46,7 +52,10 @@ THEMES = {
'border': '#444',
'message-text': '#DDD',
'message-background': '#335',
- 'message-border': '#446'
+ 'message-border': '#446',
+ 'error-text': '#DDD',
+ 'error-background': '#533',
+ 'error-border': '#644'
}
}
diff --git a/relay/frontend/base.haml b/relay/frontend/base.haml
index 2df7899..8fb7e80 100644
--- a/relay/frontend/base.haml
+++ b/relay/frontend/base.haml
@@ -1,22 +1,80 @@
+-macro menu_item(name, path)
+ -if view.request.path == path
+ %a.button(active="true") -> =name
+
+ -else
+ %a.button(href="{{path}}") -> =name
+
!!!
%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")
+ %link(rel="stylesheet" type="text/css" href="/style.css?page={{page}}")
-block head
%body
+ #menu.section(visible="false")
+ .menu-head
+ %span.menu-title << Menu
+ %span#menu-close.button << ✖
+
+ {{menu_item("Home", "/")}}
+
+ -if view.request["user"]
+ {{menu_item("Instances", "/admin/instances")}}
+ {{menu_item("Whitelist", "/admin/whitelist")}}
+ {{menu_item("Domain Bans", "/admin/domain_bans")}}
+ {{menu_item("Software Bans", "/admin/software_bans")}}
+ {{menu_item("Config", "/admin/config")}}
+ {{menu_item("Logout", "/logout")}}
+
+ -else
+ {{menu_item("Login", "/login")}}
+
#container
#header.section
+ %span#menu-open.button << ⁞
%a(href="https://{{domain}}/") -> =config.name
+ .empty
+
+ -if error
+ .error.section -> =error
+
+ -if message
+ .message.section -> =message
#content
-block content
#footer.section
.col1
+ -if not view.request["user"]
+ %a(href="/login") << Login
+
+ -else
+ =view.request["user"]["username"]
+ (
+ %a(href="/logout") << Logout
+ )
+
.version
%a(href="https://git.pleroma.social/pleroma/relay")
ActivityRelay/{{version}}
+
+ %script(type="application/javascript")
+ const body = document.getElementById("container")
+ const menu = document.getElementById("menu");
+ const menu_open = document.getElementById("menu-open");
+ const menu_close = document.getElementById("menu-close");
+
+ menu_open.addEventListener("click", (event) => {menu.attributes.visible.nodeValue = "true"});
+ menu_close.addEventListener("click", (event) => {menu.attributes.visible.nodeValue = "false"});
+ body.addEventListener("click", (event) => {
+ if (event.target === menu_open) {
+ return;
+ }
+
+ menu.attributes.visible.nodeValue = "false";
+ });
diff --git a/relay/frontend/functions.haml b/relay/frontend/functions.haml
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/relay/frontend/functions.haml
@@ -0,0 +1 @@
+
diff --git a/relay/frontend/page/admin-config.haml b/relay/frontend/page/admin-config.haml
new file mode 100644
index 0000000..6587dbe
--- /dev/null
+++ b/relay/frontend/page/admin-config.haml
@@ -0,0 +1,5 @@
+-extends "base.haml"
+-set page="Config"
+-block content
+ .section
+ UvU
diff --git a/relay/frontend/page/admin-domain_bans.haml b/relay/frontend/page/admin-domain_bans.haml
new file mode 100644
index 0000000..6550244
--- /dev/null
+++ b/relay/frontend/page/admin-domain_bans.haml
@@ -0,0 +1,5 @@
+-extends "base.haml"
+-set page="Domain Bans"
+-block content
+ .section
+ UvU
diff --git a/relay/frontend/page/admin-instances.haml b/relay/frontend/page/admin-instances.haml
new file mode 100644
index 0000000..04c84f9
--- /dev/null
+++ b/relay/frontend/page/admin-instances.haml
@@ -0,0 +1,5 @@
+-extends "base.haml"
+-set page="Instances"
+-block content
+ .section
+ UvU
diff --git a/relay/frontend/page/admin-software_bans.haml b/relay/frontend/page/admin-software_bans.haml
new file mode 100644
index 0000000..c215fc8
--- /dev/null
+++ b/relay/frontend/page/admin-software_bans.haml
@@ -0,0 +1,5 @@
+-extends "base.haml"
+-set page="Software Bans"
+-block content
+ .section
+ UvU
diff --git a/relay/frontend/page/admin-whitelist.haml b/relay/frontend/page/admin-whitelist.haml
new file mode 100644
index 0000000..083c8bb
--- /dev/null
+++ b/relay/frontend/page/admin-whitelist.haml
@@ -0,0 +1,5 @@
+-extends "base.haml"
+-set page="Whitelist"
+-block content
+ .section
+ UvU
diff --git a/relay/frontend/page/login.haml b/relay/frontend/page/login.haml
new file mode 100644
index 0000000..ee7d1dc
--- /dev/null
+++ b/relay/frontend/page/login.haml
@@ -0,0 +1,9 @@
+-extends "base.haml"
+-set page="Login"
+-block content
+ %form.section(action="/login" method="post")
+ %label(for="username") << Username
+ %input(id="username" name="username" placeholder="Username" value="{{username or ''}}")
+ %label(for="password") << Password
+ %input(id="password" name="password" placeholder="Password" type="password")
+ %input(type="submit" value="Login")
diff --git a/relay/frontend/style.css b/relay/frontend/style.css
index 743b2b5..61c358d 100644
--- a/relay/frontend/style.css
+++ b/relay/frontend/style.css
@@ -9,6 +9,9 @@
--message-text: {{theme["message-text"]}};
--message-background: {{theme["message-background"]}};
--message-border: {{theme["message-border"]}};
+ --error-text: {{theme["error-text"]}};
+ --error-background: {{theme["error-background"]}};
+ --error-border: {{theme["error-border"]}};
--spacing: 10px;
}
@@ -84,31 +87,76 @@ table tbody td {
#container {
width: 1024px;
margin: 0px auto;
+ height: 100vh;
}
#header {
- text-align: center;
+ display: grid;
+ grid-template-columns: 50px auto 50px;
+ justify-items: center;
+ align-items: center;
}
-#header a {
- font-size: 3em;
+#header > * {
+ font-size: 2em;
}
-#instances table {
- width: 100%;
+#header > *:nth-child(2) {
+ font-weight: bold;
}
-#instances .instance {
- width: 100%;
-}
-
-#instances .date {
+#menu {
+ padding: 0px;
+ position: fixed;
+ top: 0px;
+ left: 0px;
+ margin: 0px;
+ height: 100%;
width: max-content;
- text-align: right;
+ z-index: 1;
+ font-size: 1.5em;
+ min-width: 300px;
}
-#instances thead td {
- text-align: center !important;
+#menu[visible="false"] {
+ visibility: hidden;
+}
+
+#menu > a {
+ margin: var(--spacing);
+ display: block;
+ border-radius: 5px;
+ padding: 5px;
+}
+
+#menu > a[active="true"] {
+ cursor: default;
+ background-color: var(--background);
+ color: var(--primary);
+ border-color: transparent;
+}
+
+#menu .menu-head {
+ display: grid;
+ grid-template-columns: auto max-content;
+ margin: var(--spacing);
+}
+
+#menu .menu-head > * {
+ font-weight: bold;
+}
+
+#menu .menu-title {
+ color: var(--primary);
+}
+
+#menu-open, #menu-close {
+ cursor: pointer;
+}
+
+#menu-close, #menu-open {
+ min-width: 35px;
+ text-align: center;
}
#footer {
@@ -121,6 +169,34 @@ table tbody td {
}
+.button {
+ background-color: var(--primary);
+ border: 1px solid var(--primary);
+ color: var(--background);
+ border-radius: 5px;
+}
+
+.button:hover {
+ background-color: var(--background);
+ color: var(--primary);
+ text-decoration: None;
+}
+
+.container {
+ display: grid;
+ grid-template-columns: max-content auto;
+}
+
+.error, .message {
+ text-align: center;
+}
+
+.error{
+ color: var(--error-text) !important;
+ background-color: var(--error-background) !important;
+ border: 1px solid var(--error-border) !important;
+}
+
.message {
color: var(--message-text) !important;
background-color: var(--message-background) !important;
@@ -143,16 +219,37 @@ table tbody td {
}
+{% if page %}
+ {% include "style/" + page.lower().replace(" ", "_") + ".css" %}
+{% endif %}
+
+
@media (max-width: 1026px) {
body {
margin: 0px;
}
+ #menu {
+ width: 100%;
+ }
+
+ #menu > a {
+ text-align: center;
+ }
+
#container {
width: unset;
margin: unset;
}
+ .container {
+ grid-template-columns: auto;
+ }
+
+ .content {
+ grid-template-columns: auto;
+ }
+
.section {
border-width: 0px;
border-radius: 0px;
diff --git a/relay/frontend/style/config.css b/relay/frontend/style/config.css
new file mode 100644
index 0000000..e69de29
diff --git a/relay/frontend/style/domain_bans.css b/relay/frontend/style/domain_bans.css
new file mode 100644
index 0000000..e69de29
diff --git a/relay/frontend/style/home.css b/relay/frontend/style/home.css
new file mode 100644
index 0000000..4cdfaa9
--- /dev/null
+++ b/relay/frontend/style/home.css
@@ -0,0 +1,16 @@
+#instances table {
+ width: 100%;
+}
+
+#instances .instance {
+ width: 100%;
+}
+
+#instances .date {
+ width: max-content;
+ text-align: right;
+}
+
+#instances thead td {
+ text-align: center !important;
+}
diff --git a/relay/frontend/style/instances.css b/relay/frontend/style/instances.css
new file mode 100644
index 0000000..e69de29
diff --git a/relay/frontend/style/login.css b/relay/frontend/style/login.css
new file mode 100644
index 0000000..48f623b
--- /dev/null
+++ b/relay/frontend/style/login.css
@@ -0,0 +1,18 @@
+label, input {
+ margin: 0 auto;
+ display: block;
+}
+
+label, input:not([type="submit"]) {
+ width: 50%;
+}
+
+input:not([type="submit"]) {
+ margin-bottom: var(--spacing);
+}
+
+@media (max-width: 1026px) {
+ label, input:not([type="submit"]) {
+ width: 75%;
+ }
+}
diff --git a/relay/frontend/style/software_bans.css b/relay/frontend/style/software_bans.css
new file mode 100644
index 0000000..e69de29
diff --git a/relay/frontend/style/whitelist.css b/relay/frontend/style/whitelist.css
new file mode 100644
index 0000000..e69de29
diff --git a/relay/misc.py b/relay/misc.py
index b4d333c..3007863 100644
--- a/relay/misc.py
+++ b/relay/misc.py
@@ -225,6 +225,12 @@ class Response(AiohttpResponse):
return cls.new(body=body, status=status, ctype=ctype)
+ @classmethod
+ def new_redir(cls: type[Response], path: str) -> Response:
+ body = f'Redirect to {path}'
+ return cls.new(body, 302, {'Location': path})
+
+
@property
def location(self) -> str:
return self.headers.get('Location')
diff --git a/relay/views/frontend.py b/relay/views/frontend.py
index 567a44b..7b45011 100644
--- a/relay/views/frontend.py
+++ b/relay/views/frontend.py
@@ -2,6 +2,9 @@ from __future__ import annotations
import typing
+from aiohttp import web
+from argon2.exceptions import VerifyMismatchError
+
from .base import View, register_route
from ..misc import Response
@@ -10,6 +13,44 @@ if typing.TYPE_CHECKING:
from aiohttp.web import Request
+AUTH_ROUTES = {
+ '/admin',
+ '/admin/instances',
+ '/admin/domain_bans',
+ '/admin/software_bans',
+ '/admin/whitelist',
+ '/admin/config',
+ '/logout'
+}
+
+
+UNAUTH_ROUTES = {
+ '/',
+ '/login'
+}
+
+ALL_ROUTES = {*AUTH_ROUTES, *UNAUTH_ROUTES}
+
+
+@web.middleware
+async def handle_frontend_path(request: web.Request, handler: Coroutine) -> Response:
+ if request.path in ALL_ROUTES:
+ request['token'] = request.cookies.get('user-token')
+ request['user'] = None
+
+ if request['token']:
+ with request.app.database.session(False) as conn:
+ request['user'] = conn.get_user_by_token(request['token'])
+
+ if request['user'] and request.path == '/login':
+ return Response.new('', 302, {'Location': '/'})
+
+ if not request['user'] and request.path.startswith('/admin'):
+ return Response.new('', 302, {'Location': f'/login?redir={request.path}'})
+
+ return await handler(request)
+
+
# pylint: disable=unused-argument
@register_route('/')
@@ -25,12 +66,112 @@ class HomeView(View):
# targets = '
'.join(inbox['domain'] for inbox in inboxes)
# )
- data = self.template.render('page/home.haml', instances = instances)
+ data = self.template.render('page/home.haml', self, instances = instances)
return Response.new(data, ctype='html')
+@register_route('/login')
+class Login(View):
+ async def get(self, request: Request) -> Response:
+ data = self.template.render('page/login.haml', self)
+ 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 = True,
+ samesite = 'Strict'
+ )
+
+ return resp
+
+
+@register_route('/logout')
+class Logout(View):
+ async def get(self, request: Request) -> Response:
+ with self.database.session(True) as conn:
+ conn.del_token(request['token'])
+
+ resp = Response.new_redir('/')
+ resp.del_cookie('user-token', domain = self.config.domain, path = '/')
+ return resp
+
+
+@register_route('/admin')
+class Admin(View):
+ async def get(self, request: Request) -> Response:
+ return Response.new('', 302, {'Location': '/admin/instances'})
+
+
+@register_route('/admin/instances')
+class AdminInstances(View):
+ async def get(self, request: Request) -> Response:
+ data = self.template.render('page/admin-instances.haml', self)
+ return Response.new(data, ctype = 'html')
+
+
+@register_route('/admin/whitelist')
+class AdminWhitelist(View):
+ async def get(self, request: Request) -> Response:
+ data = self.template.render('page/admin-whitelist.haml', self)
+ return Response.new(data, ctype = 'html')
+
+
+@register_route('/admin/domain_bans')
+class AdminDomainBans(View):
+ async def get(self, request: Request) -> Response:
+ data = self.template.render('page/admin-domain_bans.haml', self)
+ return Response.new(data, ctype = 'html')
+
+
+@register_route('/admin/software_bans')
+class AdminSoftwareBans(View):
+ async def get(self, request: Request) -> Response:
+ data = self.template.render('page/admin-software_bans.haml', self)
+ return Response.new(data, ctype = 'html')
+
+
+@register_route('/admin/config')
+class AdminConfig(View):
+ async def get(self, request: Request) -> Response:
+ data = self.template.render('page/admin-config.haml', self)
+ 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')
+ data = self.template.render('style.css', self, page = request.query.getone('page', ""))
return Response.new(data, ctype = 'css')