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 %}
Resources
@@ -192,6 +201,15 @@
{% endif %}
+ {% if website_hosting_nav %}
+
+
+
+
+
+
+
+ {% endif %}
Resources
diff --git a/templates/bucket_detail.html b/templates/bucket_detail.html
index 182e5e7..9289019 100644
--- a/templates/bucket_detail.html
+++ b/templates/bucket_detail.html
@@ -966,6 +966,89 @@
{% endif %}
+
+ {% if website_hosting_enabled %}
+
+
+
+ {% if website_config %}
+
+
+
+
+
+
Website hosting is enabled
+
+ Index: {{ website_config.index_document }}
+ {% if website_config.error_document %} Error: {{ website_config.error_document }}{% endif %}
+
+
+
+ {% else %}
+
+
+
+
+
+
+
Website hosting is disabled
+
Enable website hosting to serve bucket contents as a static website.
+
+
+ {% endif %}
+
+ {% if can_manage_website %}
+
+ {% else %}
+
+
+
+
+
You do not have permission to modify website hosting for this bucket.
+
+ {% endif %}
+
+
+ {% endif %}
@@ -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
+
+
+
+
+