add login/logout and start on admin interface

This commit is contained in:
Izalia Mae 2024-03-02 17:36:44 -05:00
parent a271cf22b4
commit 7af3b9c20b
20 changed files with 402 additions and 20 deletions

View file

@ -23,6 +23,7 @@ from .misc import check_open_port, get_resource
from .template import Template from .template import Template
from .views import VIEWS from .views import VIEWS
from .views.api import handle_api_path from .views.api import handle_api_path
from .views.frontend import handle_frontend_path
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from tinysql import Database, Row from tinysql import Database, Row
@ -38,7 +39,8 @@ class Application(web.Application):
def __init__(self, cfgpath: str | None): def __init__(self, cfgpath: str | None):
web.Application.__init__(self, web.Application.__init__(self,
middlewares = [ middlewares = [
handle_api_path handle_api_path,
handle_frontend_path
] ]
) )

View file

@ -22,7 +22,10 @@ THEMES = {
'border': '#444', 'border': '#444',
'message-text': '#DDD', 'message-text': '#DDD',
'message-background': '#335', 'message-background': '#335',
'message-border': '#446' 'message-border': '#446',
'error-text': '#DDD',
'error-background': '#533',
'error-border': '#644'
}, },
'pink': { 'pink': {
'text': '#DDD', 'text': '#DDD',
@ -34,7 +37,10 @@ THEMES = {
'border': '#444', 'border': '#444',
'message-text': '#DDD', 'message-text': '#DDD',
'message-background': '#335', 'message-background': '#335',
'message-border': '#446' 'message-border': '#446',
'error-text': '#DDD',
'error-background': '#533',
'error-border': '#644'
}, },
'blue': { 'blue': {
'text': '#DDD', 'text': '#DDD',
@ -46,7 +52,10 @@ THEMES = {
'border': '#444', 'border': '#444',
'message-text': '#DDD', 'message-text': '#DDD',
'message-background': '#335', 'message-background': '#335',
'message-border': '#446' 'message-border': '#446',
'error-text': '#DDD',
'error-background': '#533',
'error-border': '#644'
} }
} }

View file

@ -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 %html
%head %head
%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="/style.css") %link(rel="stylesheet" type="text/css" href="/style.css?page={{page}}")
-block head -block head
%body %body
#menu.section(visible="false")
.menu-head
%span.menu-title << Menu
%span#menu-close.button << &#10006;
{{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 #container
#header.section #header.section
%span#menu-open.button << &#8286;
%a(href="https://{{domain}}/") -> =config.name %a(href="https://{{domain}}/") -> =config.name
.empty
-if error
.error.section -> =error
-if message
.message.section -> =message
#content #content
-block content -block content
#footer.section #footer.section
.col1 .col1
-if not view.request["user"]
%a(href="/login") << Login
-else
=view.request["user"]["username"]
(
%a(href="/logout") << Logout
)
.version .version
%a(href="https://git.pleroma.social/pleroma/relay") %a(href="https://git.pleroma.social/pleroma/relay")
ActivityRelay/{{version}} 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";
});

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,5 @@
-extends "base.haml"
-set page="Config"
-block content
.section
UvU

View file

@ -0,0 +1,5 @@
-extends "base.haml"
-set page="Domain Bans"
-block content
.section
UvU

View file

@ -0,0 +1,5 @@
-extends "base.haml"
-set page="Instances"
-block content
.section
UvU

View file

@ -0,0 +1,5 @@
-extends "base.haml"
-set page="Software Bans"
-block content
.section
UvU

View file

@ -0,0 +1,5 @@
-extends "base.haml"
-set page="Whitelist"
-block content
.section
UvU

View file

@ -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")

View file

@ -9,6 +9,9 @@
--message-text: {{theme["message-text"]}}; --message-text: {{theme["message-text"]}};
--message-background: {{theme["message-background"]}}; --message-background: {{theme["message-background"]}};
--message-border: {{theme["message-border"]}}; --message-border: {{theme["message-border"]}};
--error-text: {{theme["error-text"]}};
--error-background: {{theme["error-background"]}};
--error-border: {{theme["error-border"]}};
--spacing: 10px; --spacing: 10px;
} }
@ -84,31 +87,76 @@ table tbody td {
#container { #container {
width: 1024px; width: 1024px;
margin: 0px auto; margin: 0px auto;
height: 100vh;
} }
#header { #header {
text-align: center; display: grid;
grid-template-columns: 50px auto 50px;
justify-items: center;
align-items: center;
} }
#header a { #header > * {
font-size: 3em; font-size: 2em;
} }
#instances table { #header > *:nth-child(2) {
width: 100%; font-weight: bold;
} }
#instances .instance { #menu {
width: 100%; padding: 0px;
} position: fixed;
top: 0px;
#instances .date { left: 0px;
margin: 0px;
height: 100%;
width: max-content; width: max-content;
text-align: right; z-index: 1;
font-size: 1.5em;
min-width: 300px;
} }
#instances thead td { #menu[visible="false"] {
text-align: center !important; 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 { #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 { .message {
color: var(--message-text) !important; color: var(--message-text) !important;
background-color: var(--message-background) !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) { @media (max-width: 1026px) {
body { body {
margin: 0px; margin: 0px;
} }
#menu {
width: 100%;
}
#menu > a {
text-align: center;
}
#container { #container {
width: unset; width: unset;
margin: unset; margin: unset;
} }
.container {
grid-template-columns: auto;
}
.content {
grid-template-columns: auto;
}
.section { .section {
border-width: 0px; border-width: 0px;
border-radius: 0px; border-radius: 0px;

View file

View file

View file

@ -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;
}

View file

View file

@ -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%;
}
}

View file

View file

View file

@ -225,6 +225,12 @@ class Response(AiohttpResponse):
return cls.new(body=body, status=status, ctype=ctype) return cls.new(body=body, status=status, ctype=ctype)
@classmethod
def new_redir(cls: type[Response], path: str) -> Response:
body = f'Redirect to <a href="{path}">{path}</a>'
return cls.new(body, 302, {'Location': path})
@property @property
def location(self) -> str: def location(self) -> str:
return self.headers.get('Location') return self.headers.get('Location')

View file

@ -2,6 +2,9 @@ from __future__ import annotations
import typing import typing
from aiohttp import web
from argon2.exceptions import VerifyMismatchError
from .base import View, register_route from .base import View, register_route
from ..misc import Response from ..misc import Response
@ -10,6 +13,44 @@ if typing.TYPE_CHECKING:
from aiohttp.web import Request 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 # pylint: disable=unused-argument
@register_route('/') @register_route('/')
@ -25,12 +66,112 @@ class HomeView(View):
# targets = '<br>'.join(inbox['domain'] for inbox in inboxes) # targets = '<br>'.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') 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') @register_route('/style.css')
class StyleCss(View): class StyleCss(View):
async def get(self, request: Request) -> Response: 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') return Response.new(data, ctype = 'css')