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:
self.router.add_view(path, view)
self.add_routes([web.static('/static', get_resource('frontend/static'))])
setup_swagger(
self,
ui_version = 3,
@ -124,6 +126,40 @@ class Application(web.Application):
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:
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.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')):
# cache for 2 weeks
resp.headers['Cache-Control'] = 'public,max-age=1209600,immutable'

View file

@ -11,8 +11,10 @@
%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="/theme/{{config.theme}}.css")
%link(rel="stylesheet" type="text/css" href="/style.css")
%link(rel="stylesheet" type="text/css" href="/theme/{{config.theme}}.css" nonce="{{view.request['hash']}}")
%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
%body
@ -38,19 +40,18 @@
#container
#header.section
%span#menu-open << &#8286;
%span.title-container
%a.title(href="/") -> =config.name
-if view.request.path not in ["/", "/login"]
.page -> =page
%a.title(href="/") -> =config.name
.empty
-if error
.error.section -> =error
%fieldset.error.section
%legend << Error
=error
-if message
.message.section -> =message
%fieldset.message.section
%legend << Message
=message
#content(class="page-{{page.lower().replace(' ', '_')}}")
-block content
@ -69,26 +70,3 @@
.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) => {
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,24 +2,26 @@
-set page="Config"
-import "functions.haml" as func
-block content
%form.section(action="/admin/config" method="POST")
.grid-2col
%label(for="name") << Name
%input(id = "name" name="name" placeholder="Relay Name" value="{{config.name or ''}}")
%fieldset.section
%legend << Config
%form(action="/admin/config" method="POST")
.grid-2col
%label(for="name") << Name
%input(id = "name" name="name" placeholder="Relay Name" value="{{config.name or ''}}")
%label(for="description") << Description
%textarea(id="description" name="note" value="{{config.note or ''}}") << {{config.note}}
%label(for="description") << Description
%textarea(id="description" name="note" value="{{config.note or ''}}") << {{config.note}}
%label(for="theme") << Color Theme
=func.new_select("theme", config.theme, themes)
%label(for="theme") << Color Theme
=func.new_select("theme", config.theme, themes)
%label(for="log-level") << Log Level
=func.new_select("log-level", config.log_level.name, levels)
%label(for="log-level") << Log Level
=func.new_select("log-level", config.log_level.name, levels)
%label(for="whitelist-enabled") << Whitelist
=func.new_checkbox("whitelist-enabled", config.whitelist_enabled)
%label(for="whitelist-enabled") << Whitelist
=func.new_checkbox("whitelist-enabled", config.whitelist_enabled)
%label(for="approval-required") << Approval Required
=func.new_checkbox("approval-required", config.approval_required)
%label(for="approval-required") << Approval Required
=func.new_checkbox("approval-required", config.approval_required)
%input(type="submit" value="Save")
%input(type="submit" value="Save")

View file

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

View file

@ -20,56 +20,59 @@
%input(type="submit" value="Add Instance")
-if requests
.data-table.section
.title << Requests
%fieldset.section
%legend << Follow Requests
.data-table
%table
%thead
%tr
%td.instance << Instance
%td.software << Software
%td.date << Joined
%td.approve
%td.deny
%tbody
-for request in requests
%tr
%td.instance
%a(href="https://{{request.domain}}" target="_new") -> =request.domain
%td.software
=request.software or "n/a"
%td.date
=request.created.strftime("%Y-%m-%d")
%td.approve
%a(href="/admin/instances/approve/{{request.domain}}" title="Approve Request") << &check;
%td.deny
%a(href="/admin/instances/deny/{{request.domain}}" title="Deny Request") << &#10006;
%fieldset.section
%legend << Instances
.data-table
%table
%thead
%tr
%td.instance << Instance
%td.software << Software
%td.date << Joined
%td.approve
%td.deny
%td.remove
%tbody
-for request in requests
-for instance in instances
%tr
%td.instance
%a(href="https://{{request.domain}}" target="_new") -> =request.domain
%a(href="https://{{instance.domain}}/" target="_new") -> =instance.domain
%td.software
=request.software or "n/a"
=instance.software or "n/a"
%td.date
=request.created.strftime("%Y-%m-%d")
=instance.created.strftime("%Y-%m-%d")
%td.approve
%a(href="/admin/instances/approve/{{request.domain}}" title="Approve Request") << &check;
%td.deny
%a(href="/admin/instances/deny/{{request.domain}}" title="Deny Request") << &#10006;
.data-table.section
.title << Instances
%table
%thead
%tr
%td.instance << Instance
%td.software << Software
%td.date << Joined
%td.remove
%tbody
-for instance in instances
%tr
%td.instance
%a(href="https://{{instance.domain}}/" target="_new") -> =instance.domain
%td.software
=instance.software or "n/a"
%td.date
=instance.created.strftime("%Y-%m-%d")
%td.remove
%a(href="/admin/instances/delete/{{instance.domain}}" title="Remove Instance") << &#10006;
%td.remove
%a(href="/admin/instances/delete/{{instance.domain}}" title="Remove Instance") << &#10006;

View file

@ -16,33 +16,36 @@
%input(type="submit" value="Ban Software")
.data-table.section
%table
%thead
%tr
%td.name << Instance
%td << Date
%td.remove
%fieldset.section
%legend << Software Bans
%tbody
-for ban in bans
.data-table
%table
%thead
%tr
%td.name
%details
%summary -> =ban.name
%form(action="/admin/software_bans" method="POST")
.grid-2col
.reason << Reason
%textarea.reason(id="reason" name="reason") << {{ban.reason or ""}}
.note << Note
%textarea.note(id="note" name="note") << {{ban.note or ""}}
%input(type="hidden" name="name" value="{{ban.name}}")
%input(type="submit" value="Update")
%td.date
=ban.created.strftime("%Y-%m-%d")
%td.name << Name
%td << Date
%td.remove
%a(href="/admin/software_bans/delete/{{ban.name}}" title="Unban software") << &#10006;
%tbody
-for ban in bans
%tr
%td.name
%details
%summary -> =ban.name
%form(action="/admin/software_bans" method="POST")
.grid-2col
.reason << Reason
%textarea.reason(id="reason" name="reason") << {{ban.reason or ""}}
.note << Note
%textarea.note(id="note" name="note") << {{ban.note or ""}}
%input(type="hidden" name="name" value="{{ban.name}}")
%input(type="submit" value="Update")
%td.date
=ban.created.strftime("%Y-%m-%d")
%td.remove
%a(href="/admin/software_bans/delete/{{ban.name}}" title="Unban software") << &#10006;

View file

@ -19,26 +19,29 @@
%input(type="submit" value="Add User")
.data-table.section
%table
%thead
%tr
%td.username << Username
%td.handle << Handle
%td.date << Joined
%td.remove
%fieldset.section
%legend << Users
%tbody
-for user in users
.data-table
%table
%thead
%tr
%td.username
=user.username
%td.handle
=user.handle or "n/a"
%td.date
=user.created.strftime("%Y-%m-%d")
%td.username << Username
%td.handle << Handle
%td.date << Joined
%td.remove
%a(href="/admin/users/delete/{{user.username}}" title="Remove User") << &#10006;
%tbody
-for user in users
%tr
%td.username
=user.username
%td.handle
=user.handle or "n/a"
%td.date
=user.created.strftime("%Y-%m-%d")
%td.remove
%a(href="/admin/users/delete/{{user.username}}" title="Remove User") << &#10006;

View file

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

View file

@ -14,27 +14,34 @@
%a(href="https://{{domain}}/actor") << https://{{domain}}/actor</a>
-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
your request.
-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
joining.
.data-table.section
%table
%thead
%tr
%td.instance << Instance
%td.date << Joined
%fieldset.section
%legend << Instances
%tbody
-for instance in instances
.data-table
%table
%thead
%tr
%td.instance -> %a(href="https://{{instance.domain}}/" target="_new")
=instance.domain
%td.instance << Instance
%td.date << Joined
%td.date
=instance.created.strftime("%Y-%m-%d")
%tbody
-for instance in instances
%tr
%td.instance -> %a(href="https://{{instance.domain}}/" target="_new")
=instance.domain
%td.date
=instance.created.strftime("%Y-%m-%d")

View file

@ -1,12 +1,15 @@
-extends "base.haml"
-set page="Login"
-block content
%form.section(action="/login" method="POST")
.grid-2col
%label(for="username") << Username
%input(id="username" name="username" placeholder="Username" value="{{username or ''}}")
%fieldset.section
%legend << Login
%label(for="password") << Password
%input(id="password" name="password" placeholder="Password" type="password")
%form(action="/login" method="POST")
.grid-2col
%label(for="username") << Username
%input(id="username" name="username" placeholder="Username" value="{{username or ''}}")
%input(type="submit" value="Login")
%label(for="password") << Password
%input(id="password" name="password" placeholder="Password" type="password")
%input(type="submit" value="Login")

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;
}
fieldset {
margin-left: 0px;
margin-right: 0px;
}
fieldset > *:nth-child(2) {
margin-top: 0px !important;
}
form input[type="submit"] {
display: block;
margin: 0 auto;
}
legend {
background-color: var(--section-background);
background-color: var(--table-background);
padding: 5px;
border: 1px solid var(--border);
border-radius: 5px;
font-size: 10pt;
font-weight: bold;
}
p {

View file

@ -274,54 +274,6 @@ class DomainBan(View):
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')
class SoftwareBan(View):
async def get(self, request: Request) -> Response:

View file

@ -2,9 +2,11 @@ from __future__ import annotations
import typing
from Crypto.Random import get_random_bytes
from aiohttp.abc import AbstractView
from aiohttp.hdrs import METH_ALL as METHODS
from aiohttp.web import HTTPMethodNotAllowed
from base64 import b64encode
from functools import cached_property
from json.decoder import JSONDecodeError
@ -56,6 +58,7 @@ class View(AbstractView):
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)

