153 lines
7.9 KiB
Python
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'}
|