Add static website hosting
This commit is contained in:
135
app/__init__.py
135
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"<html><head><title>{safe_code} {safe_msg}</title></head><body><h1>{safe_code} {safe_msg}</h1></body></html>"
|
||||
return Response(body, status=status_code, mimetype="text/html")
|
||||
|
||||
@app.after_request
|
||||
def _log_request_end(response):
|
||||
duration_ms = 0.0
|
||||
|
||||
@@ -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/<domain>", 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/<domain>", 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/<domain>", 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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
219
app/ui.py
219
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/<bucket_name>/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/<domain>/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/<domain>/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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
APP_VERSION = "0.2.8"
|
||||
APP_VERSION = "0.2.9"
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
|
||||
55
app/website_domains.py
Normal file
55
app/website_domains.py
Normal file
@@ -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
|
||||
118
docs.md
118
docs.md
@@ -1552,6 +1552,9 @@ GET /<bucket>?notification # Get event notifications
|
||||
PUT /<bucket>?notification # Set event notifications (webhooks)
|
||||
GET /<bucket>?object-lock # Get object lock configuration
|
||||
PUT /<bucket>?object-lock # Set object lock configuration
|
||||
GET /<bucket>?website # Get website configuration
|
||||
PUT /<bucket>?website # Set website configuration
|
||||
DELETE /<bucket>?website # Delete website configuration
|
||||
GET /<bucket>?uploads # List active multipart uploads
|
||||
GET /<bucket>?versions # List object versions
|
||||
GET /<bucket>?location # Get bucket location/region
|
||||
@@ -1596,6 +1599,11 @@ PUT /admin/sites/<site_id> # Update peer site
|
||||
DELETE /admin/sites/<site_id> # Unregister peer site
|
||||
GET /admin/sites/<site_id>/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/<domain> # Get domain mapping
|
||||
PUT /admin/website-domains/<domain> # Update domain mapping
|
||||
DELETE /admin/website-domains/<domain> # 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 '<?xml version="1.0" encoding="UTF-8"?>
|
||||
<WebsiteConfiguration>
|
||||
<IndexDocument><Suffix>index.html</Suffix></IndexDocument>
|
||||
<ErrorDocument><Key>404.html</Key></ErrorDocument>
|
||||
</WebsiteConfiguration>'
|
||||
```
|
||||
|
||||
- `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/<domain>` | — | Get single mapping |
|
||||
| `PUT` | `/admin/website-domains/<domain>` | `{"bucket": "..."}` | Update mapping |
|
||||
| `DELETE` | `/admin/website-domains/<domain>` | — | Delete mapping |
|
||||
|
||||
### Bucket Website API
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| `PUT` | `/<bucket>?website` | Set website config (XML body) |
|
||||
| `GET` | `/<bucket>?website` | Get website config (XML response) |
|
||||
| `DELETE` | `/<bucket>?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)
|
||||
|
||||
@@ -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: <code>' + escapeHtml(indexDoc || 'index.html') + '</code>';
|
||||
if (errorDoc) detail += '<br>Error: <code>' + escapeHtml(errorDoc) + '</code>';
|
||||
alertContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||
'<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>' +
|
||||
'</svg><div><strong>Website hosting is enabled</strong>' +
|
||||
'<p class="mb-0 small">' + detail + '</p></div>';
|
||||
} else {
|
||||
alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4';
|
||||
alertContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||
'<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>' +
|
||||
'<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>' +
|
||||
'</svg><div><strong>Website hosting is disabled</strong>' +
|
||||
'<p class="mb-0 small">Enable website hosting to serve bucket contents as a static website.</p></div>';
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -101,6 +101,15 @@
|
||||
<span>Sites</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if website_hosting_nav %}
|
||||
<a href="{{ url_for('ui.website_domains_dashboard') }}" class="sidebar-link {% if request.endpoint == 'ui.website_domains_dashboard' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
||||
</svg>
|
||||
<span>Domains</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<span class="nav-section-title">Resources</span>
|
||||
@@ -192,6 +201,15 @@
|
||||
<span class="sidebar-link-text">Sites</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if website_hosting_nav %}
|
||||
<a href="{{ url_for('ui.website_domains_dashboard') }}" class="sidebar-link {% if request.endpoint == 'ui.website_domains_dashboard' %}active{% endif %}" data-tooltip="Domains">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
||||
</svg>
|
||||
<span class="sidebar-link-text">Domains</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<span class="nav-section-title">Resources</span>
|
||||
|
||||
@@ -966,6 +966,89 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if website_hosting_enabled %}
|
||||
<div class="card shadow-sm mt-4" id="bucket-website-card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
||||
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||
</svg>
|
||||
<span class="fw-semibold">Static Website Hosting</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if website_config %}
|
||||
<div class="alert alert-success d-flex align-items-start mb-4" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Website hosting is enabled</strong>
|
||||
<p class="mb-0 small">
|
||||
Index: <code>{{ website_config.index_document }}</code>
|
||||
{% if website_config.error_document %}<br>Error: <code>{{ website_config.error_document }}</code>{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-secondary d-flex align-items-start mb-4" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Website hosting is disabled</strong>
|
||||
<p class="mb-0 small">Enable website hosting to serve bucket contents as a static website.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_manage_website %}
|
||||
<form method="post" action="{{ url_for('ui.update_bucket_website', bucket_name=bucket_name) }}" id="websiteForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="hidden" name="action" value="enable" id="websiteAction" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="index_document" class="form-label fw-medium">Index Document</label>
|
||||
<input type="text" class="form-control" id="index_document" name="index_document"
|
||||
value="{{ website_config.index_document if website_config else 'index.html' }}"
|
||||
placeholder="index.html">
|
||||
<div class="form-text">The default page served for directory paths (e.g., index.html).</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="error_document" class="form-label fw-medium">Error Document</label>
|
||||
<input type="text" class="form-control" id="error_document" name="error_document"
|
||||
value="{{ website_config.error_document if website_config else '' }}"
|
||||
placeholder="error.html">
|
||||
<div class="form-text">Optional. The page served for 404 errors.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn {{ 'btn-primary' if website_config else 'btn-success' }}" type="submit" id="websiteSubmitBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z"/>
|
||||
</svg>
|
||||
<span id="websiteSubmitLabel">{{ 'Save Website Settings' if website_config else 'Enable Website Hosting' }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger" id="disableWebsiteBtn"{% if not website_config %} style="display: none;"{% endif %}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
Disable Website Hosting
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="text-muted mb-2" viewBox="0 0 16 16">
|
||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
<p class="text-muted mb-0 small">You do not have permission to modify website hosting for this bucket.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<li><a href="#advanced-ops">Advanced Operations</a></li>
|
||||
<li><a href="#acls">Access Control Lists</a></li>
|
||||
<li><a href="#tagging">Object & Bucket Tagging</a></li>
|
||||
<li><a href="#website-hosting">Static Website Hosting</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2099,6 +2100,99 @@ curl -X PUT "{{ api_base }}/<bucket>?tagging" \
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article id="website-hosting" class="card shadow-sm docs-section">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<span class="docs-section-kicker">25</span>
|
||||
<h2 class="h4 mb-0">Static Website Hosting</h2>
|
||||
</div>
|
||||
<p class="text-muted">Host static websites directly from S3 buckets with custom index and error pages, served via custom domain mapping.</p>
|
||||
|
||||
<div class="alert alert-info small mb-3">
|
||||
<strong>Prerequisite:</strong> Set <code>WEBSITE_HOSTING_ENABLED=true</code> to enable this feature.
|
||||
</div>
|
||||
|
||||
<h3 class="h6 text-uppercase text-muted mt-4">1. Configure bucket for website hosting</h3>
|
||||
<pre class="mb-3"><code class="language-bash"># 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>"</code></pre>
|
||||
|
||||
<h3 class="h6 text-uppercase text-muted mt-4">2. Map a custom domain to the bucket</h3>
|
||||
<pre class="mb-3"><code class="language-bash"># 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>"</code></pre>
|
||||
|
||||
<h3 class="h6 text-uppercase text-muted mt-4">3. Point your domain</h3>
|
||||
<p class="small text-muted">MyFSIO handles domain routing natively via the <code>Host</code> header — no path-based proxy rules needed. Just point your domain to the MyFSIO API server.</p>
|
||||
|
||||
<div class="alert alert-secondary small mb-3">
|
||||
<strong>Direct access (HTTP only):</strong> Point your domain's DNS (A or CNAME) directly to the MyFSIO server on port 5000.
|
||||
</div>
|
||||
|
||||
<p class="small text-muted mb-2">For <strong>HTTPS</strong>, place a reverse proxy in front. The proxy only needs to forward traffic — MyFSIO handles the domain-to-bucket routing:</p>
|
||||
<pre class="mb-3"><code class="language-nginx"># 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;
|
||||
}
|
||||
}</code></pre>
|
||||
<div class="alert alert-warning small mb-3">
|
||||
<strong>Important:</strong> The <code>proxy_set_header Host $host;</code> directive is required. MyFSIO matches the incoming <code>Host</code> header against domain mappings to determine which bucket to serve.
|
||||
</div>
|
||||
|
||||
<h3 class="h6 text-uppercase text-muted mt-4">How it works</h3>
|
||||
<div class="row g-2 mb-0">
|
||||
<div class="col-md-6">
|
||||
<ul class="small text-muted mb-0 ps-3">
|
||||
<li><code>/</code> serves the configured index document</li>
|
||||
<li><code>/about/</code> serves <code>about/index.html</code></li>
|
||||
<li>Objects served with correct Content-Type</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<ul class="small text-muted mb-0 ps-3">
|
||||
<li>Missing objects return the error document with 404</li>
|
||||
<li>Website endpoints are public (no auth required)</li>
|
||||
<li>Normal S3 API with auth continues to work</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-xl-4 docs-sidebar-col">
|
||||
<aside class="card shadow-sm docs-sidebar">
|
||||
@@ -2129,6 +2223,7 @@ curl -X PUT "{{ api_base }}/<bucket>?tagging" \
|
||||
<li><a href="#advanced-ops">Advanced Operations</a></li>
|
||||
<li><a href="#acls">Access Control Lists</a></li>
|
||||
<li><a href="#tagging">Object & Bucket Tagging</a></li>
|
||||
<li><a href="#website-hosting">Static Website Hosting</a></li>
|
||||
</ul>
|
||||
<div class="docs-sidebar-callouts">
|
||||
<div>
|
||||
|
||||
287
templates/website_domains.html
Normal file
287
templates/website_domains.html
Normal file
@@ -0,0 +1,287 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Website Domains - MyFSIO Console{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<p class="text-uppercase text-muted small mb-1">Website Hosting</p>
|
||||
<h1 class="h3 mb-1 d-flex align-items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||
</svg>
|
||||
Domain Mappings
|
||||
</h1>
|
||||
<p class="text-muted mb-0 mt-1">Map custom domains to buckets for static website hosting.</p>
|
||||
</div>
|
||||
<div class="d-none d-md-block">
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary fs-6 px-3 py-2">
|
||||
{{ mappings|length }} mapping{{ 's' if mappings|length != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-4 col-md-5">
|
||||
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4">
|
||||
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
Add Domain Mapping
|
||||
</h5>
|
||||
<p class="text-muted small mb-0">Point a custom domain to a bucket</p>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4">
|
||||
<form method="POST" action="{{ url_for('ui.create_website_domain') }}" id="createDomainForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="mb-3">
|
||||
<label for="domain" class="form-label fw-medium">Domain</label>
|
||||
<input type="text" class="form-control" id="domain" name="domain" required placeholder="www.example.com">
|
||||
<div class="form-text">The hostname that will serve website content.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="bucket" class="form-label fw-medium">Bucket</label>
|
||||
{% if buckets %}
|
||||
<select class="form-select" id="bucket" name="bucket" required>
|
||||
<option value="" selected disabled>Select a bucket</option>
|
||||
{% for b in buckets %}
|
||||
<option value="{{ b }}">{{ b }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="text" class="form-control" id="bucket" name="bucket" required placeholder="my-site-bucket">
|
||||
{% endif %}
|
||||
<div class="form-text">The bucket must have website hosting enabled.</div>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||
</svg>
|
||||
Add Mapping
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0 mt-4" style="border-radius: 1rem;">
|
||||
<div class="card-body px-4 py-3">
|
||||
<h6 class="fw-semibold mb-2 d-flex align-items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
||||
</svg>
|
||||
How it works
|
||||
</h6>
|
||||
<ol class="small text-muted mb-0 ps-3">
|
||||
<li class="mb-1">Enable website hosting on a bucket (Properties tab)</li>
|
||||
<li class="mb-1">Create a domain mapping here</li>
|
||||
<li>Point your DNS (A/CNAME) to this server</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8 col-md-7">
|
||||
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
||||
</svg>
|
||||
Active Mappings
|
||||
</h5>
|
||||
<p class="text-muted small mb-0">Domains currently serving website content</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4">
|
||||
{% if mappings %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col">Domain</th>
|
||||
<th scope="col">Bucket</th>
|
||||
<th scope="col" class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in mappings %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||
</svg>
|
||||
<code class="fw-medium">{{ m.domain }}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="badge bg-primary bg-opacity-10 text-primary">{{ m.bucket }}</span></td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#editDomainModal"
|
||||
data-domain="{{ m.domain }}"
|
||||
data-bucket="{{ m.bucket }}"
|
||||
title="Edit mapping">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteDomainModal"
|
||||
data-domain="{{ m.domain }}"
|
||||
data-bucket="{{ m.bucket }}"
|
||||
title="Delete mapping">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state text-center py-5">
|
||||
<div class="empty-state-icon mx-auto mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="fw-semibold mb-2">No domain mappings yet</h5>
|
||||
<p class="text-muted mb-0">Add your first domain mapping to serve a bucket as a static website.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="editDomainModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title fw-semibold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg>
|
||||
Edit Domain Mapping
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form method="POST" id="editDomainForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Domain</label>
|
||||
<input type="text" class="form-control" id="editDomainName" disabled>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editBucket" class="form-label fw-medium">Bucket</label>
|
||||
{% if buckets %}
|
||||
<select class="form-select" id="editBucket" name="bucket" required>
|
||||
{% for b in buckets %}
|
||||
<option value="{{ b }}">{{ b }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="text" class="form-control" id="editBucket" name="bucket" required>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||
</svg>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="deleteDomainModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title fw-semibold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
Delete Domain Mapping
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the mapping for <strong><code id="deleteDomainName"></code></strong>?</p>
|
||||
<div class="alert alert-warning d-flex align-items-start small" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="flex-shrink-0 me-2 mt-0" viewBox="0 0 16 16">
|
||||
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
||||
</svg>
|
||||
<div>This domain will stop serving website content immediately.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form method="POST" id="deleteDomainForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
var editModal = document.getElementById('editDomainModal');
|
||||
if (editModal) {
|
||||
editModal.addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var domain = btn.getAttribute('data-domain');
|
||||
var bucket = btn.getAttribute('data-bucket');
|
||||
document.getElementById('editDomainName').value = domain;
|
||||
var editBucket = document.getElementById('editBucket');
|
||||
if (editBucket.tagName === 'SELECT') {
|
||||
editBucket.value = bucket;
|
||||
} else {
|
||||
editBucket.value = bucket;
|
||||
}
|
||||
document.getElementById('editDomainForm').action = '{{ url_for("ui.update_website_domain", domain="__DOMAIN__") }}'.replace('__DOMAIN__', encodeURIComponent(domain));
|
||||
});
|
||||
}
|
||||
|
||||
var deleteModal = document.getElementById('deleteDomainModal');
|
||||
if (deleteModal) {
|
||||
deleteModal.addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var domain = btn.getAttribute('data-domain');
|
||||
document.getElementById('deleteDomainName').textContent = domain;
|
||||
document.getElementById('deleteDomainForm').action = '{{ url_for("ui.delete_website_domain", domain="__DOMAIN__") }}'.replace('__DOMAIN__', encodeURIComponent(domain));
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
442
tests/test_website_hosting.py
Normal file
442
tests/test_website_hosting.py
Normal file
@@ -0,0 +1,442 @@
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from xml.etree.ElementTree import fromstring
|
||||
|
||||
import pytest
|
||||
|
||||
from app import create_api_app
|
||||
from app.website_domains import WebsiteDomainStore
|
||||
|
||||
|
||||
def _stream(data: bytes):
|
||||
return io.BytesIO(data)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def website_app(tmp_path: Path):
|
||||
storage_root = tmp_path / "data"
|
||||
iam_config = tmp_path / "iam.json"
|
||||
bucket_policies = tmp_path / "bucket_policies.json"
|
||||
iam_payload = {
|
||||
"users": [
|
||||
{
|
||||
"access_key": "test",
|
||||
"secret_key": "secret",
|
||||
"display_name": "Test User",
|
||||
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy", "iam:*"]}],
|
||||
}
|
||||
]
|
||||
}
|
||||
iam_config.write_text(json.dumps(iam_payload))
|
||||
flask_app = create_api_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"SECRET_KEY": "testing",
|
||||
"STORAGE_ROOT": storage_root,
|
||||
"IAM_CONFIG": iam_config,
|
||||
"BUCKET_POLICY_PATH": bucket_policies,
|
||||
"API_BASE_URL": "http://testserver",
|
||||
"WEBSITE_HOSTING_ENABLED": True,
|
||||
}
|
||||
)
|
||||
yield flask_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def website_client(website_app):
|
||||
return website_app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def storage(website_app):
|
||||
return website_app.extensions["object_storage"]
|
||||
|
||||
|
||||
class TestWebsiteDomainStore:
|
||||
def test_empty_store(self, tmp_path):
|
||||
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||
assert store.list_all() == []
|
||||
assert store.get_bucket("example.com") is None
|
||||
|
||||
def test_set_and_get_mapping(self, tmp_path):
|
||||
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||
store.set_mapping("example.com", "my-site")
|
||||
assert store.get_bucket("example.com") == "my-site"
|
||||
|
||||
def test_case_insensitive(self, tmp_path):
|
||||
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||
store.set_mapping("Example.COM", "my-site")
|
||||
assert store.get_bucket("example.com") == "my-site"
|
||||
assert store.get_bucket("EXAMPLE.COM") == "my-site"
|
||||
|
||||
def test_list_all(self, tmp_path):
|
||||
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||
store.set_mapping("a.com", "bucket-a")
|
||||
store.set_mapping("b.com", "bucket-b")
|
||||
result = store.list_all()
|
||||
domains = {item["domain"] for item in result}
|
||||
assert domains == {"a.com", "b.com"}
|
||||
|
||||
def test_delete_mapping(self, tmp_path):
|
||||
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||
store.set_mapping("example.com", "my-site")
|
||||
assert store.delete_mapping("example.com") is True
|
||||
assert store.get_bucket("example.com") is None
|
||||
|
||||
def test_delete_nonexistent(self, tmp_path):
|
||||
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||
assert store.delete_mapping("nope.com") is False
|
||||
|
||||
def test_overwrite_mapping(self, tmp_path):
|
||||
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||
store.set_mapping("example.com", "old-bucket")
|
||||
store.set_mapping("example.com", "new-bucket")
|
||||
assert store.get_bucket("example.com") == "new-bucket"
|
||||
|
||||
def test_persistence(self, tmp_path):
|
||||
path = tmp_path / "domains.json"
|
||||
store1 = WebsiteDomainStore(path)
|
||||
store1.set_mapping("example.com", "my-site")
|
||||
store2 = WebsiteDomainStore(path)
|
||||
assert store2.get_bucket("example.com") == "my-site"
|
||||
|
||||
def test_corrupt_file(self, tmp_path):
|
||||
path = tmp_path / "domains.json"
|
||||
path.write_text("not json")
|
||||
store = WebsiteDomainStore(path)
|
||||
assert store.list_all() == []
|
||||
|
||||
def test_non_dict_file(self, tmp_path):
|
||||
path = tmp_path / "domains.json"
|
||||
path.write_text('["not", "a", "dict"]')
|
||||
store = WebsiteDomainStore(path)
|
||||
assert store.list_all() == []
|
||||
|
||||
|
||||
class TestStorageWebsiteConfig:
|
||||
def test_get_website_no_config(self, storage):
|
||||
storage.create_bucket("test-bucket")
|
||||
assert storage.get_bucket_website("test-bucket") is None
|
||||
|
||||
def test_set_and_get_website(self, storage):
|
||||
storage.create_bucket("test-bucket")
|
||||
config = {"index_document": "index.html", "error_document": "error.html"}
|
||||
storage.set_bucket_website("test-bucket", config)
|
||||
result = storage.get_bucket_website("test-bucket")
|
||||
assert result["index_document"] == "index.html"
|
||||
assert result["error_document"] == "error.html"
|
||||
|
||||
def test_delete_website_config(self, storage):
|
||||
storage.create_bucket("test-bucket")
|
||||
storage.set_bucket_website("test-bucket", {"index_document": "index.html"})
|
||||
storage.set_bucket_website("test-bucket", None)
|
||||
assert storage.get_bucket_website("test-bucket") is None
|
||||
|
||||
def test_nonexistent_bucket(self, storage):
|
||||
with pytest.raises(Exception):
|
||||
storage.get_bucket_website("no-such-bucket")
|
||||
|
||||
|
||||
class TestS3WebsiteAPI:
|
||||
def test_put_website_config(self, website_client, signer):
|
||||
headers = signer("PUT", "/site-bucket")
|
||||
assert website_client.put("/site-bucket", headers=headers).status_code == 200
|
||||
|
||||
xml_body = b"""<WebsiteConfiguration>
|
||||
<IndexDocument><Suffix>index.html</Suffix></IndexDocument>
|
||||
<ErrorDocument><Key>404.html</Key></ErrorDocument>
|
||||
</WebsiteConfiguration>"""
|
||||
headers = signer("PUT", "/site-bucket?website",
|
||||
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||
headers=headers, data=xml_body, content_type="application/xml")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_get_website_config(self, website_client, signer, storage):
|
||||
storage.create_bucket("site-bucket")
|
||||
storage.set_bucket_website("site-bucket", {
|
||||
"index_document": "index.html",
|
||||
"error_document": "error.html",
|
||||
})
|
||||
|
||||
headers = signer("GET", "/site-bucket?website")
|
||||
resp = website_client.get("/site-bucket", query_string={"website": ""}, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
root = fromstring(resp.data)
|
||||
suffix = root.find(".//{http://s3.amazonaws.com/doc/2006-03-01/}Suffix")
|
||||
if suffix is None:
|
||||
suffix = root.find(".//Suffix")
|
||||
assert suffix is not None
|
||||
assert suffix.text == "index.html"
|
||||
|
||||
def test_get_website_config_not_set(self, website_client, signer, storage):
|
||||
storage.create_bucket("no-website")
|
||||
headers = signer("GET", "/no-website?website")
|
||||
resp = website_client.get("/no-website", query_string={"website": ""}, headers=headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_website_config(self, website_client, signer, storage):
|
||||
storage.create_bucket("site-bucket")
|
||||
storage.set_bucket_website("site-bucket", {"index_document": "index.html"})
|
||||
|
||||
headers = signer("DELETE", "/site-bucket?website")
|
||||
resp = website_client.delete("/site-bucket", query_string={"website": ""}, headers=headers)
|
||||
assert resp.status_code == 204
|
||||
assert storage.get_bucket_website("site-bucket") is None
|
||||
|
||||
def test_put_website_missing_index(self, website_client, signer, storage):
|
||||
storage.create_bucket("site-bucket")
|
||||
xml_body = b"""<WebsiteConfiguration>
|
||||
<ErrorDocument><Key>error.html</Key></ErrorDocument>
|
||||
</WebsiteConfiguration>"""
|
||||
headers = signer("PUT", "/site-bucket?website",
|
||||
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||
headers=headers, data=xml_body, content_type="application/xml")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_put_website_slash_in_suffix(self, website_client, signer, storage):
|
||||
storage.create_bucket("site-bucket")
|
||||
xml_body = b"""<WebsiteConfiguration>
|
||||
<IndexDocument><Suffix>path/index.html</Suffix></IndexDocument>
|
||||
</WebsiteConfiguration>"""
|
||||
headers = signer("PUT", "/site-bucket?website",
|
||||
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||
headers=headers, data=xml_body, content_type="application/xml")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_put_website_malformed_xml(self, website_client, signer, storage):
|
||||
storage.create_bucket("site-bucket")
|
||||
xml_body = b"not xml at all"
|
||||
headers = signer("PUT", "/site-bucket?website",
|
||||
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||
headers=headers, data=xml_body, content_type="application/xml")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_website_disabled(self, client, signer):
|
||||
headers = signer("PUT", "/test-bucket")
|
||||
assert client.put("/test-bucket", headers=headers).status_code == 200
|
||||
headers = signer("GET", "/test-bucket?website")
|
||||
resp = client.get("/test-bucket", query_string={"website": ""}, headers=headers)
|
||||
assert resp.status_code == 400
|
||||
assert b"not enabled" in resp.data
|
||||
|
||||
|
||||
class TestAdminWebsiteDomains:
|
||||
def _admin_headers(self, signer):
|
||||
return signer("GET", "/admin/website-domains")
|
||||
|
||||
def test_list_empty(self, website_client, signer):
|
||||
headers = self._admin_headers(signer)
|
||||
resp = website_client.get("/admin/website-domains", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == []
|
||||
|
||||
def test_create_mapping(self, website_client, signer, storage):
|
||||
storage.create_bucket("my-site")
|
||||
headers = signer("POST", "/admin/website-domains",
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({"domain": "example.com", "bucket": "my-site"}).encode())
|
||||
resp = website_client.post("/admin/website-domains",
|
||||
headers=headers,
|
||||
json={"domain": "example.com", "bucket": "my-site"})
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert data["domain"] == "example.com"
|
||||
assert data["bucket"] == "my-site"
|
||||
|
||||
def test_create_duplicate(self, website_client, signer, storage):
|
||||
storage.create_bucket("my-site")
|
||||
body = json.dumps({"domain": "dup.com", "bucket": "my-site"}).encode()
|
||||
headers = signer("POST", "/admin/website-domains",
|
||||
headers={"Content-Type": "application/json"}, body=body)
|
||||
website_client.post("/admin/website-domains", headers=headers,
|
||||
json={"domain": "dup.com", "bucket": "my-site"})
|
||||
headers = signer("POST", "/admin/website-domains",
|
||||
headers={"Content-Type": "application/json"}, body=body)
|
||||
resp = website_client.post("/admin/website-domains", headers=headers,
|
||||
json={"domain": "dup.com", "bucket": "my-site"})
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_create_missing_domain(self, website_client, signer, storage):
|
||||
storage.create_bucket("my-site")
|
||||
body = json.dumps({"bucket": "my-site"}).encode()
|
||||
headers = signer("POST", "/admin/website-domains",
|
||||
headers={"Content-Type": "application/json"}, body=body)
|
||||
resp = website_client.post("/admin/website-domains", headers=headers,
|
||||
json={"bucket": "my-site"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_nonexistent_bucket(self, website_client, signer):
|
||||
body = json.dumps({"domain": "x.com", "bucket": "no-such"}).encode()
|
||||
headers = signer("POST", "/admin/website-domains",
|
||||
headers={"Content-Type": "application/json"}, body=body)
|
||||
resp = website_client.post("/admin/website-domains", headers=headers,
|
||||
json={"domain": "x.com", "bucket": "no-such"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_mapping(self, website_client, signer, storage):
|
||||
storage.create_bucket("my-site")
|
||||
body = json.dumps({"domain": "get.com", "bucket": "my-site"}).encode()
|
||||
headers = signer("POST", "/admin/website-domains",
|
||||
headers={"Content-Type": "application/json"}, body=body)
|
||||
website_client.post("/admin/website-domains", headers=headers,
|
||||
json={"domain": "get.com", "bucket": "my-site"})
|
||||
|
||||
headers = signer("GET", "/admin/website-domains/get.com")
|
||||
resp = website_client.get("/admin/website-domains/get.com", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["bucket"] == "my-site"
|
||||
|
||||
def test_get_nonexistent(self, website_client, signer):
|
||||
headers = signer("GET", "/admin/website-domains/nope.com")
|
||||
resp = website_client.get("/admin/website-domains/nope.com", headers=headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_mapping(self, website_client, signer, storage):
|
||||
storage.create_bucket("old-bucket")
|
||||
storage.create_bucket("new-bucket")
|
||||
body = json.dumps({"domain": "upd.com", "bucket": "old-bucket"}).encode()
|
||||
headers = signer("POST", "/admin/website-domains",
|
||||
headers={"Content-Type": "application/json"}, body=body)
|
||||
website_client.post("/admin/website-domains", headers=headers,
|
||||
json={"domain": "upd.com", "bucket": "old-bucket"})
|
||||
|
||||
body = json.dumps({"bucket": "new-bucket"}).encode()
|
||||
headers = signer("PUT", "/admin/website-domains/upd.com",
|
||||
headers={"Content-Type": "application/json"}, body=body)
|
||||
resp = website_client.put("/admin/website-domains/upd.com", headers=headers,
|
||||
json={"bucket": "new-bucket"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["bucket"] == "new-bucket"
|
||||
|
||||
def test_delete_mapping(self, website_client, signer, storage):
|
||||
storage.create_bucket("del-bucket")
|
||||
body = json.dumps({"domain": "del.com", "bucket": "del-bucket"}).encode()
|
||||
headers = signer("POST", "/admin/website-domains",
|
||||
headers={"Content-Type": "application/json"}, body=body)
|
||||
website_client.post("/admin/website-domains", headers=headers,
|
||||
json={"domain": "del.com", "bucket": "del-bucket"})
|
||||
|
||||
headers = signer("DELETE", "/admin/website-domains/del.com")
|
||||
resp = website_client.delete("/admin/website-domains/del.com", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_delete_nonexistent(self, website_client, signer):
|
||||
headers = signer("DELETE", "/admin/website-domains/nope.com")
|
||||
resp = website_client.delete("/admin/website-domains/nope.com", headers=headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_disabled(self, website_client, signer):
|
||||
with website_client.application.test_request_context():
|
||||
website_client.application.config["WEBSITE_HOSTING_ENABLED"] = False
|
||||
headers = signer("GET", "/admin/website-domains")
|
||||
resp = website_client.get("/admin/website-domains", headers=headers)
|
||||
assert resp.status_code == 400
|
||||
website_client.application.config["WEBSITE_HOSTING_ENABLED"] = True
|
||||
|
||||
|
||||
class TestWebsiteServing:
|
||||
def _setup_website(self, storage, website_app):
|
||||
storage.create_bucket("my-site")
|
||||
storage.put_object("my-site", "index.html", _stream(b"<h1>Home</h1>"))
|
||||
storage.put_object("my-site", "about.html", _stream(b"<h1>About</h1>"))
|
||||
storage.put_object("my-site", "assets/style.css", _stream(b"body { color: red; }"))
|
||||
storage.put_object("my-site", "sub/index.html", _stream(b"<h1>Sub</h1>"))
|
||||
storage.put_object("my-site", "404.html", _stream(b"<h1>Not Found</h1>"))
|
||||
storage.set_bucket_website("my-site", {
|
||||
"index_document": "index.html",
|
||||
"error_document": "404.html",
|
||||
})
|
||||
store = website_app.extensions["website_domains"]
|
||||
store.set_mapping("mysite.example.com", "my-site")
|
||||
|
||||
def test_serve_index(self, website_client, storage, website_app):
|
||||
self._setup_website(storage, website_app)
|
||||
resp = website_client.get("/", headers={"Host": "mysite.example.com"})
|
||||
assert resp.status_code == 200
|
||||
assert b"<h1>Home</h1>" in resp.data
|
||||
assert "text/html" in resp.content_type
|
||||
|
||||
def test_serve_specific_file(self, website_client, storage, website_app):
|
||||
self._setup_website(storage, website_app)
|
||||
resp = website_client.get("/about.html", headers={"Host": "mysite.example.com"})
|
||||
assert resp.status_code == 200
|
||||
assert b"<h1>About</h1>" in resp.data
|
||||
|
||||
def test_serve_css(self, website_client, storage, website_app):
|
||||
self._setup_website(storage, website_app)
|
||||
resp = website_client.get("/assets/style.css", headers={"Host": "mysite.example.com"})
|
||||
assert resp.status_code == 200
|
||||
assert b"body { color: red; }" in resp.data
|
||||
assert "text/css" in resp.content_type
|
||||
|
||||
def test_serve_subdirectory_index(self, website_client, storage, website_app):
|
||||
self._setup_website(storage, website_app)
|
||||
resp = website_client.get("/sub/", headers={"Host": "mysite.example.com"})
|
||||
assert resp.status_code == 200
|
||||
assert b"<h1>Sub</h1>" in resp.data
|
||||
|
||||
def test_serve_subdirectory_no_trailing_slash(self, website_client, storage, website_app):
|
||||
self._setup_website(storage, website_app)
|
||||
resp = website_client.get("/sub", headers={"Host": "mysite.example.com"})
|
||||
assert resp.status_code == 200
|
||||
assert b"<h1>Sub</h1>" in resp.data
|
||||
|
||||
def test_serve_error_document(self, website_client, storage, website_app):
|
||||
self._setup_website(storage, website_app)
|
||||
resp = website_client.get("/nonexistent.html", headers={"Host": "mysite.example.com"})
|
||||
assert resp.status_code == 404
|
||||
assert b"<h1>Not Found</h1>" in resp.data
|
||||
|
||||
def test_unmapped_host_passes_through(self, website_client, storage, website_app):
|
||||
self._setup_website(storage, website_app)
|
||||
resp = website_client.get("/", headers={"Host": "unknown.example.com"})
|
||||
assert resp.status_code != 200 or b"<h1>Home</h1>" not in resp.data
|
||||
|
||||
def test_head_request(self, website_client, storage, website_app):
|
||||
self._setup_website(storage, website_app)
|
||||
resp = website_client.head("/index.html", headers={"Host": "mysite.example.com"})
|
||||
assert resp.status_code == 200
|
||||
assert "Content-Length" in resp.headers
|
||||
assert resp.data == b""
|
||||
|
||||
def test_post_not_intercepted(self, website_client, storage, website_app):
|
||||
self._setup_website(storage, website_app)
|
||||
resp = website_client.post("/index.html", headers={"Host": "mysite.example.com"})
|
||||
assert resp.status_code != 200 or b"<h1>Home</h1>" not in resp.data
|
||||
|
||||
def test_bucket_deleted(self, website_client, storage, website_app):
|
||||
self._setup_website(storage, website_app)
|
||||
for obj in storage.list_objects_all("my-site"):
|
||||
storage.delete_object("my-site", obj.key)
|
||||
storage.delete_bucket("my-site")
|
||||
resp = website_client.get("/", headers={"Host": "mysite.example.com"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_no_website_config(self, website_client, storage, website_app):
|
||||
storage.create_bucket("bare-bucket")
|
||||
store = website_app.extensions["website_domains"]
|
||||
store.set_mapping("bare.example.com", "bare-bucket")
|
||||
resp = website_client.get("/", headers={"Host": "bare.example.com"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_host_with_port(self, website_client, storage, website_app):
|
||||
self._setup_website(storage, website_app)
|
||||
resp = website_client.get("/", headers={"Host": "mysite.example.com:5000"})
|
||||
assert resp.status_code == 200
|
||||
assert b"<h1>Home</h1>" in resp.data
|
||||
|
||||
def test_no_error_document(self, website_client, storage, website_app):
|
||||
storage.create_bucket("no-err")
|
||||
storage.put_object("no-err", "index.html", _stream(b"<h1>Home</h1>"))
|
||||
storage.set_bucket_website("no-err", {"index_document": "index.html"})
|
||||
store = website_app.extensions["website_domains"]
|
||||
store.set_mapping("noerr.example.com", "no-err")
|
||||
resp = website_client.get("/missing.html", headers={"Host": "noerr.example.com"})
|
||||
assert resp.status_code == 404
|
||||
assert b"Not Found" in resp.data
|
||||
Reference in New Issue
Block a user