"""/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/ the site PUT /sites/ change any of the settings of POST but name; an empty or absent private_key keeps the key DELETE /sites/ 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": ""} 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": ""}'} 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//files/ a folder: {"path", "entries": [{name, folder, size, modified}]}; a file: the file itself, as an attachment PUT /sites//files/ 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//files/ {"folder": true} creates the folder, {"rename": ""} moves it DELETE /sites//files/ 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'}