diff --git a/app/__init__.py b/app/__init__.py index b010902..7befa1d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,8 @@ from __future__ import annotations +import html as html_module import logging +import mimetypes import shutil import sys import time @@ -10,7 +12,7 @@ from pathlib import Path from datetime import timedelta from typing import Any, Dict, List, Optional -from flask import Flask, g, has_request_context, redirect, render_template, request, url_for +from flask import Flask, Response, g, has_request_context, redirect, render_template, request, url_for from flask_cors import CORS from flask_wtf.csrf import CSRFError from werkzeug.middleware.proxy_fix import ProxyFix @@ -32,8 +34,9 @@ from .object_lock import ObjectLockService from .replication import ReplicationManager from .secret_store import EphemeralSecretStore from .site_registry import SiteRegistry, SiteInfo -from .storage import ObjectStorage +from .storage import ObjectStorage, StorageError from .version import get_version +from .website_domains import WebsiteDomainStore def _migrate_config_file(active_path: Path, legacy_paths: List[Path]) -> Path: @@ -223,6 +226,12 @@ def create_app( app.extensions["access_logging"] = access_logging_service app.extensions["site_registry"] = site_registry + website_domains_store = None + if app.config.get("WEBSITE_HOSTING_ENABLED", False): + website_domains_path = config_dir / "website_domains.json" + website_domains_store = WebsiteDomainStore(website_domains_path) + app.extensions["website_domains"] = website_domains_store + from .s3_client import S3ProxyClient api_base = app.config.get("API_BASE_URL") or "http://127.0.0.1:5000" app.extensions["s3_proxy"] = S3ProxyClient( @@ -472,6 +481,128 @@ def _configure_logging(app: Flask) -> None: extra={"path": request.path, "method": request.method, "remote_addr": request.remote_addr}, ) + @app.before_request + def _maybe_serve_website(): + if not app.config.get("WEBSITE_HOSTING_ENABLED"): + return None + if request.method not in {"GET", "HEAD"}: + return None + host = request.host + if ":" in host: + host = host.rsplit(":", 1)[0] + host = host.lower() + store = app.extensions.get("website_domains") + if not store: + return None + bucket = store.get_bucket(host) + if not bucket: + return None + storage = app.extensions["object_storage"] + if not storage.bucket_exists(bucket): + return _website_error_response(404, "Not Found") + website_config = storage.get_bucket_website(bucket) + if not website_config: + return _website_error_response(404, "Not Found") + index_doc = website_config.get("index_document", "index.html") + error_doc = website_config.get("error_document") + req_path = request.path.lstrip("/") + if not req_path or req_path.endswith("/"): + object_key = req_path + index_doc + else: + object_key = req_path + try: + obj_path = storage.get_object_path(bucket, object_key) + except (StorageError, OSError): + if object_key == req_path: + try: + obj_path = storage.get_object_path(bucket, req_path + "/" + index_doc) + object_key = req_path + "/" + index_doc + except (StorageError, OSError): + return _serve_website_error(storage, bucket, error_doc, 404) + else: + return _serve_website_error(storage, bucket, error_doc, 404) + content_type = mimetypes.guess_type(object_key)[0] or "application/octet-stream" + is_encrypted = False + try: + metadata = storage.get_object_metadata(bucket, object_key) + is_encrypted = "x-amz-server-side-encryption" in metadata + except (StorageError, OSError): + pass + if request.method == "HEAD": + response = Response(status=200) + if is_encrypted and hasattr(storage, "get_object_data"): + try: + data, _ = storage.get_object_data(bucket, object_key) + response.headers["Content-Length"] = len(data) + except (StorageError, OSError): + return _website_error_response(500, "Internal Server Error") + else: + try: + stat = obj_path.stat() + response.headers["Content-Length"] = stat.st_size + except OSError: + return _website_error_response(500, "Internal Server Error") + response.headers["Content-Type"] = content_type + return response + if is_encrypted and hasattr(storage, "get_object_data"): + try: + data, _ = storage.get_object_data(bucket, object_key) + response = Response(data, mimetype=content_type) + response.headers["Content-Length"] = len(data) + return response + except (StorageError, OSError): + return _website_error_response(500, "Internal Server Error") + def _stream(file_path): + with file_path.open("rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + yield chunk + try: + stat = obj_path.stat() + response = Response(_stream(obj_path), mimetype=content_type, direct_passthrough=True) + response.headers["Content-Length"] = stat.st_size + return response + except OSError: + return _website_error_response(500, "Internal Server Error") + + def _serve_website_error(storage, bucket, error_doc_key, status_code): + if not error_doc_key: + return _website_error_response(status_code, "Not Found" if status_code == 404 else "Error") + try: + obj_path = storage.get_object_path(bucket, error_doc_key) + except (StorageError, OSError): + return _website_error_response(status_code, "Not Found") + content_type = mimetypes.guess_type(error_doc_key)[0] or "text/html" + is_encrypted = False + try: + metadata = storage.get_object_metadata(bucket, error_doc_key) + is_encrypted = "x-amz-server-side-encryption" in metadata + except (StorageError, OSError): + pass + if is_encrypted and hasattr(storage, "get_object_data"): + try: + data, _ = storage.get_object_data(bucket, error_doc_key) + response = Response(data, status=status_code, mimetype=content_type) + response.headers["Content-Length"] = len(data) + return response + except (StorageError, OSError): + return _website_error_response(status_code, "Not Found") + try: + data = obj_path.read_bytes() + response = Response(data, status=status_code, mimetype=content_type) + response.headers["Content-Length"] = len(data) + return response + except OSError: + return _website_error_response(status_code, "Not Found") + + def _website_error_response(status_code, message): + safe_msg = html_module.escape(str(message)) + safe_code = html_module.escape(str(status_code)) + body = f"{safe_code} {safe_msg}

{safe_code} {safe_msg}

" + return Response(body, status=status_code, mimetype="text/html") + @app.after_request def _log_request_end(response): duration_ms = 0.0 diff --git a/app/admin_api.py b/app/admin_api.py index a3d436d..1d6b9ab 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -17,6 +17,7 @@ from .extensions import limiter from .iam import IamError, Principal from .replication import ReplicationManager from .site_registry import PeerSite, SiteInfo, SiteRegistry +from .website_domains import WebsiteDomainStore def _is_safe_url(url: str, allow_internal: bool = False) -> bool: @@ -673,3 +674,98 @@ def check_bidirectional_status(site_id: str): result["is_fully_configured"] = len(error_issues) == 0 and len(local_bidir_rules) > 0 return jsonify(result) + + +def _website_domains() -> WebsiteDomainStore: + return current_app.extensions["website_domains"] + + +def _storage(): + return current_app.extensions["object_storage"] + + +@admin_api_bp.route("/website-domains", methods=["GET"]) +@limiter.limit(lambda: _get_admin_rate_limit()) +def list_website_domains(): + principal, error = _require_admin() + if error: + return error + if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): + return _json_error("InvalidRequest", "Website hosting is not enabled", 400) + return jsonify(_website_domains().list_all()) + + +@admin_api_bp.route("/website-domains", methods=["POST"]) +@limiter.limit(lambda: _get_admin_rate_limit()) +def create_website_domain(): + principal, error = _require_admin() + if error: + return error + if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): + return _json_error("InvalidRequest", "Website hosting is not enabled", 400) + payload = request.get_json(silent=True) or {} + domain = (payload.get("domain") or "").strip().lower() + bucket = (payload.get("bucket") or "").strip() + if not domain: + return _json_error("ValidationError", "domain is required", 400) + if not bucket: + return _json_error("ValidationError", "bucket is required", 400) + storage = _storage() + if not storage.bucket_exists(bucket): + return _json_error("NoSuchBucket", f"Bucket '{bucket}' does not exist", 404) + store = _website_domains() + existing = store.get_bucket(domain) + if existing: + return _json_error("Conflict", f"Domain '{domain}' is already mapped to bucket '{existing}'", 409) + store.set_mapping(domain, bucket) + logger.info("Website domain mapping created: %s -> %s", domain, bucket) + return jsonify({"domain": domain, "bucket": bucket}), 201 + + +@admin_api_bp.route("/website-domains/", methods=["GET"]) +@limiter.limit(lambda: _get_admin_rate_limit()) +def get_website_domain(domain: str): + principal, error = _require_admin() + if error: + return error + if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): + return _json_error("InvalidRequest", "Website hosting is not enabled", 400) + bucket = _website_domains().get_bucket(domain) + if not bucket: + return _json_error("NotFound", f"No mapping found for domain '{domain}'", 404) + return jsonify({"domain": domain.lower(), "bucket": bucket}) + + +@admin_api_bp.route("/website-domains/", methods=["PUT"]) +@limiter.limit(lambda: _get_admin_rate_limit()) +def update_website_domain(domain: str): + principal, error = _require_admin() + if error: + return error + if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): + return _json_error("InvalidRequest", "Website hosting is not enabled", 400) + payload = request.get_json(silent=True) or {} + bucket = (payload.get("bucket") or "").strip() + if not bucket: + return _json_error("ValidationError", "bucket is required", 400) + storage = _storage() + if not storage.bucket_exists(bucket): + return _json_error("NoSuchBucket", f"Bucket '{bucket}' does not exist", 404) + store = _website_domains() + store.set_mapping(domain, bucket) + logger.info("Website domain mapping updated: %s -> %s", domain, bucket) + return jsonify({"domain": domain.lower(), "bucket": bucket}) + + +@admin_api_bp.route("/website-domains/", methods=["DELETE"]) +@limiter.limit(lambda: _get_admin_rate_limit()) +def delete_website_domain(domain: str): + principal, error = _require_admin() + if error: + return error + if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): + return _json_error("InvalidRequest", "Website hosting is not enabled", 400) + if not _website_domains().delete_mapping(domain): + return _json_error("NotFound", f"No mapping found for domain '{domain}'", 404) + logger.info("Website domain mapping deleted: %s", domain) + return Response(status=204) diff --git a/app/config.py b/app/config.py index bc03850..00023c5 100644 --- a/app/config.py +++ b/app/config.py @@ -149,6 +149,7 @@ class AppConfig: num_trusted_proxies: int allowed_redirect_hosts: list[str] allow_internal_endpoints: bool + website_hosting_enabled: bool @classmethod def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig": @@ -317,6 +318,7 @@ class AppConfig: allowed_redirect_hosts_raw = _get("ALLOWED_REDIRECT_HOSTS", "") allowed_redirect_hosts = [h.strip() for h in str(allowed_redirect_hosts_raw).split(",") if h.strip()] allow_internal_endpoints = str(_get("ALLOW_INTERNAL_ENDPOINTS", "0")).lower() in {"1", "true", "yes", "on"} + website_hosting_enabled = str(_get("WEBSITE_HOSTING_ENABLED", "0")).lower() in {"1", "true", "yes", "on"} return cls(storage_root=storage_root, max_upload_size=max_upload_size, @@ -403,7 +405,8 @@ class AppConfig: ratelimit_admin=ratelimit_admin, num_trusted_proxies=num_trusted_proxies, allowed_redirect_hosts=allowed_redirect_hosts, - allow_internal_endpoints=allow_internal_endpoints) + allow_internal_endpoints=allow_internal_endpoints, + website_hosting_enabled=website_hosting_enabled) def validate_and_report(self) -> list[str]: """Validate configuration and return a list of warnings/issues. @@ -509,6 +512,8 @@ class AppConfig: print(f" ENCRYPTION: Enabled (Master key: {self.encryption_master_key_path})") if self.kms_enabled: print(f" KMS: Enabled (Keys: {self.kms_keys_path})") + if self.website_hosting_enabled: + print(f" WEBSITE_HOSTING: Enabled") def _auto(flag: bool) -> str: return " (auto)" if flag else "" print(f" SERVER_THREADS: {self.server_threads}{_auto(self.server_threads_auto)}") @@ -611,4 +616,5 @@ class AppConfig: "NUM_TRUSTED_PROXIES": self.num_trusted_proxies, "ALLOWED_REDIRECT_HOSTS": self.allowed_redirect_hosts, "ALLOW_INTERNAL_ENDPOINTS": self.allow_internal_endpoints, + "WEBSITE_HOSTING_ENABLED": self.website_hosting_enabled, } diff --git a/app/encrypted_storage.py b/app/encrypted_storage.py index 6f419c3..6af859d 100644 --- a/app/encrypted_storage.py +++ b/app/encrypted_storage.py @@ -270,9 +270,15 @@ class EncryptedObjectStorage: def get_bucket_quota(self, bucket_name: str): return self.storage.get_bucket_quota(bucket_name) - + def set_bucket_quota(self, bucket_name: str, *, max_bytes=None, max_objects=None): return self.storage.set_bucket_quota(bucket_name, max_bytes=max_bytes, max_objects=max_objects) + + def get_bucket_website(self, bucket_name: str): + return self.storage.get_bucket_website(bucket_name) + + def set_bucket_website(self, bucket_name: str, website_config): + return self.storage.set_bucket_website(bucket_name, website_config) def _compute_etag(self, path: Path) -> str: return self.storage._compute_etag(path) diff --git a/app/s3_api.py b/app/s3_api.py index 60898f6..8f3f7eb 100644 --- a/app/s3_api.py +++ b/app/s3_api.py @@ -1027,6 +1027,7 @@ def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None: "uploads": _bucket_uploads_handler, "policy": _bucket_policy_handler, "replication": _bucket_replication_handler, + "website": _bucket_website_handler, } requested = [key for key in handlers if key in request.args] if not requested: @@ -3060,6 +3061,79 @@ def _parse_replication_config(bucket_name: str, payload: bytes): ) +def _bucket_website_handler(bucket_name: str) -> Response: + if request.method not in {"GET", "PUT", "DELETE"}: + return _method_not_allowed(["GET", "PUT", "DELETE"]) + if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): + return _error_response("InvalidRequest", "Website hosting is not enabled", 400) + principal, error = _require_principal() + if error: + return error + try: + _authorize_action(principal, bucket_name, "policy") + except IamError as exc: + return _error_response("AccessDenied", str(exc), 403) + storage = _storage() + if request.method == "GET": + try: + config = storage.get_bucket_website(bucket_name) + except StorageError as exc: + return _error_response("NoSuchBucket", str(exc), 404) + if not config: + return _error_response("NoSuchWebsiteConfiguration", "The specified bucket does not have a website configuration", 404) + root = Element("WebsiteConfiguration") + root.set("xmlns", S3_NS) + index_doc = config.get("index_document") + if index_doc: + idx_el = SubElement(root, "IndexDocument") + SubElement(idx_el, "Suffix").text = index_doc + error_doc = config.get("error_document") + if error_doc: + err_el = SubElement(root, "ErrorDocument") + SubElement(err_el, "Key").text = error_doc + return _xml_response(root) + if request.method == "DELETE": + try: + storage.set_bucket_website(bucket_name, None) + except StorageError as exc: + return _error_response("NoSuchBucket", str(exc), 404) + current_app.logger.info("Bucket website config deleted", extra={"bucket": bucket_name}) + return Response(status=204) + ct_error = _require_xml_content_type() + if ct_error: + return ct_error + payload = request.get_data(cache=False) or b"" + if not payload.strip(): + return _error_response("MalformedXML", "Request body is required", 400) + try: + root = _parse_xml_with_limit(payload) + except ParseError: + return _error_response("MalformedXML", "Unable to parse XML document", 400) + if _strip_ns(root.tag) != "WebsiteConfiguration": + return _error_response("MalformedXML", "Root element must be WebsiteConfiguration", 400) + index_el = _find_element(root, "IndexDocument") + if index_el is None: + return _error_response("InvalidArgument", "IndexDocument is required", 400) + suffix_el = _find_element(index_el, "Suffix") + if suffix_el is None or not (suffix_el.text or "").strip(): + return _error_response("InvalidArgument", "IndexDocument Suffix is required", 400) + index_suffix = suffix_el.text.strip() + if "/" in index_suffix: + return _error_response("InvalidArgument", "IndexDocument Suffix must not contain '/'", 400) + website_config: Dict[str, Any] = {"index_document": index_suffix} + error_el = _find_element(root, "ErrorDocument") + if error_el is not None: + key_el = _find_element(error_el, "Key") + if key_el is not None and (key_el.text or "").strip(): + website_config["error_document"] = key_el.text.strip() + try: + storage.set_bucket_website(bucket_name, website_config) + except StorageError as exc: + return _error_response("NoSuchBucket", str(exc), 404) + current_app.logger.info("Bucket website config updated", extra={"bucket": bucket_name, "index": index_suffix}) + return Response(status=200) + + def _parse_destination_arn(arn: str) -> tuple: if not arn.startswith("arn:aws:s3:::"): raise ValueError(f"Invalid ARN format: {arn}") diff --git a/app/storage.py b/app/storage.py index e3228ce..3ef3d0d 100644 --- a/app/storage.py +++ b/app/storage.py @@ -688,10 +688,19 @@ class ObjectStorage: return lifecycle if isinstance(lifecycle, list) else None def set_bucket_lifecycle(self, bucket_name: str, rules: Optional[List[Dict[str, Any]]]) -> None: - """Set lifecycle configuration for bucket.""" bucket_path = self._require_bucket_path(bucket_name) self._set_bucket_config_entry(bucket_path.name, "lifecycle", rules) + def get_bucket_website(self, bucket_name: str) -> Optional[Dict[str, Any]]: + bucket_path = self._require_bucket_path(bucket_name) + config = self._read_bucket_config(bucket_path.name) + website = config.get("website") + return website if isinstance(website, dict) else None + + def set_bucket_website(self, bucket_name: str, website_config: Optional[Dict[str, Any]]) -> None: + bucket_path = self._require_bucket_path(bucket_name) + self._set_bucket_config_entry(bucket_path.name, "website", website_config) + def get_bucket_quota(self, bucket_name: str) -> Dict[str, Any]: """Get quota configuration for bucket. diff --git a/app/ui.py b/app/ui.py index 1c23730..9124bbb 100644 --- a/app/ui.py +++ b/app/ui.py @@ -286,7 +286,8 @@ def inject_nav_state() -> dict[str, Any]: return { "principal": principal, "can_manage_iam": can_manage, - "can_view_metrics": can_manage, + "can_view_metrics": can_manage, + "website_hosting_nav": can_manage and current_app.config.get("WEBSITE_HOSTING_ENABLED", False), "csrf_token": generate_csrf, } @@ -498,12 +499,20 @@ def bucket_detail(bucket_name: str): encryption_enabled = current_app.config.get("ENCRYPTION_ENABLED", False) lifecycle_enabled = current_app.config.get("LIFECYCLE_ENABLED", False) site_sync_enabled = current_app.config.get("SITE_SYNC_ENABLED", False) + website_hosting_enabled = current_app.config.get("WEBSITE_HOSTING_ENABLED", False) can_manage_encryption = can_manage_versioning bucket_quota = storage.get_bucket_quota(bucket_name) bucket_stats = storage.bucket_stats(bucket_name) can_manage_quota = is_replication_admin + website_config = None + if website_hosting_enabled: + try: + website_config = storage.get_bucket_website(bucket_name) + except StorageError: + website_config = None + objects_api_url = url_for("ui.list_bucket_objects", bucket_name=bucket_name) objects_stream_url = url_for("ui.stream_bucket_objects", bucket_name=bucket_name) @@ -546,6 +555,9 @@ def bucket_detail(bucket_name: str): bucket_stats=bucket_stats, can_manage_quota=can_manage_quota, site_sync_enabled=site_sync_enabled, + website_hosting_enabled=website_hosting_enabled, + website_config=website_config, + can_manage_website=can_edit_policy, ) @@ -1610,6 +1622,75 @@ def update_bucket_encryption(bucket_name: str): return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) +@ui_bp.post("/buckets//website") +def update_bucket_website(bucket_name: str): + principal = _current_principal() + try: + _authorize_ui(principal, bucket_name, "policy") + except IamError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 403 + flash(_friendly_error_message(exc), "danger") + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) + + if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): + if _wants_json(): + return jsonify({"error": "Website hosting is not enabled"}), 400 + flash("Website hosting is not enabled", "danger") + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) + + action = request.form.get("action", "enable") + + if action == "disable": + try: + _storage().set_bucket_website(bucket_name, None) + if _wants_json(): + return jsonify({"success": True, "message": "Static website hosting disabled", "enabled": False}) + flash("Static website hosting disabled", "info") + except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 + flash(_friendly_error_message(exc), "danger") + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) + + index_document = request.form.get("index_document", "").strip() + error_document = request.form.get("error_document", "").strip() + + if not index_document: + if _wants_json(): + return jsonify({"error": "Index document is required"}), 400 + flash("Index document is required", "danger") + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) + + if "/" in index_document: + if _wants_json(): + return jsonify({"error": "Index document must not contain '/'"}), 400 + flash("Index document must not contain '/'", "danger") + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) + + website_cfg: dict[str, Any] = {"index_document": index_document} + if error_document: + website_cfg["error_document"] = error_document + + try: + _storage().set_bucket_website(bucket_name, website_cfg) + if _wants_json(): + return jsonify({ + "success": True, + "message": "Static website hosting enabled", + "enabled": True, + "index_document": index_document, + "error_document": error_document, + }) + flash("Static website hosting enabled", "success") + except StorageError as exc: + if _wants_json(): + return jsonify({"error": _friendly_error_message(exc)}), 400 + flash(_friendly_error_message(exc), "danger") + + return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties")) + + @ui_bp.get("/iam") def iam_dashboard(): principal = _current_principal() @@ -2275,6 +2356,142 @@ def connections_dashboard(): return render_template("connections.html", connections=connections, principal=principal) +@ui_bp.get("/website-domains") +def website_domains_dashboard(): + principal = _current_principal() + try: + _iam().authorize(principal, None, "iam:list_users") + except IamError: + flash("Access denied", "danger") + return redirect(url_for("ui.buckets_overview")) + + if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): + flash("Website hosting is not enabled", "warning") + return redirect(url_for("ui.buckets_overview")) + + store = current_app.extensions.get("website_domains") + mappings = store.list_all() if store else [] + storage = _storage() + buckets = [b.name for b in storage.list_buckets()] + return render_template( + "website_domains.html", + mappings=mappings, + buckets=buckets, + principal=principal, + can_manage_iam=True, + ) + + +@ui_bp.post("/website-domains/create") +def create_website_domain(): + principal = _current_principal() + try: + _iam().authorize(principal, None, "iam:list_users") + except IamError: + if _wants_json(): + return jsonify({"error": "Access denied"}), 403 + flash("Access denied", "danger") + return redirect(url_for("ui.website_domains_dashboard")) + + if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): + if _wants_json(): + return jsonify({"error": "Website hosting is not enabled"}), 400 + flash("Website hosting is not enabled", "warning") + return redirect(url_for("ui.buckets_overview")) + + domain = (request.form.get("domain") or "").strip().lower() + bucket = (request.form.get("bucket") or "").strip() + + if not domain: + if _wants_json(): + return jsonify({"error": "Domain is required"}), 400 + flash("Domain is required", "danger") + return redirect(url_for("ui.website_domains_dashboard")) + + if not bucket: + if _wants_json(): + return jsonify({"error": "Bucket is required"}), 400 + flash("Bucket is required", "danger") + return redirect(url_for("ui.website_domains_dashboard")) + + storage = _storage() + if not storage.bucket_exists(bucket): + if _wants_json(): + return jsonify({"error": f"Bucket '{bucket}' does not exist"}), 404 + flash(f"Bucket '{bucket}' does not exist", "danger") + return redirect(url_for("ui.website_domains_dashboard")) + + store = current_app.extensions.get("website_domains") + if store.get_bucket(domain): + if _wants_json(): + return jsonify({"error": f"Domain '{domain}' is already mapped"}), 409 + flash(f"Domain '{domain}' is already mapped", "danger") + return redirect(url_for("ui.website_domains_dashboard")) + + store.set_mapping(domain, bucket) + if _wants_json(): + return jsonify({"success": True, "domain": domain, "bucket": bucket}), 201 + flash(f"Domain '{domain}' mapped to bucket '{bucket}'", "success") + return redirect(url_for("ui.website_domains_dashboard")) + + +@ui_bp.post("/website-domains//update") +def update_website_domain(domain: str): + principal = _current_principal() + try: + _iam().authorize(principal, None, "iam:list_users") + except IamError: + if _wants_json(): + return jsonify({"error": "Access denied"}), 403 + flash("Access denied", "danger") + return redirect(url_for("ui.website_domains_dashboard")) + + bucket = (request.form.get("bucket") or "").strip() + if not bucket: + if _wants_json(): + return jsonify({"error": "Bucket is required"}), 400 + flash("Bucket is required", "danger") + return redirect(url_for("ui.website_domains_dashboard")) + + storage = _storage() + if not storage.bucket_exists(bucket): + if _wants_json(): + return jsonify({"error": f"Bucket '{bucket}' does not exist"}), 404 + flash(f"Bucket '{bucket}' does not exist", "danger") + return redirect(url_for("ui.website_domains_dashboard")) + + store = current_app.extensions.get("website_domains") + store.set_mapping(domain, bucket) + if _wants_json(): + return jsonify({"success": True, "domain": domain.lower(), "bucket": bucket}) + flash(f"Domain '{domain}' updated to bucket '{bucket}'", "success") + return redirect(url_for("ui.website_domains_dashboard")) + + +@ui_bp.post("/website-domains//delete") +def delete_website_domain(domain: str): + principal = _current_principal() + try: + _iam().authorize(principal, None, "iam:list_users") + except IamError: + if _wants_json(): + return jsonify({"error": "Access denied"}), 403 + flash("Access denied", "danger") + return redirect(url_for("ui.website_domains_dashboard")) + + store = current_app.extensions.get("website_domains") + if not store.delete_mapping(domain): + if _wants_json(): + return jsonify({"error": f"No mapping for domain '{domain}'"}), 404 + flash(f"No mapping for domain '{domain}'", "danger") + return redirect(url_for("ui.website_domains_dashboard")) + + if _wants_json(): + return jsonify({"success": True}) + flash(f"Domain '{domain}' mapping deleted", "success") + return redirect(url_for("ui.website_domains_dashboard")) + + @ui_bp.get("/metrics") def metrics_dashboard(): principal = _current_principal() diff --git a/app/version.py b/app/version.py index 91607b1..b25ea84 100644 --- a/app/version.py +++ b/app/version.py @@ -1,6 +1,6 @@ from __future__ import annotations -APP_VERSION = "0.2.8" +APP_VERSION = "0.2.9" def get_version() -> str: diff --git a/app/website_domains.py b/app/website_domains.py new file mode 100644 index 0000000..4dc4044 --- /dev/null +++ b/app/website_domains.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json +import threading +from pathlib import Path +from typing import Dict, List, Optional + + +class WebsiteDomainStore: + def __init__(self, config_path: Path) -> None: + self.config_path = config_path + self._lock = threading.Lock() + self._domains: Dict[str, str] = {} + self.reload() + + def reload(self) -> None: + if not self.config_path.exists(): + self._domains = {} + return + try: + with open(self.config_path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + self._domains = {k.lower(): v for k, v in data.items()} + else: + self._domains = {} + except (OSError, json.JSONDecodeError): + self._domains = {} + + def _save(self) -> None: + self.config_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump(self._domains, f, indent=2) + + def list_all(self) -> List[Dict[str, str]]: + with self._lock: + return [{"domain": d, "bucket": b} for d, b in self._domains.items()] + + def get_bucket(self, domain: str) -> Optional[str]: + with self._lock: + return self._domains.get(domain.lower()) + + def set_mapping(self, domain: str, bucket: str) -> None: + with self._lock: + self._domains[domain.lower()] = bucket + self._save() + + def delete_mapping(self, domain: str) -> bool: + with self._lock: + key = domain.lower() + if key not in self._domains: + return False + del self._domains[key] + self._save() + return True diff --git a/docs.md b/docs.md index 4439a92..90110bc 100644 --- a/docs.md +++ b/docs.md @@ -1552,6 +1552,9 @@ GET /?notification # Get event notifications PUT /?notification # Set event notifications (webhooks) GET /?object-lock # Get object lock configuration PUT /?object-lock # Set object lock configuration +GET /?website # Get website configuration +PUT /?website # Set website configuration +DELETE /?website # Delete website configuration GET /?uploads # List active multipart uploads GET /?versions # List object versions GET /?location # Get bucket location/region @@ -1596,6 +1599,11 @@ PUT /admin/sites/ # Update peer site DELETE /admin/sites/ # Unregister peer site GET /admin/sites//health # Check peer health GET /admin/topology # Get cluster topology +GET /admin/website-domains # List domain mappings +POST /admin/website-domains # Create domain mapping +GET /admin/website-domains/ # Get domain mapping +PUT /admin/website-domains/ # Update domain mapping +DELETE /admin/website-domains/ # Delete domain mapping # KMS API GET /kms/keys # List KMS keys @@ -2229,3 +2237,113 @@ curl "http://localhost:5000/my-bucket?list-type=2&start-after=photos/2024/" \ | `start-after` | Start listing after this key | | `fetch-owner` | Include owner info in response | | `encoding-type` | Set to `url` for URL-encoded keys + +## 26. Static Website Hosting + +MyFSIO can serve S3 buckets as static websites via custom domain mappings. When a request arrives with a `Host` header matching a mapped domain, MyFSIO resolves the bucket and serves objects directly. + +### Enabling + +Set the environment variable: + +```bash +WEBSITE_HOSTING_ENABLED=true +``` + +When disabled, all website hosting endpoints return 400 and domain-based serving is skipped. + +### Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `WEBSITE_HOSTING_ENABLED` | `false` | Master switch for website hosting | + +### Setting Up a Website + +**Step 1: Configure the bucket website settings** + +```bash +curl -X PUT "http://localhost:5000/my-site?website" \ + -H "Authorization: ..." \ + -d ' + + index.html + 404.html +' +``` + +- `IndexDocument` with `Suffix` is required (must not contain `/`) +- `ErrorDocument` is optional + +**Step 2: Map a domain to the bucket** + +```bash +curl -X POST "http://localhost:5000/admin/website-domains" \ + -H "Authorization: ..." \ + -H "Content-Type: application/json" \ + -d '{"domain": "example.com", "bucket": "my-site"}' +``` + +**Step 3: Point your domain to MyFSIO** + +For HTTP-only (direct access), point DNS to the MyFSIO host on port 5000. + +For HTTPS (recommended), use a reverse proxy. The critical requirement is passing the original `Host` header so MyFSIO can match the domain to a bucket. + +**nginx example:** + +```nginx +server { + server_name example.com; + listen 443 ssl; + + ssl_certificate /etc/ssl/certs/example.com.pem; + ssl_certificate_key /etc/ssl/private/example.com.key; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +`proxy_set_header Host $host;` is required — without it, MyFSIO cannot match the incoming domain to a bucket. You do not need any path-based routing rules; MyFSIO handles all object resolution internally. + +### How Domain Routing Works + +1. A request arrives with `Host: example.com` +2. MyFSIO's `before_request` hook strips the port and looks up the domain in the `WebsiteDomainStore` +3. If a match is found, it loads the bucket's website config (index/error documents) +4. Object key resolution: + - `/` or trailing `/` → append `index_document` (e.g., `index.html`) + - `/path` → try exact match, then try `path/index_document` + - Not found → serve `error_document` with 404 status +5. If no domain match is found, the request falls through to normal S3 API / UI routing + +### Domain Mapping Admin API + +All endpoints require admin (`iam:*`) permissions. + +| Method | Route | Body | Description | +|--------|-------|------|-------------| +| `GET` | `/admin/website-domains` | — | List all mappings | +| `POST` | `/admin/website-domains` | `{"domain": "...", "bucket": "..."}` | Create mapping | +| `GET` | `/admin/website-domains/` | — | Get single mapping | +| `PUT` | `/admin/website-domains/` | `{"bucket": "..."}` | Update mapping | +| `DELETE` | `/admin/website-domains/` | — | Delete mapping | + +### Bucket Website API + +| Method | Route | Description | +|--------|-------|-------------| +| `PUT` | `/?website` | Set website config (XML body) | +| `GET` | `/?website` | Get website config (XML response) | +| `DELETE` | `/?website` | Remove website config | + +### Web UI + +- **Per-bucket config:** Bucket Details → Properties tab → "Static Website Hosting" card +- **Domain management:** Sidebar → "Domains" (visible when hosting is enabled and user is admin) diff --git a/static/js/bucket-detail-main.js b/static/js/bucket-detail-main.js index 041298a..c643b09 100644 --- a/static/js/bucket-detail-main.js +++ b/static/js/bucket-detail-main.js @@ -4164,6 +4164,13 @@ } }); + interceptForm('websiteForm', { + successMessage: 'Website settings saved', + onSuccess: function (data) { + updateWebsiteCard(data.enabled !== false, data.index_document, data.error_document); + } + }); + interceptForm('bucketPolicyForm', { successMessage: 'Bucket policy saved', onSuccess: function (data) { @@ -4224,6 +4231,59 @@ }); } + function updateWebsiteCard(enabled, indexDoc, errorDoc) { + var card = document.getElementById('bucket-website-card'); + if (!card) return; + var alertContainer = card.querySelector('.alert'); + if (alertContainer) { + if (enabled) { + alertContainer.className = 'alert alert-success d-flex align-items-start mb-4'; + var detail = 'Index: ' + escapeHtml(indexDoc || 'index.html') + ''; + if (errorDoc) detail += '
Error: ' + escapeHtml(errorDoc) + ''; + alertContainer.innerHTML = '' + + '' + + '
Website hosting is enabled' + + '