View file

@ -26,21 +26,31 @@ UNAUTH_ROUTES = {
@web.middleware
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'):
request['token'] = request.cookies.get('user-token')
request['user'] = None
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'])
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}'})
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('/')
@ -96,8 +106,8 @@ class Login(View):
domain = self.config.domain,
path = '/',
secure = True,
httponly = True,
samesite = 'Strict'
httponly = False,
samesite = 'lax'
)
return resp
@ -271,7 +281,7 @@ class AdminWhitelist(View):
with self.database.session(True) as conn:
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'])
@ -284,7 +294,7 @@ class AdminWhitlistDelete(View):
with self.database.session() as conn:
if not conn.get_domain_whitelist(domain):
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)
@ -342,7 +352,7 @@ 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, message = 'Domain ban not found')
return await AdminDomainBans.run("GET", request, error = 'Domain ban not found')
conn.del_domain_ban(domain)
@ -400,7 +410,7 @@ 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, message = 'Software ban not found')
return await AdminSoftwareBans.run("GET", request, error = 'Software ban not found')
conn.del_software_ban(name)
@ -441,7 +451,7 @@ class AdminUsers(View):
with self.database.session(True) as conn:
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'])
@ -453,7 +463,7 @@ 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, message = 'User not found')
return await AdminUsers.run("GET", request, error = 'User not found')
conn.del_user(name)