Files
linux-cloud-router/debian-package/src/usr/lib/vpn-router/vpnrouter_web/sites.py
T

153 lines
7.9 KiB
Python

"""/sites: the settings of the web sites Caddy serves, the sites, and the files of each (site_files)."""
import mimetypes
import os
import re
import shutil
import urllib.parse
import ldap
import vpnrouter
import vpnrouter_sites
from vpnrouter_status import run
from .common import MIB, SITE_STATUS, section_change
#: A path of a site's files: the site and the path in its folder.
SITE_FILES = re.compile(r'^/sites/([^/]+)/files(?:/(.*))?$')
def sites(method, path, query, body):
"""Answer a /sites request but one of a site's files with vpnrouter_sites: the HTTP status and the JSON document.
GET /sites {"settings", "sites"}: the sites section of the configuration and the sites, each with its
settings but private_key, with private_key_set, modified and its folder
PUT /sites change any of the settings of the sites section
POST /sites add a site: {"name", and any of "enabled", "hosts", "port", "listen", "tls", "ca",
"certificate", "private_key", "index_file", "spa", "editors"}; an existing name is 409
GET /sites/<name> the site
PUT /sites/<name> change any of the settings of POST but name; an empty or absent private_key keeps the key
DELETE /sites/<name> remove the site, with content=true also its folder
A change is applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
route = (method, len(parts))
try:
cfg = vpnrouter.load_config()
if route == ('GET', 1):
return 200, {'settings': cfg['sites'], 'sites': vpnrouter_sites.listing()}
if route == ('PUT', 1):
changed = section_change('sites', body)
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
if route == ('POST', 1):
name = body.get('name')
document = vpnrouter_sites.save(cfg, name, {key: value for key, value in body.items() if key != 'name'},
True)
code = 201
elif route == ('GET', 2):
with vpnrouter_sites.database() as connection:
return 200, vpnrouter_sites.shown(vpnrouter_sites.get(connection, parts[1]))
elif route == ('PUT', 2):
document, code = vpnrouter_sites.save(cfg, parts[1], body, False), 200
elif route == ('DELETE', 2):
document = vpnrouter_sites.remove(parts[1], query.get('content', [''])[0] == 'true')
code = 200
else:
return 404, {'error': f'no route {method} {path}'}
except vpnrouter_sites.Failure as failure:
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
except vpnrouter_sites.DatabaseError as exc:
return 500, {'error': str(exc)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the site cannot be changed: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return code, document
def site_file_action(name, target, body):
"""Answer a POST to a site's file or folder: {"folder": true} creates the folder, {"rename": "<path>"} moves the file
or folder to the path in the site's folder; the HTTP status and the JSON document."""
if not isinstance(body, dict):
return 400, {'error': 'the body must be a JSON object'}
try:
if body.get('folder') is True:
vpnrouter_sites.make_folder(target)
return 201, {'path': vpnrouter_sites.relative_of(name, target), 'folder': True}
if isinstance(body.get('rename'), str):
moved = vpnrouter_sites.rename(name, target, body['rename'])
return 200, {'path': vpnrouter_sites.relative_of(name, moved)}
except vpnrouter_sites.Failure as failure:
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
except OSError as exc:
return 500, {'error': f'{exc.filename or target.name}: {exc.strerror}'}
return 400, {'error': 'give {"folder": true} or {"rename": "<path>"}'}
def site_files(handler, name, relative, query, payload, send_body):
"""Answer a request of a site's files the Handler handler received: the HTTP status and the JSON document, or None
once a file was sent.
GET /sites/<name>/files/<path> a folder: {"path", "entries": [{name, folder, size, modified}]}; a file:
the file itself, as an attachment
PUT /sites/<name>/files/<path> write the body to the file, its folders created; with unzip=true the body
is a zip archive extracted into the folder of the path: {"path", "files"}
POST /sites/<name>/files/<path> {"folder": true} creates the folder, {"rename": "<path>"} moves it
DELETE /sites/<name>/files/<path> remove the file or folder
The body of a PUT needs a Content-Length of at most sites.upload_max_mb MiB.
"""
try:
target = vpnrouter_sites.resolve(name, urllib.parse.unquote(relative))
if handler.command in ('GET', 'HEAD'):
if target.is_dir():
return 200, vpnrouter_sites.entries(name, target)
if not target.is_file():
return 404, {'error': f'{relative} does not exist'}
with open(target, 'rb') as handle:
size = os.fstat(handle.fileno()).st_size
try:
handler.send_response(200)
handler.send_header('Content-Type', mimetypes.guess_type(target.name)[0] or 'application/octet-stream')
handler.send_header('Content-Length', str(size))
handler.send_header('Content-Disposition',
f"attachment; filename*=UTF-8''{urllib.parse.quote(target.name)}")
handler.send_header('Cache-Control', 'no-store')
handler.end_headers()
if send_body:
shutil.copyfileobj(handle, handler.wfile, vpnrouter_sites.CHUNK)
except ConnectionError:
# The client went away before the file was sent.
handler.close_connection = True
return None
if handler.command == 'PUT':
length = handler.headers.get('Content-Length')
if not length or not length.isdigit():
return 411, {'error': 'the body needs a Content-Length'}
limit = vpnrouter.load_config()['sites']['upload_max_mb'] * MIB
if query.get('unzip', [''])[0] == 'true':
count = vpnrouter_sites.extract(handler.rfile, int(length), target, limit)
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'files': count}
vpnrouter_sites.receive(handler.rfile, int(length), target, limit)
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'files': 1}
if handler.command == 'POST':
return site_file_action(name, target, payload)
if handler.command == 'DELETE':
if not target.exists() and not target.is_symlink():
return 404, {'error': f'{relative} does not exist'}
vpnrouter_sites.delete(name, target)
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'removed': True}
except vpnrouter_sites.Failure as failure:
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
except OSError as exc:
return 500, {'error': f'{exc.filename or relative}: {exc.strerror}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
return 405, {'error': f'{handler.command} is not allowed on a site\'s files'}