' + detail + '

'; + } else { + alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4'; + alertContainer.innerHTML = '' + + '' + + '' + + '
Website hosting is disabled' + + '

Enable website hosting to serve bucket contents as a static website.

'; + } + } + var disableBtn = document.getElementById('disableWebsiteBtn'); + if (disableBtn) { + disableBtn.style.display = enabled ? '' : 'none'; + } + var submitBtn = document.getElementById('websiteSubmitBtn'); + if (submitBtn) { + submitBtn.classList.remove('btn-primary', 'btn-success'); + submitBtn.classList.add(enabled ? 'btn-primary' : 'btn-success'); + } + var submitLabel = document.getElementById('websiteSubmitLabel'); + if (submitLabel) { + submitLabel.textContent = enabled ? 'Save Website Settings' : 'Enable Website Hosting'; + } + } + + var disableWebsiteBtn = document.getElementById('disableWebsiteBtn'); + if (disableWebsiteBtn) { + disableWebsiteBtn.addEventListener('click', function () { + var form = document.getElementById('websiteForm'); + if (!form) return; + document.getElementById('websiteAction').value = 'disable'; + window.UICore.submitFormAjax(form, { + successMessage: 'Website hosting disabled', + onSuccess: function (data) { + document.getElementById('websiteAction').value = 'enable'; + updateWebsiteCard(false, null, null); + } + }); + }); + } + function reloadReplicationPane() { var replicationPane = document.getElementById('replication-pane'); if (!replicationPane) return; diff --git a/templates/base.html b/templates/base.html index 7cd24be..e9e8fb4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -101,6 +101,15 @@ Sites {% endif %} + {% if website_hosting_nav %} + + + + + + Domains + + {% endif %} + + {% if website_hosting_enabled %} +
+
+ + + + Static Website Hosting +
+
+ {% if website_config %} + + {% else %} + + {% endif %} + + {% if can_manage_website %} +
+ + + +
+ + +
The default page served for directory paths (e.g., index.html).
+
+ +
+ + +
Optional. The page served for 404 errors.
+
+ +
+ + +
+
+ {% else %} +
+ + + +

