minor frontend tweaks and use javascript for managing domain bans

This commit is contained in:
Izalia Mae 2024-03-14 20:58:16 -04:00
parent 49917fcc4e
commit 10ba039938
17 changed files with 461 additions and 245 deletions

View file

@ -68,6 +68,8 @@ 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,
@ -124,6 +126,40 @@ class Application(web.Application):
return timedelta(seconds=uptime.seconds) return timedelta(seconds=uptime.seconds)
def get_csp(self, request: Request) -> str:
data = [
"default-src 'none'",
f"script-src 'nonce-{request['hash']}'",
f"style-src 'nonce-{request['hash']}'",
"form-action 'self'",
"connect-src 'self'",
"img-src 'self'",
"object-src 'none'",
"frame-ancestors 'none'"
]
return '; '.join(data) + ';'
# data = {
# 'base-uri': '\'none\'',
# 'default-src': '\'none\'',
# 'frame-ancestors': '\'none\'',
# 'font-src': f'\'self\' https://{self.config.domain}',
# 'img-src': f'\'self\' https://{self.config.domain}',
# 'style-src': f'\'self\' https://{self.config.domain} \'nonce-randomstringhere\'',
# 'media-src': f'\'self\' https://{self.config.domain}',
# 'frame-src': f'\'self\' https:',
# 'manifest-src': f'\'self\' https://{self.config.domain}',
# 'form-action': f'\'self\'',
# 'child-src': f'\'self\' https://{self.config.domain}',
# 'worker-src': f'\'self\' https://{self.config.domain}',
# 'connect-src': f'\'self\' https://{self.config.domain} wss://{self.config.domain}',
# 'script-src': f'\'self\' https://{self.config.domain}'
# }
#
# return '; '.join(f'{key} {value}' for key, value in data.items()) + ';'
def push_message(self, inbox: str, message: Message, instance: Row) -> None: def push_message(self, inbox: str, message: Message, instance: Row) -> None:
self['push_queue'].put((inbox, message, instance)) self['push_queue'].put((inbox, message, instance))
@ -269,6 +305,9 @@ async def handle_response_headers(request: web.Request, handler: Callable) -> Re
resp = await handler(request) resp = await handler(request)
resp.headers['Server'] = 'ActivityRelay' resp.headers['Server'] = 'ActivityRelay'
# if resp.content_type == 'text/html':
# 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
resp.headers['Cache-Control'] = 'public,max-age=1209600,immutable' resp.headers['Cache-Control'] = 'public,max-age=1209600,immutable'

View file

@ -11,8 +11,10 @@
%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") %link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css" nonce="{{view.request['hash']}}")
%link(rel="stylesheet" type="text/css" href="/style.css") %link(rel="stylesheet" type="text/css" href="/static/style.css" nonce="{{view.request['hash']}}")
%script(type="application/javascript" src="/static/menu.js" nonce="{{view.request['hash']}}", defer)
%script(type="application/javascript" src="/static/api.js" nonce="{{view.request['hash']}}", defer)
-block head -block head
%body %body
@ -38,19 +40,18 @@
#container #container
#header.section #header.section
%span#menu-open << &#8286; %span#menu-open << &#8286;
%span.title-container
%a.title(href="/") -> =config.name %a.title(href="/") -> =config.name
-if view.request.path not in ["/", "/login"]
.page -> =page
.empty .empty
-if error -if error
.error.section -> =error %fieldset.error.section
%legend << Error
=error
-if message -if message
.message.section -> =message %fieldset.message.section
%legend << Message
=message
#content(class="page-{{page.lower().replace(' ', '_')}}") #content(class="page-{{page.lower().replace(' ', '_')}}")
-block content -block content
@ -69,26 +70,3 @@
.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) => {
var new_value = menu.attributes.visible.nodeValue === "true" ? "false" : "true";
menu.attributes.visible.nodeValue = new_value;
});
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

@ -2,7 +2,9 @@
-set page="Config" -set page="Config"
-import "functions.haml" as func -import "functions.haml" as func
-block content -block content
%form.section(action="/admin/config" method="POST") %fieldset.section
%legend << Config
%form(action="/admin/config" method="POST")
.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" name="name" placeholder="Relay Name" value="{{config.name or ''}}")

View file

@ -1,48 +1,53 @@
-extends "base.haml" -extends "base.haml"
-set page="Domain Bans" -set page="Domain Bans"
-block head
%script(type="application/javascript" src="/static/domain_ban.js" nonce="{{view.request['hash']}}", defer)
-block content -block content
%details.section %details.section
%summary << Ban Domain %summary << Ban Domain
%form(action="/admin/domain_bans" method="POST")
#add-item #add-item
%label(for="domain") << Domain %label(for="new-domain") << Domain
%input(type="domain" id="domain" name="domain" placeholder="Domain") %input(type="domain" id="new-domain" name="domain" placeholder="Domain")
%label(for="reason") << Ban Reason %label(for="new-reason") << Ban Reason
%textarea(id="reason" name="reason") << {{""}} %textarea(id="new-reason" name="new") << {{""}}
%label(for="note") << Admin Note %label(for="new-note") << Admin Note
%textarea(id="note" name="note") << {{""}} %textarea(id="new-note" name="note") << {{""}}
%input(type="submit" value="Ban Domain") %input(type="button" value="Ban Domain" onclick="ban();")
.data-table.section %fieldset.section
%table %legend << Domain Bans
.data-table
%table#table
%thead %thead
%tr %tr
%td.domain << Instance %td.domain << Domain
%td << Date %td << Date
%td.remove %td.remove
%tbody %tbody
-for ban in bans -for ban in bans
%tr %tr(id="{{ban.domain}}")
%td.domain %td.domain
%details %details
%summary -> =ban.domain %summary -> =ban.domain
%form(action="/admin/domain_bans" method="POST")
.grid-2col .grid-2col
.reason << Reason .reason << Reason
%textarea.reason(id="reason" name="reason") << {{ban.reason or ""}} %textarea.reason(id="{{ban.domain}}-reason" name="reason") << {{ban.reason or ""}}
.note << Note .note << Note
%textarea.note(id="note" name="note") << {{ban.note or ""}} %textarea.note(id="{{ban.domain}}-note" name="note") << {{ban.note or ""}}
%input(type="hidden" name="domain" value="{{ban.domain}}") %input(type="button" value="Update" onclick="update_ban('{{ban.domain}}')")
%input(type="submit" value="Update")
%td.date %td.date
=ban.created.strftime("%Y-%m-%d") =ban.created.strftime("%Y-%m-%d")
%td.remove %td.remove
%a(href="/admin/domain_bans/delete/{{ban.domain}}" title="Unban domain") << &#10006; %a(href="#", onclick="unban('{{ban.domain}}')" title="Unban domain") << &#10006;

View file

@ -20,8 +20,9 @@
%input(type="submit" value="Add Instance") %input(type="submit" value="Add Instance")
-if requests -if requests
.data-table.section %fieldset.section
.title << Requests %legend << Follow Requests
.data-table
%table %table
%thead %thead
%tr %tr
@ -49,8 +50,10 @@
%td.deny %td.deny
%a(href="/admin/instances/deny/{{request.domain}}" title="Deny Request") << &#10006; %a(href="/admin/instances/deny/{{request.domain}}" title="Deny Request") << &#10006;
.data-table.section %fieldset.section
.title << Instances %legend << Instances
.data-table
%table %table
%thead %thead
%tr %tr

View file

@ -16,11 +16,14 @@
%input(type="submit" value="Ban Software") %input(type="submit" value="Ban Software")
.data-table.section %fieldset.section
%legend << Software Bans
.data-table
%table %table
%thead %thead
%tr %tr
%td.name << Instance %td.name << Name
%td << Date %td << Date
%td.remove %td.remove

View file

@ -19,7 +19,10 @@
%input(type="submit" value="Add User") %input(type="submit" value="Add User")
.data-table.section %fieldset.section
%legend << Users
.data-table
%table %table
%thead %thead
%tr %tr

View file

@ -10,7 +10,9 @@
%input(type="submit" value="Add Domain") %input(type="submit" value="Add Domain")
.data-table.section %fieldset.data-table.section
%legend << Whitelist
%table %table
%thead %thead
%tr %tr

View file