You do not have permission to modify website hosting for this bucket.

+
+ {% endif %} +
+
+ {% endif %}
diff --git a/templates/docs.html b/templates/docs.html index 8e52e53..9ab9dcf 100644 --- a/templates/docs.html +++ b/templates/docs.html @@ -51,6 +51,7 @@
  • Advanced Operations
  • Access Control Lists
  • Object & Bucket Tagging
  • +
  • Static Website Hosting
  • @@ -2099,6 +2100,99 @@ curl -X PUT "{{ api_base }}/<bucket>?tagging" \ +
    +
    +
    + 25 +

    Static Website Hosting

    +
    +

    Host static websites directly from S3 buckets with custom index and error pages, served via custom domain mapping.

    + +
    + Prerequisite: Set WEBSITE_HOSTING_ENABLED=true to enable this feature. +
    + +

    1. Configure bucket for website hosting

    +
    # Enable website hosting with index and error documents
    +curl -X PUT "{{ api_base }}/<bucket>?website" \
    +  -H "Content-Type: application/xml" \
    +  -H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
    +  -d '<WebsiteConfiguration>
    +    <IndexDocument><Suffix>index.html</Suffix></IndexDocument>
    +    <ErrorDocument><Key>404.html</Key></ErrorDocument>
    +  </WebsiteConfiguration>'
    +
    +# Get website configuration
    +curl "{{ api_base }}/<bucket>?website" \
    +  -H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
    +
    +# Remove website configuration
    +curl -X DELETE "{{ api_base }}/<bucket>?website" \
    +  -H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
    + +

    2. Map a custom domain to the bucket

    +
    # Create domain mapping (admin only)
    +curl -X POST "{{ api_base }}/admin/website-domains" \
    +  -H "Content-Type: application/json" \
    +  -H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
    +  -d '{"domain": "example.com", "bucket": "my-site"}'
    +
    +# List all domain mappings
    +curl "{{ api_base }}/admin/website-domains" \
    +  -H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
    +
    +# Update a mapping
    +curl -X PUT "{{ api_base }}/admin/website-domains/example.com" \
    +  -H "Content-Type: application/json" \
    +  -H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
    +  -d '{"bucket": "new-site-bucket"}'
    +
    +# Delete a mapping
    +curl -X DELETE "{{ api_base }}/admin/website-domains/example.com" \
    +  -H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
    + +

    3. Point your domain

    +

    MyFSIO handles domain routing natively via the Host header — no path-based proxy rules needed. Just point your domain to the MyFSIO API server.

    + +
    + Direct access (HTTP only): Point your domain's DNS (A or CNAME) directly to the MyFSIO server on port 5000. +
    + +

    For HTTPS, place a reverse proxy in front. The proxy only needs to forward traffic — MyFSIO handles the domain-to-bucket routing:

    +
    # nginx example
    +server {
    +    server_name example.com;
    +    location / {
    +        proxy_pass http://127.0.0.1:5000;
    +        proxy_set_header Host $host;  # Required: passes the domain to MyFSIO
    +        proxy_set_header X-Real-IP $remote_addr;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +    }
    +}
    +
    + Important: The proxy_set_header Host $host; directive is required. MyFSIO matches the incoming Host header against domain mappings to determine which bucket to serve. +
    + +

    How it works

    +
    +
    +
      +
    • / serves the configured index document
    • +
    • /about/ serves about/index.html
    • +
    • Objects served with correct Content-Type
    • +
    +
    +
    +
      +
    • Missing objects return the error document with 404
    • +
    • Website endpoints are public (no auth required)
    • +
    • Normal S3 API with auth continues to work
    • +
    +
    +
    +
    +