@ -14,16 +14,23 @@
%a(href="https://{{domain}}/actor") << https://{{domain}}/actor</a> %a(href="https://{{domain}}/actor") << https://{{domain}}/actor</a>
-if config.approval_required -if config.approval_required
%p.section.message %fieldset.section.message
%legend << Require Approval
Follow requests require approval. You will need to wait for an admin to accept or deny Follow requests require approval. You will need to wait for an admin to accept or deny
your request. your request.
-elif config.whitelist_enabled -elif config.whitelist_enabled
%p.section.message %fieldset.section.message
%legend << Whitelist Enabled
The whitelist is enabled on this instance. Ask the admin to add your instance before The whitelist is enabled on this instance. Ask the admin to add your instance before
joining. joining.
.data-table.section %fieldset.section
%legend << Instances
.data-table
%table %table
%thead %thead
%tr %tr

View file

@ -1,7 +1,10 @@
-extends "base.haml" -extends "base.haml"
-set page="Login" -set page="Login"
-block content -block content
%form.section(action="/login" method="POST") %fieldset.section
%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 ''}}")

View file

@ -0,0 +1,90 @@
function get_cookie(name) {
const regex = new RegExp(`(^| )` + name + `=([^;]+)`);
const match = document.cookie.match(regex);
if (match) {
return match[2]
}
return null;
}
function get_date_string(date) {
var year = date.getFullYear().toString();
var month = date.getMonth().toString();
var day = date.getDay().toString();
if (month.length === 1) {
month = "0" + month;
}
if (day.length === 1) {
day = "0" + day
}
return `${year}-${month}-${day}`;
}
class Client {
constructor() {
this.token = get_cookie("user-token");
}
async request(method, path, body = null) {
var headers = {
"Accept": "application/json"
}
if (body !== null) {
headers["Content-Type"] = "application/json"
body = JSON.stringify(body)
}
if (this.token !== null) {
headers["Authorization"] = "Bearer " + this.token;
}
const response = await fetch("/api/" + path, {
method: method,
mode: "cors",
cache: "no-store",
redirect: "follow",
body: body,
headers: headers
});
const message = await response.json();
if (Object.hasOwn(message, "error")) {
throw new Error(message.error);
}
if (Object.hasOwn(message, "created")) {
message.created = new Date(message.created);
}
return message;
}
async ban(domain, reason, note) {
const params = {
"domain": domain,
"reason": reason,
"note": note
}
return await this.request("POST", "v1/domain_ban", params);
}
async unban(domain) {
const params = {"domain": domain}
return await this.request("DELETE", "v1/domain_ban", params);
}
}
client = new Client();

View file

@ -0,0 +1,85 @@
function create_ban_object(domain, reason, note) {
var text = '<details>\n';
text += `<summary>${domain}</summary>\n`;
text += '<div class="grid-2col">\n';
text += `<label for="${domain}-reason" class="reason">Reason</label>\n`;
text += `<textarea id="${domain}-reason" class="reason" name="reason">${reason}</textarea>\n`;
text += `<label for="${domain}-note" class="note">Note</label>\n`;
text += `<textarea id="${domain}-note" class="note" name="note">${note}</textarea>\n`;
text += `<input type="button" value="Update" onclick="update_ban(\"${domain}\"")">`;
text += '</details>';
return text;
}
async function ban() {
var table = document.getElementById("table");
var row = table.insertRow(-1);
var elems = {
domain: document.getElementById("new-domain"),
reason: document.getElementById("new-reason"),
note: document.getElementById("new-note")
}
var values = {
domain: elems.domain.value.trim(),
reason: elems.reason.value,
note: elems.note.value
}
if (values.domain === "") {
alert("Domain is required");
return;
}
try {
var ban = await client.ban(values.domain, values.reason, values.note);
} catch (err) {
alert(err);
return
}
row.id = ban.domain;
var new_domain = row.insertCell(0);
var new_date = row.insertCell(1);
var new_remove = row.insertCell(2);
new_domain.className = "domain";
new_date.className = "date";
new_remove.className = "remove";
new_domain.innerHTML = create_ban_object(ban.domain, ban.reason, ban.note);
new_date.innerHTML = get_date_string(ban.created);
new_remove.innerHTML = `<a href="#" onclick="unban('${ban.domain}')" title="Unban domain">&#10006;</a>`;
elems.domain.value = null;
elems.reason.value = null;
elems.note.value = null;
document.querySelectorAll("details.section").forEach((elem) => {
elem.open = false;
});
}
async function update_ban(domain) {
var row = document.getElementById(domain);
}
async function unban(domain) {
console.log(domain);
try {
await client.unban(domain);
} catch (err) {
alert(err);
return;
}
document.getElementById(domain).remove();
}

View file

@ -0,0 +1,21 @@
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) => {
var new_value = menu.attributes.visible.nodeValue === "true" ? "false" : "true";
menu.attributes.visible.nodeValue = new_value;
});
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

@ -23,17 +23,27 @@ details summary {
cursor: pointer; cursor: pointer;
} }
fieldset {
margin-left: 0px;
margin-right: 0px;
}
fieldset > *:nth-child(2) {
margin-top: 0px !important;
}
form input[type="submit"] { form input[type="submit"] {
display: block; display: block;
margin: 0 auto; margin: 0 auto;
} }
legend { legend {
background-color: var(--section-background); background-color: var(--table-background);
padding: 5px; padding: 5px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 5px; border-radius: 5px;
font-size: 10pt; font-size: 10pt;
font-weight: bold;
} }
p { p {

View file

@ -274,54 +274,6 @@ class DomainBan(View):
return Response.new(bans, ctype = 'json') return Response.new(bans, ctype = 'json')
async def post(self, request: Request) -> Response:
data = await self.get_api_data(['domain'], ['note', 'reason'])
if isinstance(data, Response):
return data
with self.database.session() as conn:
if conn.get_domain_ban(data['domain']):
return Response.new_error(400, 'Domain already banned', 'json')
ban = conn.put_domain_ban(**data)
return Response.new(ban, ctype = 'json')
async def patch(self, request: Request) -> Response:
with self.database.session() as conn:
data = await self.get_api_data(['domain'], ['note', 'reason'])
if isinstance(data, Response):
return data
if not conn.get_domain_ban(data['domain']):
return Response.new_error(404, 'Domain not banned', 'json')
if not any([data.get('note'), data.get('reason')]):
return Response.new_error(400, 'Must include note and/or reason parameters', 'json')
ban = conn.update_domain_ban(data['domain'], **data)
return Response.new(ban, ctype = 'json')
async def delete(self, request: Request) -> Response:
with self.database.session() as conn:
data = await self.get_api_data(['domain'], [])
if isinstance(data, Response):
return data
if not conn.get_domain_ban(data['domain']):
return Response.new_error(404, 'Domain not banned', 'json')
conn.del_domain_ban(data['domain'])
return Response.new({'message': 'Unbanned domain'}, ctype = 'json')
@register_route('/api/v1/software_ban') @register_route('/api/v1/software_ban')
class SoftwareBan(View): class SoftwareBan(View):
async def get(self, request: Request) -> Response: async def get(self, request: Request) -> Response:

View file

@ -2,9 +2,11 @@ from __future__ import annotations
import typing import typing
from Crypto.Random import get_random_bytes
from aiohttp.abc import AbstractView from aiohttp.abc import AbstractView
from aiohttp.hdrs import METH_ALL as METHODS from aiohttp.hdrs import METH_ALL as METHODS
from aiohttp.web import HTTPMethodNotAllowed from aiohttp.web import HTTPMethodNotAllowed
from base64 import b64encode
from functools import cached_property from functools import cached_property
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
@ -56,6 +58,7 @@ class View(AbstractView):
async def _run_handler(self, handler: Callable[..., Any], **kwargs: Any) -> Response: async def _run_handler(self, handler: Callable[..., Any], **kwargs: Any) -> Response:
self.request['hash'] = b64encode(get_random_bytes(16)).decode('ascii')
return await handler(self.request, **self.request.match_info, **kwargs) return await handler(self.request, **self.request.match_info, **kwargs)

View file

@ -26,21 +26,31 @@ UNAUTH_ROUTES = {
@web.middleware @web.middleware
async def handle_frontend_path(request: web.Request, handler: Callable) -> Response: async def handle_frontend_path(request: web.Request, handler: Callable) -> Response:
app = get_app()
if request.path in UNAUTH_ROUTES or request.path.startswith('/admin'): if request.path in UNAUTH_ROUTES or request.path.startswith('/admin'):
request['token'] = request.cookies.get('user-token') request['token'] = request.cookies.get('user-token')
request['user'] = None request['user'] = None
if request['token']: if request['token']:
with get_app().database.session(False) as conn: with app.database.session(False) as conn:
request['user'] = conn.get_user_by_token(request['token']) request['user'] = conn.get_user_by_token(request['token'])
if request['user'] and request.path == '/login': if request['user'] and request.path == '/login':
return Response.new('', 302, {'Location': '/'}) return Response.new('', 302, {'Location': '/'})
if not request['user'] and request.path.startswith('/admin'): if not request['user'] and request.path.startswith('/admin'):
return Response.new('', 302, {'Location': f'/login?redir={request.path}'}) response = Response.new('', 302, {'Location': f'/login?redir={request.path}'})
response.del_cookie('user-token')
return response
return await handler(request) response = await handler(request)
if not request['user'] and request['token']:
print("del token")
response.del_cookie('user-token')
return response
@register_route('/') @register_route('/')
@ -96,8 +106,8 @@ class Login(View):
domain = self.config.domain, domain = self.config.domain,
path = '/', path = '/',
secure = True, secure = True,
httponly = True, httponly = False,
samesite = 'Strict' samesite = 'lax'
) )
return resp return resp
@ -271,7 +281,7 @@ class AdminWhitelist(View):
with self.database.session(True) as conn: with self.database.session(True) as conn:
if conn.get_domain_whitelist(data['domain']): if conn.get_domain_whitelist(data['domain']):
return await self.get(request, message = "Domain already in whitelist") return await self.get(request, error = "Domain already in whitelist")
conn.put_domain_whitelist(data['domain']) conn.put_domain_whitelist(data['domain'])
@ -284,7 +294,7 @@ class AdminWhitlistDelete(View):
with self.database.session() as conn: with self.database.session() as conn:
if not conn.get_domain_whitelist(domain): if not conn.get_domain_whitelist(domain):
msg = 'Whitelisted domain not found' msg = 'Whitelisted domain not found'
return await AdminWhitelist.run("GET", request, message = msg) return await AdminWhitelist.run("GET", request, error = msg)
conn.del_domain_whitelist(domain) conn.del_domain_whitelist(domain)
@ -342,7 +352,7 @@ class AdminDomainBansDelete(View):
async def get(self, request: Request, domain: str) -> Response: async def get(self, request: Request, domain: str) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
if not conn.get_domain_ban(domain): if not conn.get_domain_ban(domain):
return await AdminDomainBans.run("GET", request, message = 'Domain ban not found') return await AdminDomainBans.run("GET", request, error = 'Domain ban not found')
conn.del_domain_ban(domain) conn.del_domain_ban(domain)
@ -400,7 +410,7 @@ class AdminSoftwareBansDelete(View):
async def get(self, request: Request, name: str) -> Response: async def get(self, request: Request, name: str) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
if not conn.get_software_ban(name): if not conn.get_software_ban(name):
return await AdminSoftwareBans.run("GET", request, message = 'Software ban not found') return await AdminSoftwareBans.run("GET", request, error = 'Software ban not found')
conn.del_software_ban(name) conn.del_software_ban(name)
@ -441,7 +451,7 @@ class AdminUsers(View):
with self.database.session(True) as conn: with self.database.session(True) as conn:
if conn.get_user(data['username']): if conn.get_user(data['username']):
return await self.get(request, message = "User already exists") return await self.get(request, error = "User already exists")
conn.put_user(data['username'], data['password'], data['handle']) conn.put_user(data['username'], data['password'], data['handle'])
@ -453,7 +463,7 @@ class AdminUsersDelete(View):
async def get(self, request: Request, name: str) -> Response: async def get(self, request: Request, name: str) -> Response:
with self.database.session() as conn: with self.database.session() as conn:
if not conn.get_user(name): if not conn.get_user(name):
return await AdminUsers.run("GET", request, message = 'User not found') return await AdminUsers.run("GET", request, error = 'User not found')
conn.del_user(name) conn.del_user(name)