Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52660570c1 | |||
| 67f057ca1c | |||
| 35f61313e0 | |||
| 01e79e6993 | |||
| 1e3c4b545f | |||
| c470cfb576 | |||
| 4ecd32a554 | |||
| aa6d7c4d28 | |||
| 6e6d6d32bf | |||
| 54705ab9c4 | |||
| d96955deee | |||
| 77a46d0725 | |||
| 0f750b9d89 | |||
| e0dee9db36 | |||
| 126657c99f | |||
| 07fb1ac773 | |||
| 147962e1dd | |||
| 2643a79121 | |||
| e9a035827b | |||
| 033b8a82be | |||
| e76c311231 | |||
| cbdf1a27c8 | |||
| 4a60cb269a | |||
| ebe7f6222d | |||
| 70b61fd8e6 | |||
| 85181f0be6 | |||
| a779b002d7 |
@@ -1,12 +1,10 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
FROM python:3.14.3-slim
|
||||||
FROM python:3.12.12-slim
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build deps for any wheels that need compilation, then clean up
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends build-essential \
|
&& apt-get install -y --no-install-recommends build-essential \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -16,10 +14,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Make entrypoint executable
|
|
||||||
RUN chmod +x docker-entrypoint.sh
|
RUN chmod +x docker-entrypoint.sh
|
||||||
|
|
||||||
# Create data directory and set permissions
|
|
||||||
RUN mkdir -p /app/data \
|
RUN mkdir -p /app/data \
|
||||||
&& useradd -m -u 1000 myfsio \
|
&& useradd -m -u 1000 myfsio \
|
||||||
&& chown -R myfsio:myfsio /app
|
&& chown -R myfsio:myfsio /app
|
||||||
|
|||||||
172
app/__init__.py
172
app/__init__.py
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html as html_module
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -10,7 +12,7 @@ from pathlib import Path
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, Dict, List, Optional
|
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_cors import CORS
|
||||||
from flask_wtf.csrf import CSRFError
|
from flask_wtf.csrf import CSRFError
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
@@ -32,8 +34,9 @@ from .object_lock import ObjectLockService
|
|||||||
from .replication import ReplicationManager
|
from .replication import ReplicationManager
|
||||||
from .secret_store import EphemeralSecretStore
|
from .secret_store import EphemeralSecretStore
|
||||||
from .site_registry import SiteRegistry, SiteInfo
|
from .site_registry import SiteRegistry, SiteInfo
|
||||||
from .storage import ObjectStorage
|
from .storage import ObjectStorage, StorageError
|
||||||
from .version import get_version
|
from .version import get_version
|
||||||
|
from .website_domains import WebsiteDomainStore
|
||||||
|
|
||||||
|
|
||||||
def _migrate_config_file(active_path: Path, legacy_paths: List[Path]) -> Path:
|
def _migrate_config_file(active_path: Path, legacy_paths: List[Path]) -> Path:
|
||||||
@@ -223,6 +226,19 @@ def create_app(
|
|||||||
app.extensions["access_logging"] = access_logging_service
|
app.extensions["access_logging"] = access_logging_service
|
||||||
app.extensions["site_registry"] = site_registry
|
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(
|
||||||
|
api_base_url=api_base,
|
||||||
|
region=app.config.get("AWS_REGION", "us-east-1"),
|
||||||
|
)
|
||||||
|
|
||||||
operation_metrics_collector = None
|
operation_metrics_collector = None
|
||||||
if app.config.get("OPERATION_METRICS_ENABLED", False):
|
if app.config.get("OPERATION_METRICS_ENABLED", False):
|
||||||
operation_metrics_collector = OperationMetricsCollector(
|
operation_metrics_collector = OperationMetricsCollector(
|
||||||
@@ -263,11 +279,37 @@ def create_app(
|
|||||||
|
|
||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def internal_error(error):
|
def internal_error(error):
|
||||||
return render_template('500.html'), 500
|
wants_html = request.accept_mimetypes.accept_html
|
||||||
|
path = request.path or ""
|
||||||
|
if include_ui and wants_html and (path.startswith("/ui") or path == "/"):
|
||||||
|
return render_template('500.html'), 500
|
||||||
|
error_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<Error>'
|
||||||
|
'<Code>InternalError</Code>'
|
||||||
|
'<Message>An internal server error occurred</Message>'
|
||||||
|
f'<Resource>{path}</Resource>'
|
||||||
|
f'<RequestId>{getattr(g, "request_id", "-")}</RequestId>'
|
||||||
|
'</Error>'
|
||||||
|
)
|
||||||
|
return error_xml, 500, {'Content-Type': 'application/xml'}
|
||||||
|
|
||||||
@app.errorhandler(CSRFError)
|
@app.errorhandler(CSRFError)
|
||||||
def handle_csrf_error(e):
|
def handle_csrf_error(e):
|
||||||
return render_template('csrf_error.html', reason=e.description), 400
|
wants_html = request.accept_mimetypes.accept_html
|
||||||
|
path = request.path or ""
|
||||||
|
if include_ui and wants_html and (path.startswith("/ui") or path == "/"):
|
||||||
|
return render_template('csrf_error.html', reason=e.description), 400
|
||||||
|
error_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<Error>'
|
||||||
|
'<Code>CSRFError</Code>'
|
||||||
|
f'<Message>{e.description}</Message>'
|
||||||
|
f'<Resource>{path}</Resource>'
|
||||||
|
f'<RequestId>{getattr(g, "request_id", "-")}</RequestId>'
|
||||||
|
'</Error>'
|
||||||
|
)
|
||||||
|
return error_xml, 400, {'Content-Type': 'application/xml'}
|
||||||
|
|
||||||
@app.template_filter("filesizeformat")
|
@app.template_filter("filesizeformat")
|
||||||
def filesizeformat(value: int) -> str:
|
def filesizeformat(value: int) -> str:
|
||||||
@@ -439,6 +481,128 @@ def _configure_logging(app: Flask) -> None:
|
|||||||
extra={"path": request.path, "method": request.method, "remote_addr": request.remote_addr},
|
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
|
@app.after_request
|
||||||
def _log_request_end(response):
|
def _log_request_end(response):
|
||||||
duration_ms = 0.0
|
duration_ms = 0.0
|
||||||
|
|||||||
101
app/admin_api.py
101
app/admin_api.py
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
@@ -16,6 +17,7 @@ from .extensions import limiter
|
|||||||
from .iam import IamError, Principal
|
from .iam import IamError, Principal
|
||||||
from .replication import ReplicationManager
|
from .replication import ReplicationManager
|
||||||
from .site_registry import PeerSite, SiteInfo, SiteRegistry
|
from .site_registry import PeerSite, SiteInfo, SiteRegistry
|
||||||
|
from .website_domains import WebsiteDomainStore
|
||||||
|
|
||||||
|
|
||||||
def _is_safe_url(url: str, allow_internal: bool = False) -> bool:
|
def _is_safe_url(url: str, allow_internal: bool = False) -> bool:
|
||||||
@@ -354,6 +356,10 @@ def update_peer_site(site_id: str):
|
|||||||
if region_error:
|
if region_error:
|
||||||
return _json_error("ValidationError", region_error, 400)
|
return _json_error("ValidationError", region_error, 400)
|
||||||
|
|
||||||
|
if "connection_id" in payload:
|
||||||
|
if payload["connection_id"] and not _connections().get(payload["connection_id"]):
|
||||||
|
return _json_error("ValidationError", f"Connection '{payload['connection_id']}' not found", 400)
|
||||||
|
|
||||||
peer = PeerSite(
|
peer = PeerSite(
|
||||||
site_id=site_id,
|
site_id=site_id,
|
||||||
endpoint=payload.get("endpoint", existing.endpoint),
|
endpoint=payload.get("endpoint", existing.endpoint),
|
||||||
@@ -668,3 +674,98 @@ def check_bidirectional_status(site_id: str):
|
|||||||
result["is_fully_configured"] = len(error_issues) == 0 and len(local_bidir_rules) > 0
|
result["is_fully_configured"] = len(error_issues) == 0 and len(local_bidir_rules) > 0
|
||||||
|
|
||||||
return jsonify(result)
|
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)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import re
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from fnmatch import fnmatch, translate
|
from fnmatch import fnmatch, translate
|
||||||
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
||||||
|
|
||||||
@@ -13,9 +14,14 @@ from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
|||||||
RESOURCE_PREFIX = "arn:aws:s3:::"
|
RESOURCE_PREFIX = "arn:aws:s3:::"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def _compile_pattern(pattern: str) -> Pattern[str]:
|
||||||
|
return re.compile(translate(pattern), re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _match_string_like(value: str, pattern: str) -> bool:
|
def _match_string_like(value: str, pattern: str) -> bool:
|
||||||
regex = translate(pattern)
|
compiled = _compile_pattern(pattern)
|
||||||
return bool(re.match(regex, value, re.IGNORECASE))
|
return bool(compiled.match(value))
|
||||||
|
|
||||||
|
|
||||||
def _ip_in_cidr(ip_str: str, cidr: str) -> bool:
|
def _ip_in_cidr(ip_str: str, cidr: str) -> bool:
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ class GzipMiddleware:
|
|||||||
content_type = None
|
content_type = None
|
||||||
content_length = None
|
content_length = None
|
||||||
should_compress = False
|
should_compress = False
|
||||||
|
passthrough = False
|
||||||
exc_info_holder = [None]
|
exc_info_holder = [None]
|
||||||
|
|
||||||
def custom_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None):
|
def custom_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None):
|
||||||
nonlocal response_started, status_code, response_headers, content_type, content_length, should_compress
|
nonlocal response_started, status_code, response_headers, content_type, content_length, should_compress, passthrough
|
||||||
response_started = True
|
response_started = True
|
||||||
status_code = int(status.split(' ', 1)[0])
|
status_code = int(status.split(' ', 1)[0])
|
||||||
response_headers = list(headers)
|
response_headers = list(headers)
|
||||||
@@ -50,18 +51,32 @@ class GzipMiddleware:
|
|||||||
if name_lower == 'content-type':
|
if name_lower == 'content-type':
|
||||||
content_type = value.split(';')[0].strip().lower()
|
content_type = value.split(';')[0].strip().lower()
|
||||||
elif name_lower == 'content-length':
|
elif name_lower == 'content-length':
|
||||||
content_length = int(value)
|
try:
|
||||||
|
content_length = int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
elif name_lower == 'content-encoding':
|
elif name_lower == 'content-encoding':
|
||||||
should_compress = False
|
passthrough = True
|
||||||
|
return start_response(status, headers, exc_info)
|
||||||
|
elif name_lower == 'x-stream-response':
|
||||||
|
passthrough = True
|
||||||
return start_response(status, headers, exc_info)
|
return start_response(status, headers, exc_info)
|
||||||
|
|
||||||
if content_type and content_type in COMPRESSIBLE_MIMES:
|
if content_type and content_type in COMPRESSIBLE_MIMES:
|
||||||
if content_length is None or content_length >= self.min_size:
|
if content_length is None or content_length >= self.min_size:
|
||||||
should_compress = True
|
should_compress = True
|
||||||
|
else:
|
||||||
|
passthrough = True
|
||||||
|
return start_response(status, headers, exc_info)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
response_body = b''.join(self.app(environ, custom_start_response))
|
app_iter = self.app(environ, custom_start_response)
|
||||||
|
|
||||||
|
if passthrough:
|
||||||
|
return app_iter
|
||||||
|
|
||||||
|
response_body = b''.join(app_iter)
|
||||||
|
|
||||||
if not response_started:
|
if not response_started:
|
||||||
return [response_body]
|
return [response_body]
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ class AppConfig:
|
|||||||
num_trusted_proxies: int
|
num_trusted_proxies: int
|
||||||
allowed_redirect_hosts: list[str]
|
allowed_redirect_hosts: list[str]
|
||||||
allow_internal_endpoints: bool
|
allow_internal_endpoints: bool
|
||||||
|
website_hosting_enabled: bool
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig":
|
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_raw = _get("ALLOWED_REDIRECT_HOSTS", "")
|
||||||
allowed_redirect_hosts = [h.strip() for h in str(allowed_redirect_hosts_raw).split(",") if h.strip()]
|
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"}
|
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,
|
return cls(storage_root=storage_root,
|
||||||
max_upload_size=max_upload_size,
|
max_upload_size=max_upload_size,
|
||||||
@@ -403,7 +405,8 @@ class AppConfig:
|
|||||||
ratelimit_admin=ratelimit_admin,
|
ratelimit_admin=ratelimit_admin,
|
||||||
num_trusted_proxies=num_trusted_proxies,
|
num_trusted_proxies=num_trusted_proxies,
|
||||||
allowed_redirect_hosts=allowed_redirect_hosts,
|
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]:
|
def validate_and_report(self) -> list[str]:
|
||||||
"""Validate configuration and return a list of warnings/issues.
|
"""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})")
|
print(f" ENCRYPTION: Enabled (Master key: {self.encryption_master_key_path})")
|
||||||
if self.kms_enabled:
|
if self.kms_enabled:
|
||||||
print(f" KMS: Enabled (Keys: {self.kms_keys_path})")
|
print(f" KMS: Enabled (Keys: {self.kms_keys_path})")
|
||||||
|
if self.website_hosting_enabled:
|
||||||
|
print(f" WEBSITE_HOSTING: Enabled")
|
||||||
def _auto(flag: bool) -> str:
|
def _auto(flag: bool) -> str:
|
||||||
return " (auto)" if flag else ""
|
return " (auto)" if flag else ""
|
||||||
print(f" SERVER_THREADS: {self.server_threads}{_auto(self.server_threads_auto)}")
|
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,
|
"NUM_TRUSTED_PROXIES": self.num_trusted_proxies,
|
||||||
"ALLOWED_REDIRECT_HOSTS": self.allowed_redirect_hosts,
|
"ALLOWED_REDIRECT_HOSTS": self.allowed_redirect_hosts,
|
||||||
"ALLOW_INTERNAL_ENDPOINTS": self.allow_internal_endpoints,
|
"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):
|
def get_bucket_quota(self, bucket_name: str):
|
||||||
return self.storage.get_bucket_quota(bucket_name)
|
return self.storage.get_bucket_quota(bucket_name)
|
||||||
|
|
||||||
def set_bucket_quota(self, bucket_name: str, *, max_bytes=None, max_objects=None):
|
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)
|
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:
|
def _compute_etag(self, path: Path) -> str:
|
||||||
return self.storage._compute_etag(path)
|
return self.storage._compute_etag(path)
|
||||||
|
|||||||
49
app/iam.py
49
app/iam.py
@@ -4,6 +4,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -121,7 +122,8 @@ class IamService:
|
|||||||
self._failed_attempts: Dict[str, Deque[datetime]] = {}
|
self._failed_attempts: Dict[str, Deque[datetime]] = {}
|
||||||
self._last_load_time = 0.0
|
self._last_load_time = 0.0
|
||||||
self._principal_cache: Dict[str, Tuple[Principal, float]] = {}
|
self._principal_cache: Dict[str, Tuple[Principal, float]] = {}
|
||||||
self._cache_ttl = 10.0
|
self._secret_key_cache: Dict[str, Tuple[str, float]] = {}
|
||||||
|
self._cache_ttl = float(os.environ.get("IAM_CACHE_TTL_SECONDS", "5.0"))
|
||||||
self._last_stat_check = 0.0
|
self._last_stat_check = 0.0
|
||||||
self._stat_check_interval = 1.0
|
self._stat_check_interval = 1.0
|
||||||
self._sessions: Dict[str, Dict[str, Any]] = {}
|
self._sessions: Dict[str, Dict[str, Any]] = {}
|
||||||
@@ -139,6 +141,7 @@ class IamService:
|
|||||||
if self.config_path.stat().st_mtime > self._last_load_time:
|
if self.config_path.stat().st_mtime > self._last_load_time:
|
||||||
self._load()
|
self._load()
|
||||||
self._principal_cache.clear()
|
self._principal_cache.clear()
|
||||||
|
self._secret_key_cache.clear()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -306,6 +309,18 @@ class IamService:
|
|||||||
if not self._is_allowed(principal, normalized, action):
|
if not self._is_allowed(principal, normalized, action):
|
||||||
raise IamError(f"Access denied for action '{action}' on bucket '{bucket_name}'")
|
raise IamError(f"Access denied for action '{action}' on bucket '{bucket_name}'")
|
||||||
|
|
||||||
|
def check_permissions(self, principal: Principal, bucket_name: str | None, actions: Iterable[str]) -> Dict[str, bool]:
|
||||||
|
self._maybe_reload()
|
||||||
|
bucket_name = (bucket_name or "*").lower() if bucket_name != "*" else (bucket_name or "*")
|
||||||
|
normalized_actions = {a: self._normalize_action(a) for a in actions}
|
||||||
|
results: Dict[str, bool] = {}
|
||||||
|
for original, canonical in normalized_actions.items():
|
||||||
|
if canonical not in ALLOWED_ACTIONS:
|
||||||
|
results[original] = False
|
||||||
|
else:
|
||||||
|
results[original] = self._is_allowed(principal, bucket_name, canonical)
|
||||||
|
return results
|
||||||
|
|
||||||
def buckets_for_principal(self, principal: Principal, buckets: Iterable[str]) -> List[str]:
|
def buckets_for_principal(self, principal: Principal, buckets: Iterable[str]) -> List[str]:
|
||||||
return [bucket for bucket in buckets if self._is_allowed(principal, bucket, "list")]
|
return [bucket for bucket in buckets if self._is_allowed(principal, bucket, "list")]
|
||||||
|
|
||||||
@@ -367,6 +382,9 @@ class IamService:
|
|||||||
user["secret_key"] = new_secret
|
user["secret_key"] = new_secret
|
||||||
self._save()
|
self._save()
|
||||||
self._principal_cache.pop(access_key, None)
|
self._principal_cache.pop(access_key, None)
|
||||||
|
self._secret_key_cache.pop(access_key, None)
|
||||||
|
from .s3_api import clear_signing_key_cache
|
||||||
|
clear_signing_key_cache()
|
||||||
self._load()
|
self._load()
|
||||||
return new_secret
|
return new_secret
|
||||||
|
|
||||||
@@ -385,6 +403,10 @@ class IamService:
|
|||||||
raise IamError("User not found")
|
raise IamError("User not found")
|
||||||
self._raw_config["users"] = remaining
|
self._raw_config["users"] = remaining
|
||||||
self._save()
|
self._save()
|
||||||
|
self._principal_cache.pop(access_key, None)
|
||||||
|
self._secret_key_cache.pop(access_key, None)
|
||||||
|
from .s3_api import clear_signing_key_cache
|
||||||
|
clear_signing_key_cache()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def update_user_policies(self, access_key: str, policies: Sequence[Dict[str, Any]]) -> None:
|
def update_user_policies(self, access_key: str, policies: Sequence[Dict[str, Any]]) -> None:
|
||||||
@@ -519,11 +541,13 @@ class IamService:
|
|||||||
return candidate if candidate in ALLOWED_ACTIONS else ""
|
return candidate if candidate in ALLOWED_ACTIONS else ""
|
||||||
|
|
||||||
def _write_default(self) -> None:
|
def _write_default(self) -> None:
|
||||||
|
access_key = secrets.token_hex(12)
|
||||||
|
secret_key = secrets.token_urlsafe(32)
|
||||||
default = {
|
default = {
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"access_key": "localadmin",
|
"access_key": access_key,
|
||||||
"secret_key": "localadmin",
|
"secret_key": secret_key,
|
||||||
"display_name": "Local Admin",
|
"display_name": "Local Admin",
|
||||||
"policies": [
|
"policies": [
|
||||||
{"bucket": "*", "actions": list(ALLOWED_ACTIONS)}
|
{"bucket": "*", "actions": list(ALLOWED_ACTIONS)}
|
||||||
@@ -532,6 +556,14 @@ class IamService:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
self.config_path.write_text(json.dumps(default, indent=2))
|
self.config_path.write_text(json.dumps(default, indent=2))
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print("MYFSIO FIRST RUN - ADMIN CREDENTIALS GENERATED")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"Access Key: {access_key}")
|
||||||
|
print(f"Secret Key: {secret_key}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"Missed this? Check: {self.config_path}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
def _generate_access_key(self) -> str:
|
def _generate_access_key(self) -> str:
|
||||||
return secrets.token_hex(8)
|
return secrets.token_hex(8)
|
||||||
@@ -546,10 +578,19 @@ class IamService:
|
|||||||
raise IamError("User not found")
|
raise IamError("User not found")
|
||||||
|
|
||||||
def get_secret_key(self, access_key: str) -> str | None:
|
def get_secret_key(self, access_key: str) -> str | None:
|
||||||
|
now = time.time()
|
||||||
|
cached = self._secret_key_cache.get(access_key)
|
||||||
|
if cached:
|
||||||
|
secret_key, cached_time = cached
|
||||||
|
if now - cached_time < self._cache_ttl:
|
||||||
|
return secret_key
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
record = self._users.get(access_key)
|
||||||
if record:
|
if record:
|
||||||
return record["secret_key"]
|
secret_key = record["secret_key"]
|
||||||
|
self._secret_key_cache[access_key] = (secret_key, now)
|
||||||
|
return secret_key
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_principal(self, access_key: str) -> Principal | None:
|
def get_principal(self, access_key: str) -> Principal | None:
|
||||||
|
|||||||
30
app/kms.py
30
app/kms.py
@@ -160,6 +160,7 @@ class KMSManager:
|
|||||||
self.generate_data_key_max_bytes = generate_data_key_max_bytes
|
self.generate_data_key_max_bytes = generate_data_key_max_bytes
|
||||||
self._keys: Dict[str, KMSKey] = {}
|
self._keys: Dict[str, KMSKey] = {}
|
||||||
self._master_key: bytes | None = None
|
self._master_key: bytes | None = None
|
||||||
|
self._master_aesgcm: AESGCM | None = None
|
||||||
self._loaded = False
|
self._loaded = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -191,6 +192,7 @@ class KMSManager:
|
|||||||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
else:
|
else:
|
||||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
self._master_aesgcm = AESGCM(self._master_key)
|
||||||
return self._master_key
|
return self._master_key
|
||||||
|
|
||||||
def _load_keys(self) -> None:
|
def _load_keys(self) -> None:
|
||||||
@@ -231,18 +233,16 @@ class KMSManager:
|
|||||||
_set_secure_file_permissions(self.keys_path)
|
_set_secure_file_permissions(self.keys_path)
|
||||||
|
|
||||||
def _encrypt_key_material(self, key_material: bytes) -> bytes:
|
def _encrypt_key_material(self, key_material: bytes) -> bytes:
|
||||||
"""Encrypt key material with the master key."""
|
_ = self.master_key
|
||||||
aesgcm = AESGCM(self.master_key)
|
|
||||||
nonce = secrets.token_bytes(12)
|
nonce = secrets.token_bytes(12)
|
||||||
ciphertext = aesgcm.encrypt(nonce, key_material, None)
|
ciphertext = self._master_aesgcm.encrypt(nonce, key_material, None)
|
||||||
return nonce + ciphertext
|
return nonce + ciphertext
|
||||||
|
|
||||||
def _decrypt_key_material(self, encrypted: bytes) -> bytes:
|
def _decrypt_key_material(self, encrypted: bytes) -> bytes:
|
||||||
"""Decrypt key material with the master key."""
|
_ = self.master_key
|
||||||
aesgcm = AESGCM(self.master_key)
|
|
||||||
nonce = encrypted[:12]
|
nonce = encrypted[:12]
|
||||||
ciphertext = encrypted[12:]
|
ciphertext = encrypted[12:]
|
||||||
return aesgcm.decrypt(nonce, ciphertext, None)
|
return self._master_aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
|
||||||
def create_key(self, description: str = "", key_id: str | None = None) -> KMSKey:
|
def create_key(self, description: str = "", key_id: str | None = None) -> KMSKey:
|
||||||
"""Create a new KMS key."""
|
"""Create a new KMS key."""
|
||||||
@@ -404,22 +404,6 @@ class KMSManager:
|
|||||||
plaintext, _ = self.decrypt(encrypted_key, context)
|
plaintext, _ = self.decrypt(encrypted_key, context)
|
||||||
return plaintext
|
return plaintext
|
||||||
|
|
||||||
def get_provider(self, key_id: str | None = None) -> KMSEncryptionProvider:
|
|
||||||
"""Get an encryption provider for a specific key."""
|
|
||||||
self._load_keys()
|
|
||||||
|
|
||||||
if key_id is None:
|
|
||||||
if not self._keys:
|
|
||||||
key = self.create_key("Default KMS Key")
|
|
||||||
key_id = key.key_id
|
|
||||||
else:
|
|
||||||
key_id = next(iter(self._keys.keys()))
|
|
||||||
|
|
||||||
if key_id not in self._keys:
|
|
||||||
raise EncryptionError(f"Key not found: {key_id}")
|
|
||||||
|
|
||||||
return KMSEncryptionProvider(self, key_id)
|
|
||||||
|
|
||||||
def re_encrypt(self, ciphertext: bytes, destination_key_id: str,
|
def re_encrypt(self, ciphertext: bytes, destination_key_id: str,
|
||||||
source_context: Dict[str, str] | None = None,
|
source_context: Dict[str, str] | None = None,
|
||||||
destination_context: Dict[str, str] | None = None) -> bytes:
|
destination_context: Dict[str, str] | None = None) -> bytes:
|
||||||
|
|||||||
@@ -176,11 +176,12 @@ class ReplicationFailureStore:
|
|||||||
self.storage_root = storage_root
|
self.storage_root = storage_root
|
||||||
self.max_failures_per_bucket = max_failures_per_bucket
|
self.max_failures_per_bucket = max_failures_per_bucket
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._cache: Dict[str, List[ReplicationFailure]] = {}
|
||||||
|
|
||||||
def _get_failures_path(self, bucket_name: str) -> Path:
|
def _get_failures_path(self, bucket_name: str) -> Path:
|
||||||
return self.storage_root / ".myfsio.sys" / "buckets" / bucket_name / "replication_failures.json"
|
return self.storage_root / ".myfsio.sys" / "buckets" / bucket_name / "replication_failures.json"
|
||||||
|
|
||||||
def load_failures(self, bucket_name: str) -> List[ReplicationFailure]:
|
def _load_from_disk(self, bucket_name: str) -> List[ReplicationFailure]:
|
||||||
path = self._get_failures_path(bucket_name)
|
path = self._get_failures_path(bucket_name)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return []
|
return []
|
||||||
@@ -192,7 +193,7 @@ class ReplicationFailureStore:
|
|||||||
logger.error(f"Failed to load replication failures for {bucket_name}: {e}")
|
logger.error(f"Failed to load replication failures for {bucket_name}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def save_failures(self, bucket_name: str, failures: List[ReplicationFailure]) -> None:
|
def _save_to_disk(self, bucket_name: str, failures: List[ReplicationFailure]) -> None:
|
||||||
path = self._get_failures_path(bucket_name)
|
path = self._get_failures_path(bucket_name)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
data = {"failures": [f.to_dict() for f in failures[:self.max_failures_per_bucket]]}
|
data = {"failures": [f.to_dict() for f in failures[:self.max_failures_per_bucket]]}
|
||||||
@@ -202,6 +203,18 @@ class ReplicationFailureStore:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error(f"Failed to save replication failures for {bucket_name}: {e}")
|
logger.error(f"Failed to save replication failures for {bucket_name}: {e}")
|
||||||
|
|
||||||
|
def load_failures(self, bucket_name: str) -> List[ReplicationFailure]:
|
||||||
|
if bucket_name in self._cache:
|
||||||
|
return list(self._cache[bucket_name])
|
||||||
|
failures = self._load_from_disk(bucket_name)
|
||||||
|
self._cache[bucket_name] = failures
|
||||||
|
return list(failures)
|
||||||
|
|
||||||
|
def save_failures(self, bucket_name: str, failures: List[ReplicationFailure]) -> None:
|
||||||
|
trimmed = failures[:self.max_failures_per_bucket]
|
||||||
|
self._cache[bucket_name] = trimmed
|
||||||
|
self._save_to_disk(bucket_name, trimmed)
|
||||||
|
|
||||||
def add_failure(self, bucket_name: str, failure: ReplicationFailure) -> None:
|
def add_failure(self, bucket_name: str, failure: ReplicationFailure) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
failures = self.load_failures(bucket_name)
|
failures = self.load_failures(bucket_name)
|
||||||
@@ -227,6 +240,7 @@ class ReplicationFailureStore:
|
|||||||
|
|
||||||
def clear_failures(self, bucket_name: str) -> None:
|
def clear_failures(self, bucket_name: str) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._cache.pop(bucket_name, None)
|
||||||
path = self._get_failures_path(bucket_name)
|
path = self._get_failures_path(bucket_name)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
|||||||
154
app/s3_api.py
154
app/s3_api.py
@@ -3,12 +3,16 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional, Tuple
|
||||||
from urllib.parse import quote, urlencode, urlparse, unquote
|
from urllib.parse import quote, urlencode, urlparse, unquote
|
||||||
from xml.etree.ElementTree import Element, SubElement, tostring, ParseError
|
from xml.etree.ElementTree import Element, SubElement, tostring, ParseError
|
||||||
from defusedxml.ElementTree import fromstring
|
from defusedxml.ElementTree import fromstring
|
||||||
@@ -181,11 +185,41 @@ def _sign(key: bytes, msg: str) -> bytes:
|
|||||||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
_SIGNING_KEY_CACHE: OrderedDict[Tuple[str, str, str, str], Tuple[bytes, float]] = OrderedDict()
|
||||||
|
_SIGNING_KEY_CACHE_LOCK = threading.Lock()
|
||||||
|
_SIGNING_KEY_CACHE_TTL = 60.0
|
||||||
|
_SIGNING_KEY_CACHE_MAX_SIZE = 256
|
||||||
|
|
||||||
|
|
||||||
|
def clear_signing_key_cache() -> None:
|
||||||
|
with _SIGNING_KEY_CACHE_LOCK:
|
||||||
|
_SIGNING_KEY_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
def _get_signature_key(key: str, date_stamp: str, region_name: str, service_name: str) -> bytes:
|
def _get_signature_key(key: str, date_stamp: str, region_name: str, service_name: str) -> bytes:
|
||||||
|
cache_key = (key, date_stamp, region_name, service_name)
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
with _SIGNING_KEY_CACHE_LOCK:
|
||||||
|
cached = _SIGNING_KEY_CACHE.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
signing_key, cached_time = cached
|
||||||
|
if now - cached_time < _SIGNING_KEY_CACHE_TTL:
|
||||||
|
_SIGNING_KEY_CACHE.move_to_end(cache_key)
|
||||||
|
return signing_key
|
||||||
|
else:
|
||||||
|
del _SIGNING_KEY_CACHE[cache_key]
|
||||||
|
|
||||||
k_date = _sign(("AWS4" + key).encode("utf-8"), date_stamp)
|
k_date = _sign(("AWS4" + key).encode("utf-8"), date_stamp)
|
||||||
k_region = _sign(k_date, region_name)
|
k_region = _sign(k_date, region_name)
|
||||||
k_service = _sign(k_region, service_name)
|
k_service = _sign(k_region, service_name)
|
||||||
k_signing = _sign(k_service, "aws4_request")
|
k_signing = _sign(k_service, "aws4_request")
|
||||||
|
|
||||||
|
with _SIGNING_KEY_CACHE_LOCK:
|
||||||
|
if len(_SIGNING_KEY_CACHE) >= _SIGNING_KEY_CACHE_MAX_SIZE:
|
||||||
|
_SIGNING_KEY_CACHE.popitem(last=False)
|
||||||
|
_SIGNING_KEY_CACHE[cache_key] = (k_signing, now)
|
||||||
|
|
||||||
return k_signing
|
return k_signing
|
||||||
|
|
||||||
|
|
||||||
@@ -966,12 +1000,14 @@ def _apply_object_headers(
|
|||||||
etag: str,
|
etag: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
if file_stat is not None:
|
if file_stat is not None:
|
||||||
response.headers["Content-Length"] = str(file_stat.st_size)
|
if response.status_code != 206:
|
||||||
|
response.headers["Content-Length"] = str(file_stat.st_size)
|
||||||
response.headers["Last-Modified"] = http_date(file_stat.st_mtime)
|
response.headers["Last-Modified"] = http_date(file_stat.st_mtime)
|
||||||
response.headers["ETag"] = f'"{etag}"'
|
response.headers["ETag"] = f'"{etag}"'
|
||||||
response.headers["Accept-Ranges"] = "bytes"
|
response.headers["Accept-Ranges"] = "bytes"
|
||||||
for key, value in (metadata or {}).items():
|
for key, value in (metadata or {}).items():
|
||||||
response.headers[f"X-Amz-Meta-{key}"] = value
|
safe_value = _sanitize_header_value(str(value))
|
||||||
|
response.headers[f"X-Amz-Meta-{key}"] = safe_value
|
||||||
|
|
||||||
|
|
||||||
def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None:
|
def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None:
|
||||||
@@ -991,6 +1027,7 @@ def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None:
|
|||||||
"uploads": _bucket_uploads_handler,
|
"uploads": _bucket_uploads_handler,
|
||||||
"policy": _bucket_policy_handler,
|
"policy": _bucket_policy_handler,
|
||||||
"replication": _bucket_replication_handler,
|
"replication": _bucket_replication_handler,
|
||||||
|
"website": _bucket_website_handler,
|
||||||
}
|
}
|
||||||
requested = [key for key in handlers if key in request.args]
|
requested = [key for key in handlers if key in request.args]
|
||||||
if not requested:
|
if not requested:
|
||||||
@@ -2309,10 +2346,12 @@ def _post_object(bucket_name: str) -> Response:
|
|||||||
success_action_redirect = request.form.get("success_action_redirect")
|
success_action_redirect = request.form.get("success_action_redirect")
|
||||||
if success_action_redirect:
|
if success_action_redirect:
|
||||||
allowed_hosts = current_app.config.get("ALLOWED_REDIRECT_HOSTS", [])
|
allowed_hosts = current_app.config.get("ALLOWED_REDIRECT_HOSTS", [])
|
||||||
|
if not allowed_hosts:
|
||||||
|
allowed_hosts = [request.host]
|
||||||
parsed = urlparse(success_action_redirect)
|
parsed = urlparse(success_action_redirect)
|
||||||
if parsed.scheme not in ("http", "https"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
return _error_response("InvalidArgument", "Redirect URL must use http or https", 400)
|
return _error_response("InvalidArgument", "Redirect URL must use http or https", 400)
|
||||||
if allowed_hosts and parsed.netloc not in allowed_hosts:
|
if parsed.netloc not in allowed_hosts:
|
||||||
return _error_response("InvalidArgument", "Redirect URL host not allowed", 400)
|
return _error_response("InvalidArgument", "Redirect URL host not allowed", 400)
|
||||||
redirect_url = f"{success_action_redirect}?bucket={bucket_name}&key={quote(object_key)}&etag={meta.etag}"
|
redirect_url = f"{success_action_redirect}?bucket={bucket_name}&key={quote(object_key)}&etag={meta.etag}"
|
||||||
return Response(status=303, headers={"Location": redirect_url})
|
return Response(status=303, headers={"Location": redirect_url})
|
||||||
@@ -2740,9 +2779,14 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
return _error_response("InternalError", str(exc), 500)
|
return _error_response("InternalError", str(exc), 500)
|
||||||
else:
|
else:
|
||||||
stat = path.stat()
|
try:
|
||||||
file_size = stat.st_size
|
stat = path.stat()
|
||||||
etag = storage._compute_etag(path)
|
file_size = stat.st_size
|
||||||
|
etag = metadata.get("__etag__") or storage._compute_etag(path)
|
||||||
|
except PermissionError:
|
||||||
|
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
||||||
|
except OSError as exc:
|
||||||
|
return _error_response("InternalError", f"Failed to access object: {exc}", 500)
|
||||||
|
|
||||||
if range_header:
|
if range_header:
|
||||||
try:
|
try:
|
||||||
@@ -2783,13 +2827,22 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
return _error_response("InternalError", str(exc), 500)
|
return _error_response("InternalError", str(exc), 500)
|
||||||
else:
|
else:
|
||||||
stat = path.stat()
|
try:
|
||||||
response = Response(status=200)
|
stat = path.stat()
|
||||||
etag = storage._compute_etag(path)
|
response = Response(status=200)
|
||||||
|
etag = metadata.get("__etag__") or storage._compute_etag(path)
|
||||||
|
except PermissionError:
|
||||||
|
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
||||||
|
except OSError as exc:
|
||||||
|
return _error_response("InternalError", f"Failed to access object: {exc}", 500)
|
||||||
response.headers["Content-Type"] = mimetype
|
response.headers["Content-Type"] = mimetype
|
||||||
logged_bytes = 0
|
logged_bytes = 0
|
||||||
|
|
||||||
_apply_object_headers(response, file_stat=path.stat() if not is_encrypted else None, metadata=metadata, etag=etag)
|
try:
|
||||||
|
file_stat = path.stat() if not is_encrypted else None
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
file_stat = None
|
||||||
|
_apply_object_headers(response, file_stat=file_stat, metadata=metadata, etag=etag)
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
response_overrides = {
|
response_overrides = {
|
||||||
@@ -2912,7 +2965,11 @@ def _bucket_policy_handler(bucket_name: str) -> Response:
|
|||||||
store.delete_policy(bucket_name)
|
store.delete_policy(bucket_name)
|
||||||
current_app.logger.info("Bucket policy removed", extra={"bucket": bucket_name})
|
current_app.logger.info("Bucket policy removed", extra={"bucket": bucket_name})
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
payload = request.get_json(silent=True)
|
raw_body = request.get_data(cache=False) or b""
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return _error_response("MalformedPolicy", "Policy document must be JSON", 400)
|
||||||
if not payload:
|
if not payload:
|
||||||
return _error_response("MalformedPolicy", "Policy document must be JSON", 400)
|
return _error_response("MalformedPolicy", "Policy document must be JSON", 400)
|
||||||
try:
|
try:
|
||||||
@@ -3004,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:
|
def _parse_destination_arn(arn: str) -> tuple:
|
||||||
if not arn.startswith("arn:aws:s3:::"):
|
if not arn.startswith("arn:aws:s3:::"):
|
||||||
raise ValueError(f"Invalid ARN format: {arn}")
|
raise ValueError(f"Invalid ARN format: {arn}")
|
||||||
|
|||||||
284
app/s3_client.py
Normal file
284
app/s3_client.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Generator, Optional
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
from botocore.exceptions import ClientError, EndpointConnectionError, ConnectionClosedError
|
||||||
|
from flask import current_app, session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UI_PROXY_USER_AGENT = "MyFSIO-UIProxy/1.0"
|
||||||
|
|
||||||
|
_BOTO_ERROR_MAP = {
|
||||||
|
"NoSuchBucket": 404,
|
||||||
|
"NoSuchKey": 404,
|
||||||
|
"NoSuchUpload": 404,
|
||||||
|
"BucketAlreadyExists": 409,
|
||||||
|
"BucketAlreadyOwnedByYou": 409,
|
||||||
|
"BucketNotEmpty": 409,
|
||||||
|
"AccessDenied": 403,
|
||||||
|
"InvalidAccessKeyId": 403,
|
||||||
|
"SignatureDoesNotMatch": 403,
|
||||||
|
"InvalidBucketName": 400,
|
||||||
|
"InvalidArgument": 400,
|
||||||
|
"MalformedXML": 400,
|
||||||
|
"EntityTooLarge": 400,
|
||||||
|
"QuotaExceeded": 403,
|
||||||
|
}
|
||||||
|
|
||||||
|
_UPLOAD_REGISTRY_MAX_AGE = 86400
|
||||||
|
_UPLOAD_REGISTRY_CLEANUP_INTERVAL = 3600
|
||||||
|
|
||||||
|
|
||||||
|
class UploadRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._entries: dict[str, tuple[str, str, float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._last_cleanup = time.monotonic()
|
||||||
|
|
||||||
|
def register(self, upload_id: str, bucket_name: str, object_key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries[upload_id] = (bucket_name, object_key, time.monotonic())
|
||||||
|
self._maybe_cleanup()
|
||||||
|
|
||||||
|
def get_key(self, upload_id: str, bucket_name: str) -> Optional[str]:
|
||||||
|
with self._lock:
|
||||||
|
entry = self._entries.get(upload_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
stored_bucket, key, created_at = entry
|
||||||
|
if stored_bucket != bucket_name:
|
||||||
|
return None
|
||||||
|
if time.monotonic() - created_at > _UPLOAD_REGISTRY_MAX_AGE:
|
||||||
|
del self._entries[upload_id]
|
||||||
|
return None
|
||||||
|
return key
|
||||||
|
|
||||||
|
def remove(self, upload_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.pop(upload_id, None)
|
||||||
|
|
||||||
|
def _maybe_cleanup(self) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._last_cleanup < _UPLOAD_REGISTRY_CLEANUP_INTERVAL:
|
||||||
|
return
|
||||||
|
self._last_cleanup = now
|
||||||
|
cutoff = now - _UPLOAD_REGISTRY_MAX_AGE
|
||||||
|
stale = [uid for uid, (_, _, ts) in self._entries.items() if ts < cutoff]
|
||||||
|
for uid in stale:
|
||||||
|
del self._entries[uid]
|
||||||
|
|
||||||
|
|
||||||
|
class S3ProxyClient:
|
||||||
|
def __init__(self, api_base_url: str, region: str = "us-east-1") -> None:
|
||||||
|
if not api_base_url:
|
||||||
|
raise ValueError("api_base_url is required for S3ProxyClient")
|
||||||
|
self._api_base_url = api_base_url.rstrip("/")
|
||||||
|
self._region = region
|
||||||
|
self.upload_registry = UploadRegistry()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_base_url(self) -> str:
|
||||||
|
return self._api_base_url
|
||||||
|
|
||||||
|
def get_client(self, access_key: str, secret_key: str) -> Any:
|
||||||
|
if not access_key or not secret_key:
|
||||||
|
raise ValueError("Both access_key and secret_key are required")
|
||||||
|
config = Config(
|
||||||
|
user_agent_extra=UI_PROXY_USER_AGENT,
|
||||||
|
connect_timeout=5,
|
||||||
|
read_timeout=30,
|
||||||
|
retries={"max_attempts": 0},
|
||||||
|
signature_version="s3v4",
|
||||||
|
s3={"addressing_style": "path"},
|
||||||
|
request_checksum_calculation="when_required",
|
||||||
|
response_checksum_validation="when_required",
|
||||||
|
)
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=self._api_base_url,
|
||||||
|
aws_access_key_id=access_key,
|
||||||
|
aws_secret_access_key=secret_key,
|
||||||
|
region_name=self._region,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_proxy() -> S3ProxyClient:
|
||||||
|
proxy = current_app.extensions.get("s3_proxy")
|
||||||
|
if proxy is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"S3 proxy not configured. Set API_BASE_URL or run both API and UI servers."
|
||||||
|
)
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session_creds() -> tuple[str, str]:
|
||||||
|
secret_store = current_app.extensions["secret_store"]
|
||||||
|
secret_store.purge_expired()
|
||||||
|
token = session.get("cred_token")
|
||||||
|
if not token:
|
||||||
|
raise PermissionError("Not authenticated")
|
||||||
|
creds = secret_store.peek(token)
|
||||||
|
if not creds:
|
||||||
|
raise PermissionError("Session expired")
|
||||||
|
access_key = creds.get("access_key", "")
|
||||||
|
secret_key = creds.get("secret_key", "")
|
||||||
|
if not access_key or not secret_key:
|
||||||
|
raise PermissionError("Invalid session credentials")
|
||||||
|
return access_key, secret_key
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_s3_client() -> Any:
|
||||||
|
proxy = _get_proxy()
|
||||||
|
access_key, secret_key = _get_session_creds()
|
||||||
|
return proxy.get_client(access_key, secret_key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload_registry() -> UploadRegistry:
|
||||||
|
return _get_proxy().upload_registry
|
||||||
|
|
||||||
|
|
||||||
|
def handle_client_error(exc: ClientError) -> tuple[dict[str, str], int]:
|
||||||
|
error_info = exc.response.get("Error", {})
|
||||||
|
code = error_info.get("Code", "InternalError")
|
||||||
|
message = error_info.get("Message") or "S3 operation failed"
|
||||||
|
http_status = _BOTO_ERROR_MAP.get(code)
|
||||||
|
if http_status is None:
|
||||||
|
http_status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 500)
|
||||||
|
return {"error": message}, http_status
|
||||||
|
|
||||||
|
|
||||||
|
def handle_connection_error(exc: Exception) -> tuple[dict[str, str], int]:
|
||||||
|
logger.error("S3 API connection failed: %s", exc)
|
||||||
|
return {"error": "S3 API server is unreachable. Ensure the API server is running."}, 502
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime_display(dt: Any, display_tz: str = "UTC") -> str:
|
||||||
|
from .ui import _format_datetime_display
|
||||||
|
return _format_datetime_display(dt, display_tz)
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime_iso(dt: Any, display_tz: str = "UTC") -> str:
|
||||||
|
from .ui import _format_datetime_iso
|
||||||
|
return _format_datetime_iso(dt, display_tz)
|
||||||
|
|
||||||
|
|
||||||
|
def build_url_templates(bucket_name: str) -> dict[str, str]:
|
||||||
|
from flask import url_for
|
||||||
|
preview_t = url_for("ui.object_preview", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
delete_t = url_for("ui.delete_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
presign_t = url_for("ui.object_presign", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
versions_t = url_for("ui.object_versions", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
restore_t = url_for(
|
||||||
|
"ui.restore_object_version",
|
||||||
|
bucket_name=bucket_name,
|
||||||
|
object_key="KEY_PLACEHOLDER",
|
||||||
|
version_id="VERSION_ID_PLACEHOLDER",
|
||||||
|
)
|
||||||
|
tags_t = url_for("ui.object_tags", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
copy_t = url_for("ui.copy_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
move_t = url_for("ui.move_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
metadata_t = url_for("ui.object_metadata", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
return {
|
||||||
|
"preview": preview_t,
|
||||||
|
"download": preview_t + "?download=1",
|
||||||
|
"presign": presign_t,
|
||||||
|
"delete": delete_t,
|
||||||
|
"versions": versions_t,
|
||||||
|
"restore": restore_t,
|
||||||
|
"tags": tags_t,
|
||||||
|
"copy": copy_t,
|
||||||
|
"move": move_t,
|
||||||
|
"metadata": metadata_t,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def translate_list_objects(
|
||||||
|
boto3_response: dict[str, Any],
|
||||||
|
url_templates: dict[str, str],
|
||||||
|
display_tz: str = "UTC",
|
||||||
|
versioning_enabled: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
objects_data = []
|
||||||
|
for obj in boto3_response.get("Contents", []):
|
||||||
|
last_mod = obj["LastModified"]
|
||||||
|
objects_data.append({
|
||||||
|
"key": obj["Key"],
|
||||||
|
"size": obj["Size"],
|
||||||
|
"last_modified": last_mod.isoformat(),
|
||||||
|
"last_modified_display": format_datetime_display(last_mod, display_tz),
|
||||||
|
"last_modified_iso": format_datetime_iso(last_mod, display_tz),
|
||||||
|
"etag": obj.get("ETag", "").strip('"'),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"objects": objects_data,
|
||||||
|
"is_truncated": boto3_response.get("IsTruncated", False),
|
||||||
|
"next_continuation_token": boto3_response.get("NextContinuationToken"),
|
||||||
|
"total_count": boto3_response.get("KeyCount", len(objects_data)),
|
||||||
|
"versioning_enabled": versioning_enabled,
|
||||||
|
"url_templates": url_templates,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_versioning_via_s3(client: Any, bucket_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
resp = client.get_bucket_versioning(Bucket=bucket_name)
|
||||||
|
return resp.get("Status") == "Enabled"
|
||||||
|
except ClientError as exc:
|
||||||
|
code = exc.response.get("Error", {}).get("Code", "")
|
||||||
|
if code != "NoSuchBucket":
|
||||||
|
logger.warning("Failed to check versioning for %s: %s", bucket_name, code)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def stream_objects_ndjson(
|
||||||
|
client: Any,
|
||||||
|
bucket_name: str,
|
||||||
|
prefix: Optional[str],
|
||||||
|
url_templates: dict[str, str],
|
||||||
|
display_tz: str = "UTC",
|
||||||
|
versioning_enabled: bool = False,
|
||||||
|
) -> Generator[str, None, None]:
|
||||||
|
meta_line = json.dumps({
|
||||||
|
"type": "meta",
|
||||||
|
"versioning_enabled": versioning_enabled,
|
||||||
|
"url_templates": url_templates,
|
||||||
|
}) + "\n"
|
||||||
|
yield meta_line
|
||||||
|
|
||||||
|
yield json.dumps({"type": "count", "total_count": 0}) + "\n"
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = {"Bucket": bucket_name, "MaxKeys": 1000}
|
||||||
|
if prefix:
|
||||||
|
kwargs["Prefix"] = prefix
|
||||||
|
|
||||||
|
try:
|
||||||
|
paginator = client.get_paginator("list_objects_v2")
|
||||||
|
for page in paginator.paginate(**kwargs):
|
||||||
|
for obj in page.get("Contents", []):
|
||||||
|
last_mod = obj["LastModified"]
|
||||||
|
yield json.dumps({
|
||||||
|
"type": "object",
|
||||||
|
"key": obj["Key"],
|
||||||
|
"size": obj["Size"],
|
||||||
|
"last_modified": last_mod.isoformat(),
|
||||||
|
"last_modified_display": format_datetime_display(last_mod, display_tz),
|
||||||
|
"last_modified_iso": format_datetime_iso(last_mod, display_tz),
|
||||||
|
"etag": obj.get("ETag", "").strip('"'),
|
||||||
|
}) + "\n"
|
||||||
|
except ClientError as exc:
|
||||||
|
error_msg = exc.response.get("Error", {}).get("Message", "S3 operation failed")
|
||||||
|
yield json.dumps({"type": "error", "error": error_msg}) + "\n"
|
||||||
|
return
|
||||||
|
except (EndpointConnectionError, ConnectionClosedError):
|
||||||
|
yield json.dumps({"type": "error", "error": "S3 API server is unreachable"}) + "\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
yield json.dumps({"type": "done"}) + "\n"
|
||||||
@@ -18,6 +18,18 @@ class EphemeralSecretStore:
|
|||||||
self._store[token] = (payload, expires_at)
|
self._store[token] = (payload, expires_at)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
def peek(self, token: str | None) -> Any | None:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
entry = self._store.get(token)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
payload, expires_at = entry
|
||||||
|
if expires_at < time.time():
|
||||||
|
self._store.pop(token, None)
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
def pop(self, token: str | None) -> Any | None:
|
def pop(self, token: str | None) -> Any | None:
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
|||||||
448
app/storage.py
448
app/storage.py
@@ -11,6 +11,7 @@ import time
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
import uuid
|
import uuid
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -177,7 +178,7 @@ class ObjectStorage:
|
|||||||
self.root = Path(root)
|
self.root = Path(root)
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
self._ensure_system_roots()
|
self._ensure_system_roots()
|
||||||
self._object_cache: OrderedDict[str, tuple[Dict[str, ObjectMeta], float]] = OrderedDict()
|
self._object_cache: OrderedDict[str, tuple[Dict[str, ObjectMeta], float, float]] = OrderedDict()
|
||||||
self._cache_lock = threading.Lock()
|
self._cache_lock = threading.Lock()
|
||||||
self._bucket_locks: Dict[str, threading.Lock] = {}
|
self._bucket_locks: Dict[str, threading.Lock] = {}
|
||||||
self._cache_version: Dict[str, int] = {}
|
self._cache_version: Dict[str, int] = {}
|
||||||
@@ -186,6 +187,9 @@ class ObjectStorage:
|
|||||||
self._cache_ttl = cache_ttl
|
self._cache_ttl = cache_ttl
|
||||||
self._object_cache_max_size = object_cache_max_size
|
self._object_cache_max_size = object_cache_max_size
|
||||||
self._object_key_max_length_bytes = object_key_max_length_bytes
|
self._object_key_max_length_bytes = object_key_max_length_bytes
|
||||||
|
self._sorted_key_cache: Dict[str, tuple[list[str], int]] = {}
|
||||||
|
self._meta_index_locks: Dict[str, threading.Lock] = {}
|
||||||
|
self._cleanup_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ParentCleanup")
|
||||||
|
|
||||||
def _get_bucket_lock(self, bucket_id: str) -> threading.Lock:
|
def _get_bucket_lock(self, bucket_id: str) -> threading.Lock:
|
||||||
"""Get or create a lock for a specific bucket. Reduces global lock contention."""
|
"""Get or create a lock for a specific bucket. Reduces global lock contention."""
|
||||||
@@ -243,10 +247,15 @@ class ObjectStorage:
|
|||||||
raise BucketNotFoundError("Bucket does not exist")
|
raise BucketNotFoundError("Bucket does not exist")
|
||||||
|
|
||||||
cache_path = self._system_bucket_root(bucket_name) / "stats.json"
|
cache_path = self._system_bucket_root(bucket_name) / "stats.json"
|
||||||
|
cached_stats = None
|
||||||
|
cache_fresh = False
|
||||||
|
|
||||||
if cache_path.exists():
|
if cache_path.exists():
|
||||||
try:
|
try:
|
||||||
if time.time() - cache_path.stat().st_mtime < cache_ttl:
|
cache_fresh = time.time() - cache_path.stat().st_mtime < cache_ttl
|
||||||
return json.loads(cache_path.read_text(encoding="utf-8"))
|
cached_stats = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||||
|
if cache_fresh:
|
||||||
|
return cached_stats
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -255,40 +264,50 @@ class ObjectStorage:
|
|||||||
version_count = 0
|
version_count = 0
|
||||||
version_bytes = 0
|
version_bytes = 0
|
||||||
|
|
||||||
for path in bucket_path.rglob("*"):
|
try:
|
||||||
if path.is_file():
|
for path in bucket_path.rglob("*"):
|
||||||
rel = path.relative_to(bucket_path)
|
|
||||||
if not rel.parts:
|
|
||||||
continue
|
|
||||||
top_folder = rel.parts[0]
|
|
||||||
if top_folder not in self.INTERNAL_FOLDERS:
|
|
||||||
stat = path.stat()
|
|
||||||
object_count += 1
|
|
||||||
total_bytes += stat.st_size
|
|
||||||
|
|
||||||
versions_root = self._bucket_versions_root(bucket_name)
|
|
||||||
if versions_root.exists():
|
|
||||||
for path in versions_root.rglob("*.bin"):
|
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
stat = path.stat()
|
rel = path.relative_to(bucket_path)
|
||||||
version_count += 1
|
if not rel.parts:
|
||||||
version_bytes += stat.st_size
|
continue
|
||||||
|
top_folder = rel.parts[0]
|
||||||
|
if top_folder not in self.INTERNAL_FOLDERS:
|
||||||
|
stat = path.stat()
|
||||||
|
object_count += 1
|
||||||
|
total_bytes += stat.st_size
|
||||||
|
|
||||||
|
versions_root = self._bucket_versions_root(bucket_name)
|
||||||
|
if versions_root.exists():
|
||||||
|
for path in versions_root.rglob("*.bin"):
|
||||||
|
if path.is_file():
|
||||||
|
stat = path.stat()
|
||||||
|
version_count += 1
|
||||||
|
version_bytes += stat.st_size
|
||||||
|
except OSError:
|
||||||
|
if cached_stats is not None:
|
||||||
|
return cached_stats
|
||||||
|
raise
|
||||||
|
|
||||||
|
existing_serial = 0
|
||||||
|
if cached_stats is not None:
|
||||||
|
existing_serial = cached_stats.get("_cache_serial", 0)
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"objects": object_count,
|
"objects": object_count,
|
||||||
"bytes": total_bytes,
|
"bytes": total_bytes,
|
||||||
"version_count": version_count,
|
"version_count": version_count,
|
||||||
"version_bytes": version_bytes,
|
"version_bytes": version_bytes,
|
||||||
"total_objects": object_count + version_count,
|
"total_objects": object_count + version_count,
|
||||||
"total_bytes": total_bytes + version_bytes,
|
"total_bytes": total_bytes + version_bytes,
|
||||||
|
"_cache_serial": existing_serial,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(json.dumps(stats), encoding="utf-8")
|
cache_path.write_text(json.dumps(stats), encoding="utf-8")
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
def _invalidate_bucket_stats_cache(self, bucket_id: str) -> None:
|
def _invalidate_bucket_stats_cache(self, bucket_id: str) -> None:
|
||||||
@@ -299,6 +318,39 @@ class ObjectStorage:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _update_bucket_stats_cache(
|
||||||
|
self,
|
||||||
|
bucket_id: str,
|
||||||
|
*,
|
||||||
|
bytes_delta: int = 0,
|
||||||
|
objects_delta: int = 0,
|
||||||
|
version_bytes_delta: int = 0,
|
||||||
|
version_count_delta: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Incrementally update cached bucket statistics instead of invalidating.
|
||||||
|
|
||||||
|
This avoids expensive full directory scans on every PUT/DELETE by
|
||||||
|
adjusting the cached values directly. Also signals cross-process cache
|
||||||
|
invalidation by incrementing _cache_serial.
|
||||||
|
"""
|
||||||
|
cache_path = self._system_bucket_root(bucket_id) / "stats.json"
|
||||||
|
try:
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if cache_path.exists():
|
||||||
|
data = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||||
|
else:
|
||||||
|
data = {"objects": 0, "bytes": 0, "version_count": 0, "version_bytes": 0, "total_objects": 0, "total_bytes": 0, "_cache_serial": 0}
|
||||||
|
data["objects"] = max(0, data.get("objects", 0) + objects_delta)
|
||||||
|
data["bytes"] = max(0, data.get("bytes", 0) + bytes_delta)
|
||||||
|
data["version_count"] = max(0, data.get("version_count", 0) + version_count_delta)
|
||||||
|
data["version_bytes"] = max(0, data.get("version_bytes", 0) + version_bytes_delta)
|
||||||
|
data["total_objects"] = max(0, data.get("total_objects", 0) + objects_delta + version_count_delta)
|
||||||
|
data["total_bytes"] = max(0, data.get("total_bytes", 0) + bytes_delta + version_bytes_delta)
|
||||||
|
data["_cache_serial"] = data.get("_cache_serial", 0) + 1
|
||||||
|
cache_path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
def delete_bucket(self, bucket_name: str) -> None:
|
def delete_bucket(self, bucket_name: str) -> None:
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
if not bucket_path.exists():
|
if not bucket_path.exists():
|
||||||
@@ -333,22 +385,35 @@ class ObjectStorage:
|
|||||||
Returns:
|
Returns:
|
||||||
ListObjectsResult with objects, truncation status, and continuation token
|
ListObjectsResult with objects, truncation status, and continuation token
|
||||||
"""
|
"""
|
||||||
|
import bisect
|
||||||
|
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
if not bucket_path.exists():
|
if not bucket_path.exists():
|
||||||
raise BucketNotFoundError("Bucket does not exist")
|
raise BucketNotFoundError("Bucket does not exist")
|
||||||
bucket_id = bucket_path.name
|
bucket_id = bucket_path.name
|
||||||
|
|
||||||
object_cache = self._get_object_cache(bucket_id, bucket_path)
|
object_cache = self._get_object_cache(bucket_id, bucket_path)
|
||||||
|
|
||||||
all_keys = sorted(object_cache.keys())
|
cache_version = self._cache_version.get(bucket_id, 0)
|
||||||
|
cached_entry = self._sorted_key_cache.get(bucket_id)
|
||||||
|
if cached_entry and cached_entry[1] == cache_version:
|
||||||
|
all_keys = cached_entry[0]
|
||||||
|
else:
|
||||||
|
all_keys = sorted(object_cache.keys())
|
||||||
|
self._sorted_key_cache[bucket_id] = (all_keys, cache_version)
|
||||||
|
|
||||||
if prefix:
|
if prefix:
|
||||||
all_keys = [k for k in all_keys if k.startswith(prefix)]
|
lo = bisect.bisect_left(all_keys, prefix)
|
||||||
|
hi = len(all_keys)
|
||||||
|
for i in range(lo, len(all_keys)):
|
||||||
|
if not all_keys[i].startswith(prefix):
|
||||||
|
hi = i
|
||||||
|
break
|
||||||
|
all_keys = all_keys[lo:hi]
|
||||||
|
|
||||||
total_count = len(all_keys)
|
total_count = len(all_keys)
|
||||||
start_index = 0
|
start_index = 0
|
||||||
if continuation_token:
|
if continuation_token:
|
||||||
import bisect
|
|
||||||
start_index = bisect.bisect_right(all_keys, continuation_token)
|
start_index = bisect.bisect_right(all_keys, continuation_token)
|
||||||
if start_index >= total_count:
|
if start_index >= total_count:
|
||||||
return ListObjectsResult(
|
return ListObjectsResult(
|
||||||
@@ -356,8 +421,8 @@ class ObjectStorage:
|
|||||||
is_truncated=False,
|
is_truncated=False,
|
||||||
next_continuation_token=None,
|
next_continuation_token=None,
|
||||||
total_count=total_count,
|
total_count=total_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
end_index = start_index + max_keys
|
end_index = start_index + max_keys
|
||||||
keys_slice = all_keys[start_index:end_index]
|
keys_slice = all_keys[start_index:end_index]
|
||||||
is_truncated = end_index < total_count
|
is_truncated = end_index < total_count
|
||||||
@@ -403,7 +468,9 @@ class ObjectStorage:
|
|||||||
is_overwrite = destination.exists()
|
is_overwrite = destination.exists()
|
||||||
existing_size = destination.stat().st_size if is_overwrite else 0
|
existing_size = destination.stat().st_size if is_overwrite else 0
|
||||||
|
|
||||||
|
archived_version_size = 0
|
||||||
if self._is_versioning_enabled(bucket_path) and is_overwrite:
|
if self._is_versioning_enabled(bucket_path) and is_overwrite:
|
||||||
|
archived_version_size = existing_size
|
||||||
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
||||||
|
|
||||||
tmp_dir = self._system_root_path() / self.SYSTEM_TMP_DIR
|
tmp_dir = self._system_root_path() / self.SYSTEM_TMP_DIR
|
||||||
@@ -416,11 +483,10 @@ class ObjectStorage:
|
|||||||
shutil.copyfileobj(_HashingReader(stream, checksum), target)
|
shutil.copyfileobj(_HashingReader(stream, checksum), target)
|
||||||
|
|
||||||
new_size = tmp_path.stat().st_size
|
new_size = tmp_path.stat().st_size
|
||||||
|
size_delta = new_size - existing_size
|
||||||
|
object_delta = 0 if is_overwrite else 1
|
||||||
|
|
||||||
if enforce_quota:
|
if enforce_quota:
|
||||||
size_delta = new_size - existing_size
|
|
||||||
object_delta = 0 if is_overwrite else 1
|
|
||||||
|
|
||||||
quota_check = self.check_quota(
|
quota_check = self.check_quota(
|
||||||
bucket_name,
|
bucket_name,
|
||||||
additional_bytes=max(0, size_delta),
|
additional_bytes=max(0, size_delta),
|
||||||
@@ -432,7 +498,7 @@ class ObjectStorage:
|
|||||||
quota_check["quota"],
|
quota_check["quota"],
|
||||||
quota_check["usage"],
|
quota_check["usage"],
|
||||||
)
|
)
|
||||||
|
|
||||||
shutil.move(str(tmp_path), str(destination))
|
shutil.move(str(tmp_path), str(destination))
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
@@ -448,7 +514,13 @@ class ObjectStorage:
|
|||||||
combined_meta = {**internal_meta, **(metadata or {})}
|
combined_meta = {**internal_meta, **(metadata or {})}
|
||||||
self._write_metadata(bucket_id, safe_key, combined_meta)
|
self._write_metadata(bucket_id, safe_key, combined_meta)
|
||||||
|
|
||||||
self._invalidate_bucket_stats_cache(bucket_id)
|
self._update_bucket_stats_cache(
|
||||||
|
bucket_id,
|
||||||
|
bytes_delta=size_delta,
|
||||||
|
objects_delta=object_delta,
|
||||||
|
version_bytes_delta=archived_version_size,
|
||||||
|
version_count_delta=1 if archived_version_size > 0 else 0,
|
||||||
|
)
|
||||||
|
|
||||||
obj_meta = ObjectMeta(
|
obj_meta = ObjectMeta(
|
||||||
key=safe_key.as_posix(),
|
key=safe_key.as_posix(),
|
||||||
@@ -463,7 +535,7 @@ class ObjectStorage:
|
|||||||
|
|
||||||
def get_object_path(self, bucket_name: str, object_key: str) -> Path:
|
def get_object_path(self, bucket_name: str, object_key: str) -> Path:
|
||||||
path = self._object_path(bucket_name, object_key)
|
path = self._object_path(bucket_name, object_key)
|
||||||
if not path.exists():
|
if not path.is_file():
|
||||||
raise ObjectNotFoundError("Object not found")
|
raise ObjectNotFoundError("Object not found")
|
||||||
return path
|
return path
|
||||||
|
|
||||||
@@ -475,11 +547,14 @@ class ObjectStorage:
|
|||||||
return self._read_metadata(bucket_path.name, safe_key) or {}
|
return self._read_metadata(bucket_path.name, safe_key) or {}
|
||||||
|
|
||||||
def _cleanup_empty_parents(self, path: Path, stop_at: Path) -> None:
|
def _cleanup_empty_parents(self, path: Path, stop_at: Path) -> None:
|
||||||
"""Remove empty parent directories up to (but not including) stop_at.
|
"""Remove empty parent directories in a background thread.
|
||||||
|
|
||||||
On Windows/OneDrive, directories may be locked briefly after file deletion.
|
On Windows/OneDrive, directories may be locked briefly after file deletion.
|
||||||
This method retries with a small delay to handle that case.
|
Running this in the background avoids blocking the request thread with retries.
|
||||||
"""
|
"""
|
||||||
|
self._cleanup_executor.submit(self._do_cleanup_empty_parents, path, stop_at)
|
||||||
|
|
||||||
|
def _do_cleanup_empty_parents(self, path: Path, stop_at: Path) -> None:
|
||||||
for parent in path.parents:
|
for parent in path.parents:
|
||||||
if parent == stop_at:
|
if parent == stop_at:
|
||||||
break
|
break
|
||||||
@@ -487,7 +562,7 @@ class ObjectStorage:
|
|||||||
try:
|
try:
|
||||||
if parent.exists() and not any(parent.iterdir()):
|
if parent.exists() and not any(parent.iterdir()):
|
||||||
parent.rmdir()
|
parent.rmdir()
|
||||||
break
|
break
|
||||||
except OSError:
|
except OSError:
|
||||||
if attempt < 2:
|
if attempt < 2:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@@ -498,15 +573,24 @@ class ObjectStorage:
|
|||||||
path = self._object_path(bucket_name, object_key)
|
path = self._object_path(bucket_name, object_key)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return
|
return
|
||||||
|
deleted_size = path.stat().st_size
|
||||||
safe_key = path.relative_to(bucket_path)
|
safe_key = path.relative_to(bucket_path)
|
||||||
bucket_id = bucket_path.name
|
bucket_id = bucket_path.name
|
||||||
|
archived_version_size = 0
|
||||||
if self._is_versioning_enabled(bucket_path):
|
if self._is_versioning_enabled(bucket_path):
|
||||||
|
archived_version_size = deleted_size
|
||||||
self._archive_current_version(bucket_id, safe_key, reason="delete")
|
self._archive_current_version(bucket_id, safe_key, reason="delete")
|
||||||
rel = path.relative_to(bucket_path)
|
rel = path.relative_to(bucket_path)
|
||||||
self._safe_unlink(path)
|
self._safe_unlink(path)
|
||||||
self._delete_metadata(bucket_id, rel)
|
self._delete_metadata(bucket_id, rel)
|
||||||
|
|
||||||
self._invalidate_bucket_stats_cache(bucket_id)
|
self._update_bucket_stats_cache(
|
||||||
|
bucket_id,
|
||||||
|
bytes_delta=-deleted_size,
|
||||||
|
objects_delta=-1,
|
||||||
|
version_bytes_delta=archived_version_size,
|
||||||
|
version_count_delta=1 if archived_version_size > 0 else 0,
|
||||||
|
)
|
||||||
self._update_object_cache_entry(bucket_id, safe_key.as_posix(), None)
|
self._update_object_cache_entry(bucket_id, safe_key.as_posix(), None)
|
||||||
self._cleanup_empty_parents(path, bucket_path)
|
self._cleanup_empty_parents(path, bucket_path)
|
||||||
|
|
||||||
@@ -604,10 +688,19 @@ class ObjectStorage:
|
|||||||
return lifecycle if isinstance(lifecycle, list) else None
|
return lifecycle if isinstance(lifecycle, list) else None
|
||||||
|
|
||||||
def set_bucket_lifecycle(self, bucket_name: str, rules: Optional[List[Dict[str, Any]]]) -> 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)
|
bucket_path = self._require_bucket_path(bucket_name)
|
||||||
self._set_bucket_config_entry(bucket_path.name, "lifecycle", rules)
|
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]:
|
def get_bucket_quota(self, bucket_name: str) -> Dict[str, Any]:
|
||||||
"""Get quota configuration for bucket.
|
"""Get quota configuration for bucket.
|
||||||
|
|
||||||
@@ -733,6 +826,10 @@ class ObjectStorage:
|
|||||||
if not object_path.exists():
|
if not object_path.exists():
|
||||||
raise ObjectNotFoundError("Object does not exist")
|
raise ObjectNotFoundError("Object does not exist")
|
||||||
|
|
||||||
|
entry = self._read_index_entry(bucket_path.name, safe_key)
|
||||||
|
if entry is not None:
|
||||||
|
tags = entry.get("tags")
|
||||||
|
return tags if isinstance(tags, list) else []
|
||||||
for meta_file in (self._metadata_file(bucket_path.name, safe_key), self._legacy_metadata_file(bucket_path.name, safe_key)):
|
for meta_file in (self._metadata_file(bucket_path.name, safe_key), self._legacy_metadata_file(bucket_path.name, safe_key)):
|
||||||
if not meta_file.exists():
|
if not meta_file.exists():
|
||||||
continue
|
continue
|
||||||
@@ -756,30 +853,31 @@ class ObjectStorage:
|
|||||||
if not object_path.exists():
|
if not object_path.exists():
|
||||||
raise ObjectNotFoundError("Object does not exist")
|
raise ObjectNotFoundError("Object does not exist")
|
||||||
|
|
||||||
meta_file = self._metadata_file(bucket_path.name, safe_key)
|
bucket_id = bucket_path.name
|
||||||
|
existing_entry = self._read_index_entry(bucket_id, safe_key) or {}
|
||||||
existing_payload: Dict[str, Any] = {}
|
if not existing_entry:
|
||||||
if meta_file.exists():
|
meta_file = self._metadata_file(bucket_id, safe_key)
|
||||||
try:
|
if meta_file.exists():
|
||||||
existing_payload = json.loads(meta_file.read_text(encoding="utf-8"))
|
try:
|
||||||
except (OSError, json.JSONDecodeError):
|
existing_entry = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||||
pass
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
if tags:
|
if tags:
|
||||||
existing_payload["tags"] = tags
|
existing_entry["tags"] = tags
|
||||||
else:
|
else:
|
||||||
existing_payload.pop("tags", None)
|
existing_entry.pop("tags", None)
|
||||||
|
|
||||||
if existing_payload.get("metadata") or existing_payload.get("tags"):
|
if existing_entry.get("metadata") or existing_entry.get("tags"):
|
||||||
meta_file.parent.mkdir(parents=True, exist_ok=True)
|
self._write_index_entry(bucket_id, safe_key, existing_entry)
|
||||||
meta_file.write_text(json.dumps(existing_payload), encoding="utf-8")
|
else:
|
||||||
elif meta_file.exists():
|
self._delete_index_entry(bucket_id, safe_key)
|
||||||
meta_file.unlink()
|
old_meta = self._metadata_file(bucket_id, safe_key)
|
||||||
parent = meta_file.parent
|
try:
|
||||||
meta_root = self._bucket_meta_root(bucket_path.name)
|
if old_meta.exists():
|
||||||
while parent != meta_root and parent.exists() and not any(parent.iterdir()):
|
old_meta.unlink()
|
||||||
parent.rmdir()
|
except OSError:
|
||||||
parent = parent.parent
|
pass
|
||||||
|
|
||||||
def delete_object_tags(self, bucket_name: str, object_key: str) -> None:
|
def delete_object_tags(self, bucket_name: str, object_key: str) -> None:
|
||||||
"""Delete all tags from an object."""
|
"""Delete all tags from an object."""
|
||||||
@@ -828,7 +926,12 @@ class ObjectStorage:
|
|||||||
if not isinstance(metadata, dict):
|
if not isinstance(metadata, dict):
|
||||||
metadata = {}
|
metadata = {}
|
||||||
destination = bucket_path / safe_key
|
destination = bucket_path / safe_key
|
||||||
if self._is_versioning_enabled(bucket_path) and destination.exists():
|
restored_size = data_path.stat().st_size
|
||||||
|
is_overwrite = destination.exists()
|
||||||
|
existing_size = destination.stat().st_size if is_overwrite else 0
|
||||||
|
archived_version_size = 0
|
||||||
|
if self._is_versioning_enabled(bucket_path) and is_overwrite:
|
||||||
|
archived_version_size = existing_size
|
||||||
self._archive_current_version(bucket_id, safe_key, reason="restore-overwrite")
|
self._archive_current_version(bucket_id, safe_key, reason="restore-overwrite")
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(data_path, destination)
|
shutil.copy2(data_path, destination)
|
||||||
@@ -837,7 +940,13 @@ class ObjectStorage:
|
|||||||
else:
|
else:
|
||||||
self._delete_metadata(bucket_id, safe_key)
|
self._delete_metadata(bucket_id, safe_key)
|
||||||
stat = destination.stat()
|
stat = destination.stat()
|
||||||
self._invalidate_bucket_stats_cache(bucket_id)
|
self._update_bucket_stats_cache(
|
||||||
|
bucket_id,
|
||||||
|
bytes_delta=restored_size - existing_size,
|
||||||
|
objects_delta=0 if is_overwrite else 1,
|
||||||
|
version_bytes_delta=archived_version_size,
|
||||||
|
version_count_delta=1 if archived_version_size > 0 else 0,
|
||||||
|
)
|
||||||
return ObjectMeta(
|
return ObjectMeta(
|
||||||
key=safe_key.as_posix(),
|
key=safe_key.as_posix(),
|
||||||
size=stat.st_size,
|
size=stat.st_size,
|
||||||
@@ -861,6 +970,7 @@ class ObjectStorage:
|
|||||||
meta_path = legacy_version_dir / f"{version_id}.json"
|
meta_path = legacy_version_dir / f"{version_id}.json"
|
||||||
if not data_path.exists() and not meta_path.exists():
|
if not data_path.exists() and not meta_path.exists():
|
||||||
raise StorageError(f"Version {version_id} not found")
|
raise StorageError(f"Version {version_id} not found")
|
||||||
|
deleted_version_size = data_path.stat().st_size if data_path.exists() else 0
|
||||||
if data_path.exists():
|
if data_path.exists():
|
||||||
data_path.unlink()
|
data_path.unlink()
|
||||||
if meta_path.exists():
|
if meta_path.exists():
|
||||||
@@ -868,6 +978,12 @@ class ObjectStorage:
|
|||||||
parent = data_path.parent
|
parent = data_path.parent
|
||||||
if parent.exists() and not any(parent.iterdir()):
|
if parent.exists() and not any(parent.iterdir()):
|
||||||
parent.rmdir()
|
parent.rmdir()
|
||||||
|
if deleted_version_size > 0:
|
||||||
|
self._update_bucket_stats_cache(
|
||||||
|
bucket_id,
|
||||||
|
version_bytes_delta=-deleted_version_size,
|
||||||
|
version_count_delta=-1,
|
||||||
|
)
|
||||||
|
|
||||||
def list_orphaned_objects(self, bucket_name: str) -> List[Dict[str, Any]]:
|
def list_orphaned_objects(self, bucket_name: str) -> List[Dict[str, Any]]:
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
@@ -1164,14 +1280,14 @@ class ObjectStorage:
|
|||||||
|
|
||||||
safe_key = self._sanitize_object_key(manifest["object_key"], self._object_key_max_length_bytes)
|
safe_key = self._sanitize_object_key(manifest["object_key"], self._object_key_max_length_bytes)
|
||||||
destination = bucket_path / safe_key
|
destination = bucket_path / safe_key
|
||||||
|
|
||||||
is_overwrite = destination.exists()
|
is_overwrite = destination.exists()
|
||||||
existing_size = destination.stat().st_size if is_overwrite else 0
|
existing_size = destination.stat().st_size if is_overwrite else 0
|
||||||
|
size_delta = total_size - existing_size
|
||||||
|
object_delta = 0 if is_overwrite else 1
|
||||||
|
versioning_enabled = self._is_versioning_enabled(bucket_path)
|
||||||
|
|
||||||
if enforce_quota:
|
if enforce_quota:
|
||||||
size_delta = total_size - existing_size
|
|
||||||
object_delta = 0 if is_overwrite else 1
|
|
||||||
|
|
||||||
quota_check = self.check_quota(
|
quota_check = self.check_quota(
|
||||||
bucket_name,
|
bucket_name,
|
||||||
additional_bytes=max(0, size_delta),
|
additional_bytes=max(0, size_delta),
|
||||||
@@ -1183,14 +1299,16 @@ class ObjectStorage:
|
|||||||
quota_check["quota"],
|
quota_check["quota"],
|
||||||
quota_check["usage"],
|
quota_check["usage"],
|
||||||
)
|
)
|
||||||
|
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
lock_file_path = self._system_bucket_root(bucket_id) / "locks" / f"{safe_key.as_posix().replace('/', '_')}.lock"
|
lock_file_path = self._system_bucket_root(bucket_id) / "locks" / f"{safe_key.as_posix().replace('/', '_')}.lock"
|
||||||
|
|
||||||
|
archived_version_size = 0
|
||||||
try:
|
try:
|
||||||
with _atomic_lock_file(lock_file_path):
|
with _atomic_lock_file(lock_file_path):
|
||||||
if self._is_versioning_enabled(bucket_path) and destination.exists():
|
if versioning_enabled and destination.exists():
|
||||||
|
archived_version_size = destination.stat().st_size
|
||||||
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
||||||
checksum = hashlib.md5()
|
checksum = hashlib.md5()
|
||||||
with destination.open("wb") as target:
|
with destination.open("wb") as target:
|
||||||
@@ -1210,7 +1328,13 @@ class ObjectStorage:
|
|||||||
|
|
||||||
shutil.rmtree(upload_root, ignore_errors=True)
|
shutil.rmtree(upload_root, ignore_errors=True)
|
||||||
|
|
||||||
self._invalidate_bucket_stats_cache(bucket_id)
|
self._update_bucket_stats_cache(
|
||||||
|
bucket_id,
|
||||||
|
bytes_delta=size_delta,
|
||||||
|
objects_delta=object_delta,
|
||||||
|
version_bytes_delta=archived_version_size,
|
||||||
|
version_count_delta=1 if archived_version_size > 0 else 0,
|
||||||
|
)
|
||||||
|
|
||||||
stat = destination.stat()
|
stat = destination.stat()
|
||||||
etag = checksum.hexdigest()
|
etag = checksum.hexdigest()
|
||||||
@@ -1420,7 +1544,7 @@ class ObjectStorage:
|
|||||||
if entry.is_dir(follow_symlinks=False):
|
if entry.is_dir(follow_symlinks=False):
|
||||||
if check_newer(entry.path):
|
if check_newer(entry.path):
|
||||||
return True
|
return True
|
||||||
elif entry.is_file(follow_symlinks=False) and entry.name.endswith('.meta.json'):
|
elif entry.is_file(follow_symlinks=False) and (entry.name.endswith('.meta.json') or entry.name == '_index.json'):
|
||||||
if entry.stat().st_mtime > index_mtime:
|
if entry.stat().st_mtime > index_mtime:
|
||||||
return True
|
return True
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1434,22 +1558,50 @@ class ObjectStorage:
|
|||||||
meta_str = str(meta_root)
|
meta_str = str(meta_root)
|
||||||
meta_len = len(meta_str) + 1
|
meta_len = len(meta_str) + 1
|
||||||
meta_files: list[tuple[str, str]] = []
|
meta_files: list[tuple[str, str]] = []
|
||||||
|
index_files: list[str] = []
|
||||||
|
|
||||||
def collect_meta_files(dir_path: str) -> None:
|
def collect_meta_files(dir_path: str) -> None:
|
||||||
try:
|
try:
|
||||||
with os.scandir(dir_path) as it:
|
with os.scandir(dir_path) as it:
|
||||||
for entry in it:
|
for entry in it:
|
||||||
if entry.is_dir(follow_symlinks=False):
|
if entry.is_dir(follow_symlinks=False):
|
||||||
collect_meta_files(entry.path)
|
collect_meta_files(entry.path)
|
||||||
elif entry.is_file(follow_symlinks=False) and entry.name.endswith('.meta.json'):
|
elif entry.is_file(follow_symlinks=False):
|
||||||
rel = entry.path[meta_len:]
|
if entry.name == '_index.json':
|
||||||
key = rel[:-10].replace(os.sep, '/')
|
index_files.append(entry.path)
|
||||||
meta_files.append((key, entry.path))
|
elif entry.name.endswith('.meta.json'):
|
||||||
|
rel = entry.path[meta_len:]
|
||||||
|
key = rel[:-10].replace(os.sep, '/')
|
||||||
|
meta_files.append((key, entry.path))
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
collect_meta_files(meta_str)
|
collect_meta_files(meta_str)
|
||||||
|
|
||||||
|
meta_cache = {}
|
||||||
|
|
||||||
|
for idx_path in index_files:
|
||||||
|
try:
|
||||||
|
with open(idx_path, 'r', encoding='utf-8') as f:
|
||||||
|
idx_data = json.load(f)
|
||||||
|
rel_dir = idx_path[meta_len:]
|
||||||
|
rel_dir = rel_dir.replace(os.sep, '/')
|
||||||
|
if rel_dir.endswith('/_index.json'):
|
||||||
|
dir_prefix = rel_dir[:-len('/_index.json')]
|
||||||
|
else:
|
||||||
|
dir_prefix = ''
|
||||||
|
for entry_name, entry_data in idx_data.items():
|
||||||
|
if dir_prefix:
|
||||||
|
key = f"{dir_prefix}/{entry_name}"
|
||||||
|
else:
|
||||||
|
key = entry_name
|
||||||
|
meta = entry_data.get("metadata", {})
|
||||||
|
etag = meta.get("__etag__")
|
||||||
|
if etag:
|
||||||
|
meta_cache[key] = etag
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
def read_meta_file(item: tuple[str, str]) -> tuple[str, str | None]:
|
def read_meta_file(item: tuple[str, str]) -> tuple[str, str | None]:
|
||||||
key, path = item
|
key, path = item
|
||||||
try:
|
try:
|
||||||
@@ -1466,14 +1618,16 @@ class ObjectStorage:
|
|||||||
return key, None
|
return key, None
|
||||||
except (OSError, UnicodeDecodeError):
|
except (OSError, UnicodeDecodeError):
|
||||||
return key, None
|
return key, None
|
||||||
|
|
||||||
if meta_files:
|
legacy_meta_files = [(k, p) for k, p in meta_files if k not in meta_cache]
|
||||||
meta_cache = {}
|
if legacy_meta_files:
|
||||||
with ThreadPoolExecutor(max_workers=min(64, len(meta_files))) as executor:
|
max_workers = min((os.cpu_count() or 4) * 2, len(legacy_meta_files), 16)
|
||||||
for key, etag in executor.map(read_meta_file, meta_files):
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
for key, etag in executor.map(read_meta_file, legacy_meta_files):
|
||||||
if etag:
|
if etag:
|
||||||
meta_cache[key] = etag
|
meta_cache[key] = etag
|
||||||
|
|
||||||
|
if meta_cache:
|
||||||
try:
|
try:
|
||||||
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
||||||
@@ -1522,38 +1676,46 @@ class ObjectStorage:
|
|||||||
|
|
||||||
Uses LRU eviction to prevent unbounded cache growth.
|
Uses LRU eviction to prevent unbounded cache growth.
|
||||||
Thread-safe with per-bucket locks to reduce contention.
|
Thread-safe with per-bucket locks to reduce contention.
|
||||||
|
Checks stats.json for cross-process cache invalidation.
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
current_stats_mtime = self._get_cache_marker_mtime(bucket_id)
|
||||||
|
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
cached = self._object_cache.get(bucket_id)
|
cached = self._object_cache.get(bucket_id)
|
||||||
if cached:
|
if cached:
|
||||||
objects, timestamp = cached
|
objects, timestamp, cached_stats_mtime = cached
|
||||||
if now - timestamp < self._cache_ttl:
|
if now - timestamp < self._cache_ttl and current_stats_mtime == cached_stats_mtime:
|
||||||
self._object_cache.move_to_end(bucket_id)
|
self._object_cache.move_to_end(bucket_id)
|
||||||
return objects
|
return objects
|
||||||
cache_version = self._cache_version.get(bucket_id, 0)
|
cache_version = self._cache_version.get(bucket_id, 0)
|
||||||
|
|
||||||
bucket_lock = self._get_bucket_lock(bucket_id)
|
bucket_lock = self._get_bucket_lock(bucket_id)
|
||||||
with bucket_lock:
|
with bucket_lock:
|
||||||
|
current_stats_mtime = self._get_cache_marker_mtime(bucket_id)
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
cached = self._object_cache.get(bucket_id)
|
cached = self._object_cache.get(bucket_id)
|
||||||
if cached:
|
if cached:
|
||||||
objects, timestamp = cached
|
objects, timestamp, cached_stats_mtime = cached
|
||||||
if now - timestamp < self._cache_ttl:
|
if now - timestamp < self._cache_ttl and current_stats_mtime == cached_stats_mtime:
|
||||||
self._object_cache.move_to_end(bucket_id)
|
self._object_cache.move_to_end(bucket_id)
|
||||||
return objects
|
return objects
|
||||||
|
|
||||||
objects = self._build_object_cache(bucket_path)
|
objects = self._build_object_cache(bucket_path)
|
||||||
|
new_stats_mtime = self._get_cache_marker_mtime(bucket_id)
|
||||||
|
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
current_version = self._cache_version.get(bucket_id, 0)
|
current_version = self._cache_version.get(bucket_id, 0)
|
||||||
if current_version != cache_version:
|
if current_version != cache_version:
|
||||||
objects = self._build_object_cache(bucket_path)
|
objects = self._build_object_cache(bucket_path)
|
||||||
|
new_stats_mtime = self._get_cache_marker_mtime(bucket_id)
|
||||||
while len(self._object_cache) >= self._object_cache_max_size:
|
while len(self._object_cache) >= self._object_cache_max_size:
|
||||||
self._object_cache.popitem(last=False)
|
self._object_cache.popitem(last=False)
|
||||||
|
|
||||||
self._object_cache[bucket_id] = (objects, time.time())
|
self._object_cache[bucket_id] = (objects, time.time(), new_stats_mtime)
|
||||||
self._object_cache.move_to_end(bucket_id)
|
self._object_cache.move_to_end(bucket_id)
|
||||||
|
self._cache_version[bucket_id] = current_version + 1
|
||||||
|
self._sorted_key_cache.pop(bucket_id, None)
|
||||||
|
|
||||||
return objects
|
return objects
|
||||||
|
|
||||||
@@ -1561,6 +1723,7 @@ class ObjectStorage:
|
|||||||
"""Invalidate the object cache and etag index for a bucket.
|
"""Invalidate the object cache and etag index for a bucket.
|
||||||
|
|
||||||
Increments version counter to signal stale reads.
|
Increments version counter to signal stale reads.
|
||||||
|
Cross-process invalidation is handled by checking stats.json mtime.
|
||||||
"""
|
"""
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
self._object_cache.pop(bucket_id, None)
|
self._object_cache.pop(bucket_id, None)
|
||||||
@@ -1572,19 +1735,37 @@ class ObjectStorage:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _get_cache_marker_mtime(self, bucket_id: str) -> float:
|
||||||
|
"""Get a cache marker combining serial and object count for cross-process invalidation.
|
||||||
|
|
||||||
|
Returns a combined value that changes if either _cache_serial or object count changes.
|
||||||
|
This handles cases where the serial was reset but object count differs.
|
||||||
|
"""
|
||||||
|
stats_path = self._system_bucket_root(bucket_id) / "stats.json"
|
||||||
|
try:
|
||||||
|
data = json.loads(stats_path.read_text(encoding="utf-8"))
|
||||||
|
serial = data.get("_cache_serial", 0)
|
||||||
|
count = data.get("objects", 0)
|
||||||
|
return float(serial * 1000000 + count)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
def _update_object_cache_entry(self, bucket_id: str, key: str, meta: Optional[ObjectMeta]) -> None:
|
def _update_object_cache_entry(self, bucket_id: str, key: str, meta: Optional[ObjectMeta]) -> None:
|
||||||
"""Update a single entry in the object cache instead of invalidating the whole cache.
|
"""Update a single entry in the object cache instead of invalidating the whole cache.
|
||||||
|
|
||||||
This is a performance optimization - lazy update instead of full invalidation.
|
This is a performance optimization - lazy update instead of full invalidation.
|
||||||
|
Cross-process invalidation is handled by checking stats.json mtime.
|
||||||
"""
|
"""
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
cached = self._object_cache.get(bucket_id)
|
cached = self._object_cache.get(bucket_id)
|
||||||
if cached:
|
if cached:
|
||||||
objects, timestamp = cached
|
objects, timestamp, stats_mtime = cached
|
||||||
if meta is None:
|
if meta is None:
|
||||||
objects.pop(key, None)
|
objects.pop(key, None)
|
||||||
else:
|
else:
|
||||||
objects[key] = meta
|
objects[key] = meta
|
||||||
|
self._cache_version[bucket_id] = self._cache_version.get(bucket_id, 0) + 1
|
||||||
|
self._sorted_key_cache.pop(bucket_id, None)
|
||||||
|
|
||||||
def warm_cache(self, bucket_names: Optional[List[str]] = None) -> None:
|
def warm_cache(self, bucket_names: Optional[List[str]] = None) -> None:
|
||||||
"""Pre-warm the object cache for specified buckets or all buckets.
|
"""Pre-warm the object cache for specified buckets or all buckets.
|
||||||
@@ -1696,6 +1877,64 @@ class ObjectStorage:
|
|||||||
meta_rel = Path(key.as_posix() + ".meta.json")
|
meta_rel = Path(key.as_posix() + ".meta.json")
|
||||||
return meta_root / meta_rel
|
return meta_root / meta_rel
|
||||||
|
|
||||||
|
def _index_file_for_key(self, bucket_name: str, key: Path) -> tuple[Path, str]:
|
||||||
|
meta_root = self._bucket_meta_root(bucket_name)
|
||||||
|
parent = key.parent
|
||||||
|
entry_name = key.name
|
||||||
|
if parent == Path("."):
|
||||||
|
return meta_root / "_index.json", entry_name
|
||||||
|
return meta_root / parent / "_index.json", entry_name
|
||||||
|
|
||||||
|
def _get_meta_index_lock(self, index_path: str) -> threading.Lock:
|
||||||
|
with self._cache_lock:
|
||||||
|
if index_path not in self._meta_index_locks:
|
||||||
|
self._meta_index_locks[index_path] = threading.Lock()
|
||||||
|
return self._meta_index_locks[index_path]
|
||||||
|
|
||||||
|
def _read_index_entry(self, bucket_name: str, key: Path) -> Optional[Dict[str, Any]]:
|
||||||
|
index_path, entry_name = self._index_file_for_key(bucket_name, key)
|
||||||
|
if not index_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
return index_data.get(entry_name)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _write_index_entry(self, bucket_name: str, key: Path, entry: Dict[str, Any]) -> None:
|
||||||
|
index_path, entry_name = self._index_file_for_key(bucket_name, key)
|
||||||
|
lock = self._get_meta_index_lock(str(index_path))
|
||||||
|
with lock:
|
||||||
|
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
index_data: Dict[str, Any] = {}
|
||||||
|
if index_path.exists():
|
||||||
|
try:
|
||||||
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
index_data[entry_name] = entry
|
||||||
|
index_path.write_text(json.dumps(index_data), encoding="utf-8")
|
||||||
|
|
||||||
|
def _delete_index_entry(self, bucket_name: str, key: Path) -> None:
|
||||||
|
index_path, entry_name = self._index_file_for_key(bucket_name, key)
|
||||||
|
if not index_path.exists():
|
||||||
|
return
|
||||||
|
lock = self._get_meta_index_lock(str(index_path))
|
||||||
|
with lock:
|
||||||
|
try:
|
||||||
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
if entry_name in index_data:
|
||||||
|
del index_data[entry_name]
|
||||||
|
if index_data:
|
||||||
|
index_path.write_text(json.dumps(index_data), encoding="utf-8")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
index_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _normalize_metadata(self, metadata: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
|
def _normalize_metadata(self, metadata: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return None
|
return None
|
||||||
@@ -1707,9 +1946,13 @@ class ObjectStorage:
|
|||||||
if not clean:
|
if not clean:
|
||||||
self._delete_metadata(bucket_name, key)
|
self._delete_metadata(bucket_name, key)
|
||||||
return
|
return
|
||||||
meta_file = self._metadata_file(bucket_name, key)
|
self._write_index_entry(bucket_name, key, {"metadata": clean})
|
||||||
meta_file.parent.mkdir(parents=True, exist_ok=True)
|
old_meta = self._metadata_file(bucket_name, key)
|
||||||
meta_file.write_text(json.dumps({"metadata": clean}), encoding="utf-8")
|
try:
|
||||||
|
if old_meta.exists():
|
||||||
|
old_meta.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _archive_current_version(self, bucket_name: str, key: Path, *, reason: str) -> None:
|
def _archive_current_version(self, bucket_name: str, key: Path, *, reason: str) -> None:
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
@@ -1736,6 +1979,10 @@ class ObjectStorage:
|
|||||||
manifest_path.write_text(json.dumps(record), encoding="utf-8")
|
manifest_path.write_text(json.dumps(record), encoding="utf-8")
|
||||||
|
|
||||||
def _read_metadata(self, bucket_name: str, key: Path) -> Dict[str, str]:
|
def _read_metadata(self, bucket_name: str, key: Path) -> Dict[str, str]:
|
||||||
|
entry = self._read_index_entry(bucket_name, key)
|
||||||
|
if entry is not None:
|
||||||
|
data = entry.get("metadata")
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
for meta_file in (self._metadata_file(bucket_name, key), self._legacy_metadata_file(bucket_name, key)):
|
for meta_file in (self._metadata_file(bucket_name, key), self._legacy_metadata_file(bucket_name, key)):
|
||||||
if not meta_file.exists():
|
if not meta_file.exists():
|
||||||
continue
|
continue
|
||||||
@@ -1766,6 +2013,7 @@ class ObjectStorage:
|
|||||||
raise StorageError(message) from last_error
|
raise StorageError(message) from last_error
|
||||||
|
|
||||||
def _delete_metadata(self, bucket_name: str, key: Path) -> None:
|
def _delete_metadata(self, bucket_name: str, key: Path) -> None:
|
||||||
|
self._delete_index_entry(bucket_name, key)
|
||||||
locations = (
|
locations = (
|
||||||
(self._metadata_file(bucket_name, key), self._bucket_meta_root(bucket_name)),
|
(self._metadata_file(bucket_name, key), self._bucket_meta_root(bucket_name)),
|
||||||
(self._legacy_metadata_file(bucket_name, key), self._legacy_meta_root(bucket_name)),
|
(self._legacy_metadata_file(bucket_name, key), self._legacy_meta_root(bucket_name)),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
APP_VERSION = "0.2.4"
|
APP_VERSION = "0.2.9"
|
||||||
|
|
||||||
|
|
||||||
def get_version() -> str:
|
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
|
||||||
128
docs.md
128
docs.md
@@ -7,7 +7,7 @@ This document expands on the README to describe the full workflow for running, c
|
|||||||
MyFSIO ships two Flask entrypoints that share the same storage, IAM, and bucket-policy state:
|
MyFSIO ships two Flask entrypoints that share the same storage, IAM, and bucket-policy state:
|
||||||
|
|
||||||
- **API server** – Implements the S3-compatible REST API, policy evaluation, and Signature Version 4 presign service.
|
- **API server** – Implements the S3-compatible REST API, policy evaluation, and Signature Version 4 presign service.
|
||||||
- **UI server** – Provides the browser console for buckets, IAM, and policies. It proxies to the API for presign operations.
|
- **UI server** – Provides the browser console for buckets, IAM, and policies. It proxies all storage operations through the S3 API via boto3 (SigV4-signed), mirroring the architecture used by MinIO and Garage.
|
||||||
|
|
||||||
Both servers read `AppConfig`, so editing JSON stores on disk instantly affects both surfaces.
|
Both servers read `AppConfig`, so editing JSON stores on disk instantly affects both surfaces.
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ All configuration is done via environment variables. The table below lists every
|
|||||||
| `MAX_UPLOAD_SIZE` | `1073741824` (1 GiB) | Bytes. Caps incoming uploads in both API + UI. |
|
| `MAX_UPLOAD_SIZE` | `1073741824` (1 GiB) | Bytes. Caps incoming uploads in both API + UI. |
|
||||||
| `UI_PAGE_SIZE` | `100` | `MaxKeys` hint shown in listings. |
|
| `UI_PAGE_SIZE` | `100` | `MaxKeys` hint shown in listings. |
|
||||||
| `SECRET_KEY` | Auto-generated | Flask session key. Auto-generates and persists if not set. **Set explicitly in production.** |
|
| `SECRET_KEY` | Auto-generated | Flask session key. Auto-generates and persists if not set. **Set explicitly in production.** |
|
||||||
| `API_BASE_URL` | `None` | Public URL for presigned URLs. Required behind proxies. |
|
| `API_BASE_URL` | `http://127.0.0.1:5000` | Internal S3 API URL used by the web UI proxy. Also used for presigned URL generation. Set to your public URL if running behind a reverse proxy. |
|
||||||
| `AWS_REGION` | `us-east-1` | Region embedded in SigV4 credential scope. |
|
| `AWS_REGION` | `us-east-1` | Region embedded in SigV4 credential scope. |
|
||||||
| `AWS_SERVICE` | `s3` | Service string for SigV4. |
|
| `AWS_SERVICE` | `s3` | Service string for SigV4. |
|
||||||
|
|
||||||
@@ -619,13 +619,15 @@ MyFSIO implements a comprehensive Identity and Access Management (IAM) system th
|
|||||||
|
|
||||||
### Getting Started
|
### Getting Started
|
||||||
|
|
||||||
1. On first boot, `data/.myfsio.sys/config/iam.json` is seeded with `localadmin / localadmin` that has wildcard access.
|
1. On first boot, `data/.myfsio.sys/config/iam.json` is created with a randomly generated admin user. The access key and secret key are printed to the console during first startup. If you miss it, check the `iam.json` file directly—credentials are stored in plaintext.
|
||||||
2. Sign into the UI using those credentials, then open **IAM**:
|
2. Sign into the UI using the generated credentials, then open **IAM**:
|
||||||
- **Create user**: supply a display name and optional JSON inline policy array.
|
- **Create user**: supply a display name and optional JSON inline policy array.
|
||||||
- **Rotate secret**: generates a new secret key; the UI surfaces it once.
|
- **Rotate secret**: generates a new secret key; the UI surfaces it once.
|
||||||
- **Policy editor**: select a user, paste an array of objects (`{"bucket": "*", "actions": ["list", "read"]}`), and submit. Alias support includes AWS-style verbs (e.g., `s3:GetObject`).
|
- **Policy editor**: select a user, paste an array of objects (`{"bucket": "*", "actions": ["list", "read"]}`), and submit. Alias support includes AWS-style verbs (e.g., `s3:GetObject`).
|
||||||
3. Wildcard action `iam:*` is supported for admin user definitions.
|
3. Wildcard action `iam:*` is supported for admin user definitions.
|
||||||
|
|
||||||
|
> **Breaking Change (v0.2.0+):** Previous versions used fixed default credentials (`localadmin/localadmin`). If upgrading from an older version, your existing credentials remain unchanged, but new installations will generate random credentials.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
The API expects every request to include authentication headers. The UI persists them in the Flask session after login.
|
The API expects every request to include authentication headers. The UI persists them in the Flask session after login.
|
||||||
@@ -1550,6 +1552,9 @@ GET /<bucket>?notification # Get event notifications
|
|||||||
PUT /<bucket>?notification # Set event notifications (webhooks)
|
PUT /<bucket>?notification # Set event notifications (webhooks)
|
||||||
GET /<bucket>?object-lock # Get object lock configuration
|
GET /<bucket>?object-lock # Get object lock configuration
|
||||||
PUT /<bucket>?object-lock # Set 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>?uploads # List active multipart uploads
|
||||||
GET /<bucket>?versions # List object versions
|
GET /<bucket>?versions # List object versions
|
||||||
GET /<bucket>?location # Get bucket location/region
|
GET /<bucket>?location # Get bucket location/region
|
||||||
@@ -1594,6 +1599,11 @@ PUT /admin/sites/<site_id> # Update peer site
|
|||||||
DELETE /admin/sites/<site_id> # Unregister peer site
|
DELETE /admin/sites/<site_id> # Unregister peer site
|
||||||
GET /admin/sites/<site_id>/health # Check peer health
|
GET /admin/sites/<site_id>/health # Check peer health
|
||||||
GET /admin/topology # Get cluster topology
|
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
|
# KMS API
|
||||||
GET /kms/keys # List KMS keys
|
GET /kms/keys # List KMS keys
|
||||||
@@ -2227,3 +2237,113 @@ curl "http://localhost:5000/my-bucket?list-type=2&start-after=photos/2024/" \
|
|||||||
| `start-after` | Start listing after this key |
|
| `start-after` | Start listing after this key |
|
||||||
| `fetch-owner` | Include owner info in response |
|
| `fetch-owner` | Include owner info in response |
|
||||||
| `encoding-type` | Set to `url` for URL-encoded keys
|
| `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)
|
||||||
|
|||||||
5
run.py
5
run.py
@@ -5,6 +5,7 @@ import argparse
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
import multiprocessing
|
||||||
from multiprocessing import Process
|
from multiprocessing import Process
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -87,6 +88,10 @@ def serve_ui(port: int, prod: bool = False, config: Optional[AppConfig] = None)
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
if _is_frozen():
|
||||||
|
multiprocessing.set_start_method("spawn", force=True)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Run the S3 clone services.")
|
parser = argparse.ArgumentParser(description="Run the S3 clone services.")
|
||||||
parser.add_argument("--mode", choices=["api", "ui", "both"], default="both")
|
parser.add_argument("--mode", choices=["api", "ui", "both"], default="both")
|
||||||
parser.add_argument("--api-port", type=int, default=5000)
|
parser.add_argument("--api-port", type=int, default=5000)
|
||||||
|
|||||||
@@ -192,31 +192,86 @@ cat > "$INSTALL_DIR/myfsio.env" << EOF
|
|||||||
# Generated by install.sh on $(date)
|
# Generated by install.sh on $(date)
|
||||||
# Documentation: https://go.jzwsite.com/myfsio
|
# Documentation: https://go.jzwsite.com/myfsio
|
||||||
|
|
||||||
# Storage paths
|
# =============================================================================
|
||||||
|
# STORAGE PATHS
|
||||||
|
# =============================================================================
|
||||||
STORAGE_ROOT=$DATA_DIR
|
STORAGE_ROOT=$DATA_DIR
|
||||||
LOG_DIR=$LOG_DIR
|
LOG_DIR=$LOG_DIR
|
||||||
|
|
||||||
# Network
|
# =============================================================================
|
||||||
|
# NETWORK
|
||||||
|
# =============================================================================
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
APP_PORT=$API_PORT
|
APP_PORT=$API_PORT
|
||||||
|
|
||||||
# Security - CHANGE IN PRODUCTION
|
# Public URL (set this if behind a reverse proxy for presigned URLs)
|
||||||
SECRET_KEY=$SECRET_KEY
|
|
||||||
CORS_ORIGINS=*
|
|
||||||
|
|
||||||
# Public URL (set this if behind a reverse proxy)
|
|
||||||
$(if [[ -n "$API_URL" ]]; then echo "API_BASE_URL=$API_URL"; else echo "# API_BASE_URL=https://s3.example.com"; fi)
|
$(if [[ -n "$API_URL" ]]; then echo "API_BASE_URL=$API_URL"; else echo "# API_BASE_URL=https://s3.example.com"; fi)
|
||||||
|
|
||||||
# Logging
|
# =============================================================================
|
||||||
|
# SECURITY
|
||||||
|
# =============================================================================
|
||||||
|
# Secret key for session signing (auto-generated if not set)
|
||||||
|
SECRET_KEY=$SECRET_KEY
|
||||||
|
|
||||||
|
# CORS settings - restrict in production
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
|
||||||
|
# Brute-force protection
|
||||||
|
AUTH_MAX_ATTEMPTS=5
|
||||||
|
AUTH_LOCKOUT_MINUTES=15
|
||||||
|
|
||||||
|
# Reverse proxy settings (set to number of trusted proxies in front)
|
||||||
|
# NUM_TRUSTED_PROXIES=1
|
||||||
|
|
||||||
|
# Allow internal admin endpoints (only enable on trusted networks)
|
||||||
|
# ALLOW_INTERNAL_ENDPOINTS=false
|
||||||
|
|
||||||
|
# Allowed hosts for redirects (comma-separated, empty = restrict all)
|
||||||
|
# ALLOWED_REDIRECT_HOSTS=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGGING
|
||||||
|
# =============================================================================
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
LOG_TO_FILE=true
|
LOG_TO_FILE=true
|
||||||
|
|
||||||
# Rate limiting
|
# =============================================================================
|
||||||
|
# RATE LIMITING
|
||||||
|
# =============================================================================
|
||||||
RATE_LIMIT_DEFAULT=200 per minute
|
RATE_LIMIT_DEFAULT=200 per minute
|
||||||
|
# RATE_LIMIT_LIST_BUCKETS=60 per minute
|
||||||
|
# RATE_LIMIT_BUCKET_OPS=120 per minute
|
||||||
|
# RATE_LIMIT_OBJECT_OPS=240 per minute
|
||||||
|
# RATE_LIMIT_ADMIN=60 per minute
|
||||||
|
|
||||||
# Optional: Encryption (uncomment to enable)
|
# =============================================================================
|
||||||
|
# SERVER TUNING (0 = auto-detect based on system resources)
|
||||||
|
# =============================================================================
|
||||||
|
# SERVER_THREADS=0
|
||||||
|
# SERVER_CONNECTION_LIMIT=0
|
||||||
|
# SERVER_BACKLOG=0
|
||||||
|
# SERVER_CHANNEL_TIMEOUT=120
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENCRYPTION (uncomment to enable)
|
||||||
|
# =============================================================================
|
||||||
# ENCRYPTION_ENABLED=true
|
# ENCRYPTION_ENABLED=true
|
||||||
# KMS_ENABLED=true
|
# KMS_ENABLED=true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SITE SYNC / REPLICATION (for multi-site deployments)
|
||||||
|
# =============================================================================
|
||||||
|
# SITE_ID=site-1
|
||||||
|
# SITE_ENDPOINT=https://s3-site1.example.com
|
||||||
|
# SITE_REGION=us-east-1
|
||||||
|
# SITE_SYNC_ENABLED=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OPTIONAL FEATURES
|
||||||
|
# =============================================================================
|
||||||
|
# LIFECYCLE_ENABLED=false
|
||||||
|
# METRICS_HISTORY_ENABLED=false
|
||||||
|
# OPERATION_METRICS_ENABLED=false
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$INSTALL_DIR/myfsio.env"
|
chmod 600 "$INSTALL_DIR/myfsio.env"
|
||||||
echo " [OK] Created $INSTALL_DIR/myfsio.env"
|
echo " [OK] Created $INSTALL_DIR/myfsio.env"
|
||||||
@@ -308,7 +363,7 @@ if [[ "$SKIP_SYSTEMD" != true ]]; then
|
|||||||
systemctl start myfsio
|
systemctl start myfsio
|
||||||
echo " [OK] Service started"
|
echo " [OK] Service started"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
read -p "Would you like to enable MyFSIO to start on boot? [Y/n] " -n 1 -r
|
read -p "Would you like to enable MyFSIO to start on boot? [Y/n] " -n 1 -r
|
||||||
echo
|
echo
|
||||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||||
@@ -316,12 +371,37 @@ if [[ "$SKIP_SYSTEMD" != true ]]; then
|
|||||||
echo " [OK] Service enabled on boot"
|
echo " [OK] Service enabled on boot"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
sleep 2
|
echo " Waiting for service initialization..."
|
||||||
|
sleep 3
|
||||||
|
|
||||||
echo " Service Status:"
|
echo " Service Status:"
|
||||||
echo " ---------------"
|
echo " ---------------"
|
||||||
if systemctl is-active --quiet myfsio; then
|
if systemctl is-active --quiet myfsio; then
|
||||||
echo " [OK] MyFSIO is running"
|
echo " [OK] MyFSIO is running"
|
||||||
|
|
||||||
|
IAM_FILE="$DATA_DIR/.myfsio.sys/config/iam.json"
|
||||||
|
if [[ -f "$IAM_FILE" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo " ============================================"
|
||||||
|
echo " ADMIN CREDENTIALS (save these securely!)"
|
||||||
|
echo " ============================================"
|
||||||
|
if command -v jq &>/dev/null; then
|
||||||
|
ACCESS_KEY=$(jq -r '.users[0].access_key' "$IAM_FILE" 2>/dev/null)
|
||||||
|
SECRET_KEY=$(jq -r '.users[0].secret_key' "$IAM_FILE" 2>/dev/null)
|
||||||
|
else
|
||||||
|
ACCESS_KEY=$(grep -o '"access_key"[[:space:]]*:[[:space:]]*"[^"]*"' "$IAM_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||||
|
SECRET_KEY=$(grep -o '"secret_key"[[:space:]]*:[[:space:]]*"[^"]*"' "$IAM_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||||
|
fi
|
||||||
|
if [[ -n "$ACCESS_KEY" && -n "$SECRET_KEY" ]]; then
|
||||||
|
echo " Access Key: $ACCESS_KEY"
|
||||||
|
echo " Secret Key: $SECRET_KEY"
|
||||||
|
else
|
||||||
|
echo " [!] Could not parse credentials from $IAM_FILE"
|
||||||
|
echo " Check the file manually or view service logs."
|
||||||
|
fi
|
||||||
|
echo " ============================================"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " [WARNING] MyFSIO may not have started correctly"
|
echo " [WARNING] MyFSIO may not have started correctly"
|
||||||
echo " Check logs with: journalctl -u myfsio -f"
|
echo " Check logs with: journalctl -u myfsio -f"
|
||||||
@@ -346,19 +426,26 @@ echo "Access Points:"
|
|||||||
echo " API: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$API_PORT"
|
echo " API: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$API_PORT"
|
||||||
echo " UI: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$UI_PORT/ui"
|
echo " UI: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$UI_PORT/ui"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Default Credentials:"
|
echo "Credentials:"
|
||||||
echo " Username: localadmin"
|
echo " Admin credentials were shown above (if service was started)."
|
||||||
echo " Password: localadmin"
|
echo " You can also find them in: $DATA_DIR/.myfsio.sys/config/iam.json"
|
||||||
echo " [!] WARNING: Change these immediately after first login!"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Configuration Files:"
|
echo "Configuration Files:"
|
||||||
echo " Environment: $INSTALL_DIR/myfsio.env"
|
echo " Environment: $INSTALL_DIR/myfsio.env"
|
||||||
echo " IAM Users: $DATA_DIR/.myfsio.sys/config/iam.json"
|
echo " IAM Users: $DATA_DIR/.myfsio.sys/config/iam.json"
|
||||||
echo " Bucket Policies: $DATA_DIR/.myfsio.sys/config/bucket_policies.json"
|
echo " Bucket Policies: $DATA_DIR/.myfsio.sys/config/bucket_policies.json"
|
||||||
|
echo " Secret Key: $DATA_DIR/.myfsio.sys/config/.secret (auto-generated)"
|
||||||
|
echo ""
|
||||||
|
echo "Security Notes:"
|
||||||
|
echo " - Rate limiting is enabled by default (200 req/min)"
|
||||||
|
echo " - Brute-force protection: 5 attempts, 15 min lockout"
|
||||||
|
echo " - Set CORS_ORIGINS to specific domains in production"
|
||||||
|
echo " - Set NUM_TRUSTED_PROXIES if behind a reverse proxy"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Useful Commands:"
|
echo "Useful Commands:"
|
||||||
echo " Check status: sudo systemctl status myfsio"
|
echo " Check status: sudo systemctl status myfsio"
|
||||||
echo " View logs: sudo journalctl -u myfsio -f"
|
echo " View logs: sudo journalctl -u myfsio -f"
|
||||||
|
echo " Validate config: $INSTALL_DIR/myfsio --check-config"
|
||||||
echo " Restart: sudo systemctl restart myfsio"
|
echo " Restart: sudo systemctl restart myfsio"
|
||||||
echo " Stop: sudo systemctl stop myfsio"
|
echo " Stop: sudo systemctl stop myfsio"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ echo "The following items will be removed:"
|
|||||||
echo ""
|
echo ""
|
||||||
echo " Install directory: $INSTALL_DIR"
|
echo " Install directory: $INSTALL_DIR"
|
||||||
if [[ "$KEEP_DATA" != true ]]; then
|
if [[ "$KEEP_DATA" != true ]]; then
|
||||||
echo " Data directory: $DATA_DIR (ALL YOUR DATA WILL BE DELETED!)"
|
echo " Data directory: $DATA_DIR"
|
||||||
|
echo " [!] ALL DATA, IAM USERS, AND ENCRYPTION KEYS WILL BE DELETED!"
|
||||||
else
|
else
|
||||||
echo " Data directory: $DATA_DIR (WILL BE KEPT)"
|
echo " Data directory: $DATA_DIR (WILL BE KEPT)"
|
||||||
fi
|
fi
|
||||||
@@ -227,8 +228,15 @@ echo ""
|
|||||||
if [[ "$KEEP_DATA" == true ]]; then
|
if [[ "$KEEP_DATA" == true ]]; then
|
||||||
echo "Your data has been preserved at: $DATA_DIR"
|
echo "Your data has been preserved at: $DATA_DIR"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To reinstall MyFSIO with existing data, run:"
|
echo "Preserved files include:"
|
||||||
echo " curl -fsSL https://go.jzwsite.com/myfsio-install | sudo bash"
|
echo " - All buckets and objects"
|
||||||
|
echo " - IAM configuration: $DATA_DIR/.myfsio.sys/config/iam.json"
|
||||||
|
echo " - Bucket policies: $DATA_DIR/.myfsio.sys/config/bucket_policies.json"
|
||||||
|
echo " - Secret key: $DATA_DIR/.myfsio.sys/config/.secret"
|
||||||
|
echo " - Encryption keys: $DATA_DIR/.myfsio.sys/keys/ (if encryption was enabled)"
|
||||||
|
echo ""
|
||||||
|
echo "To reinstall MyFSIO with existing data:"
|
||||||
|
echo " ./install.sh --data-dir $DATA_DIR"
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1288,6 +1288,20 @@ html.sidebar-will-collapse .sidebar-user {
|
|||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#preview-text {
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: 'SFMono-Regular', 'Menlo', 'Consolas', 'Liberation Mono', monospace;
|
||||||
|
font-size: .8rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
tab-size: 4;
|
||||||
|
color: var(--myfsio-text);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-progress-stack {
|
.upload-progress-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
const previewImage = document.getElementById('preview-image');
|
const previewImage = document.getElementById('preview-image');
|
||||||
const previewVideo = document.getElementById('preview-video');
|
const previewVideo = document.getElementById('preview-video');
|
||||||
const previewAudio = document.getElementById('preview-audio');
|
const previewAudio = document.getElementById('preview-audio');
|
||||||
|
const previewText = document.getElementById('preview-text');
|
||||||
const previewIframe = document.getElementById('preview-iframe');
|
const previewIframe = document.getElementById('preview-iframe');
|
||||||
const downloadButton = document.getElementById('downloadButton');
|
const downloadButton = document.getElementById('downloadButton');
|
||||||
const presignButton = document.getElementById('presignButton');
|
const presignButton = document.getElementById('presignButton');
|
||||||
@@ -182,6 +183,9 @@
|
|||||||
let visibleItems = [];
|
let visibleItems = [];
|
||||||
let renderedRange = { start: 0, end: 0 };
|
let renderedRange = { start: 0, end: 0 };
|
||||||
|
|
||||||
|
let memoizedVisibleItems = null;
|
||||||
|
let memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
|
||||||
|
|
||||||
const createObjectRow = (obj, displayKey = null) => {
|
const createObjectRow = (obj, displayKey = null) => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.dataset.objectRow = '';
|
tr.dataset.objectRow = '';
|
||||||
@@ -340,7 +344,21 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const computeVisibleItems = () => {
|
const computeVisibleItems = (forceRecompute = false) => {
|
||||||
|
const currentInputs = {
|
||||||
|
objectCount: allObjects.length,
|
||||||
|
prefix: currentPrefix,
|
||||||
|
filterTerm: currentFilterTerm
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!forceRecompute &&
|
||||||
|
memoizedVisibleItems !== null &&
|
||||||
|
memoizedInputs.objectCount === currentInputs.objectCount &&
|
||||||
|
memoizedInputs.prefix === currentInputs.prefix &&
|
||||||
|
memoizedInputs.filterTerm === currentInputs.filterTerm) {
|
||||||
|
return memoizedVisibleItems;
|
||||||
|
}
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
const folders = new Set();
|
const folders = new Set();
|
||||||
|
|
||||||
@@ -381,6 +399,8 @@
|
|||||||
return aKey.localeCompare(bKey);
|
return aKey.localeCompare(bKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
memoizedVisibleItems = items;
|
||||||
|
memoizedInputs = currentInputs;
|
||||||
return items;
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -497,6 +517,9 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let lastStreamRenderTime = 0;
|
||||||
|
const STREAM_RENDER_THROTTLE_MS = 500;
|
||||||
|
|
||||||
const flushPendingStreamObjects = () => {
|
const flushPendingStreamObjects = () => {
|
||||||
if (pendingStreamObjects.length === 0) return;
|
if (pendingStreamObjects.length === 0) return;
|
||||||
const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length);
|
const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length);
|
||||||
@@ -513,6 +536,19 @@
|
|||||||
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()}${countText} loading...`;
|
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()}${countText} loading...`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (objectsLoadingRow && objectsLoadingRow.parentNode) {
|
||||||
|
const loadingText = objectsLoadingRow.querySelector('p');
|
||||||
|
if (loadingText) {
|
||||||
|
const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : '';
|
||||||
|
loadingText.textContent = `Loading ${loadedObjectCount.toLocaleString()}${countText} objects...`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = performance.now();
|
||||||
|
if (!streamingComplete && now - lastStreamRenderTime < STREAM_RENDER_THROTTLE_MS) {
|
||||||
|
streamRenderScheduled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastStreamRenderTime = now;
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
streamRenderScheduled = false;
|
streamRenderScheduled = false;
|
||||||
};
|
};
|
||||||
@@ -533,7 +569,10 @@
|
|||||||
loadedObjectCount = 0;
|
loadedObjectCount = 0;
|
||||||
totalObjectCount = 0;
|
totalObjectCount = 0;
|
||||||
allObjects = [];
|
allObjects = [];
|
||||||
|
memoizedVisibleItems = null;
|
||||||
|
memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
|
||||||
pendingStreamObjects = [];
|
pendingStreamObjects = [];
|
||||||
|
lastStreamRenderTime = 0;
|
||||||
|
|
||||||
streamAbortController = new AbortController();
|
streamAbortController = new AbortController();
|
||||||
|
|
||||||
@@ -548,7 +587,10 @@
|
|||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (objectsLoadingRow) objectsLoadingRow.remove();
|
if (objectsLoadingRow) {
|
||||||
|
const loadingText = objectsLoadingRow.querySelector('p');
|
||||||
|
if (loadingText) loadingText.textContent = 'Receiving objects...';
|
||||||
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -576,6 +618,10 @@
|
|||||||
break;
|
break;
|
||||||
case 'count':
|
case 'count':
|
||||||
totalObjectCount = msg.total_count || 0;
|
totalObjectCount = msg.total_count || 0;
|
||||||
|
if (objectsLoadingRow) {
|
||||||
|
const loadingText = objectsLoadingRow.querySelector('p');
|
||||||
|
if (loadingText) loadingText.textContent = `Loading 0 of ${totalObjectCount.toLocaleString()} objects...`;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'object':
|
case 'object':
|
||||||
pendingStreamObjects.push(processStreamObject(msg));
|
pendingStreamObjects.push(processStreamObject(msg));
|
||||||
@@ -609,11 +655,16 @@
|
|||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
flushPendingStreamObjects();
|
|
||||||
streamingComplete = true;
|
streamingComplete = true;
|
||||||
|
flushPendingStreamObjects();
|
||||||
hasMoreObjects = false;
|
hasMoreObjects = false;
|
||||||
|
totalObjectCount = loadedObjectCount;
|
||||||
updateObjectCountBadge();
|
updateObjectCountBadge();
|
||||||
|
|
||||||
|
if (objectsLoadingRow && objectsLoadingRow.parentNode) {
|
||||||
|
objectsLoadingRow.remove();
|
||||||
|
}
|
||||||
|
|
||||||
if (loadMoreStatus) {
|
if (loadMoreStatus) {
|
||||||
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
|
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
|
||||||
}
|
}
|
||||||
@@ -643,6 +694,8 @@
|
|||||||
loadedObjectCount = 0;
|
loadedObjectCount = 0;
|
||||||
totalObjectCount = 0;
|
totalObjectCount = 0;
|
||||||
allObjects = [];
|
allObjects = [];
|
||||||
|
memoizedVisibleItems = null;
|
||||||
|
memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (append && loadMoreSpinner) {
|
if (append && loadMoreSpinner) {
|
||||||
@@ -985,13 +1038,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToFolder = (prefix) => {
|
const navigateToFolder = (prefix) => {
|
||||||
|
if (streamAbortController) {
|
||||||
|
streamAbortController.abort();
|
||||||
|
streamAbortController = null;
|
||||||
|
}
|
||||||
|
|
||||||
currentPrefix = prefix;
|
currentPrefix = prefix;
|
||||||
|
|
||||||
if (scrollContainer) scrollContainer.scrollTop = 0;
|
if (scrollContainer) scrollContainer.scrollTop = 0;
|
||||||
|
|
||||||
refreshVirtualList();
|
|
||||||
renderBreadcrumb(prefix);
|
|
||||||
|
|
||||||
selectedRows.clear();
|
selectedRows.clear();
|
||||||
|
|
||||||
if (typeof updateBulkDeleteState === 'function') {
|
if (typeof updateBulkDeleteState === 'function') {
|
||||||
@@ -1001,6 +1056,9 @@
|
|||||||
if (previewPanel) previewPanel.classList.add('d-none');
|
if (previewPanel) previewPanel.classList.add('d-none');
|
||||||
if (previewEmpty) previewEmpty.classList.remove('d-none');
|
if (previewEmpty) previewEmpty.classList.remove('d-none');
|
||||||
activeRow = null;
|
activeRow = null;
|
||||||
|
|
||||||
|
isLoadingObjects = false;
|
||||||
|
loadObjects(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderObjectsView = () => {
|
const renderObjectsView = () => {
|
||||||
@@ -1838,6 +1896,10 @@
|
|||||||
el.setAttribute('src', 'about:blank');
|
el.setAttribute('src', 'about:blank');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (previewText) {
|
||||||
|
previewText.classList.add('d-none');
|
||||||
|
previewText.textContent = '';
|
||||||
|
}
|
||||||
previewPlaceholder.classList.remove('d-none');
|
previewPlaceholder.classList.remove('d-none');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1901,11 +1963,28 @@
|
|||||||
previewIframe.style.minHeight = '500px';
|
previewIframe.style.minHeight = '500px';
|
||||||
previewIframe.classList.remove('d-none');
|
previewIframe.classList.remove('d-none');
|
||||||
previewPlaceholder.classList.add('d-none');
|
previewPlaceholder.classList.add('d-none');
|
||||||
} else if (previewUrl && lower.match(/\.(txt|log|json|md|csv|xml|html|htm|js|ts|py|java|c|cpp|h|css|scss|yaml|yml|toml|ini|cfg|conf|sh|bat)$/)) {
|
} else if (previewUrl && previewText && lower.match(/\.(txt|log|json|md|csv|xml|html|htm|js|ts|py|java|c|cpp|h|css|scss|yaml|yml|toml|ini|cfg|conf|sh|bat|rs|go|rb|php|sql|r|swift|kt|scala|pl|lua|zig|ex|exs|hs|erl|ps1|psm1|psd1|fish|zsh|env|properties|gradle|makefile|dockerfile|vagrantfile|gitignore|gitattributes|editorconfig|eslintrc|prettierrc)$/)) {
|
||||||
previewIframe.src = previewUrl;
|
previewText.textContent = 'Loading\u2026';
|
||||||
previewIframe.style.minHeight = '200px';
|
previewText.classList.remove('d-none');
|
||||||
previewIframe.classList.remove('d-none');
|
|
||||||
previewPlaceholder.classList.add('d-none');
|
previewPlaceholder.classList.add('d-none');
|
||||||
|
const currentRow = row;
|
||||||
|
fetch(previewUrl)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
|
const len = parseInt(r.headers.get('Content-Length') || '0', 10);
|
||||||
|
if (len > 512 * 1024) {
|
||||||
|
return r.text().then((t) => t.slice(0, 512 * 1024) + '\n\n--- Truncated (file too large for preview) ---');
|
||||||
|
}
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.then((text) => {
|
||||||
|
if (activeRow !== currentRow) return;
|
||||||
|
previewText.textContent = text;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (activeRow !== currentRow) return;
|
||||||
|
previewText.textContent = 'Failed to load preview';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadataUrl = row.dataset.metadataUrl;
|
const metadataUrl = row.dataset.metadataUrl;
|
||||||
@@ -4085,6 +4164,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interceptForm('websiteForm', {
|
||||||
|
successMessage: 'Website settings saved',
|
||||||
|
onSuccess: function (data) {
|
||||||
|
updateWebsiteCard(data.enabled !== false, data.index_document, data.error_document);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
interceptForm('bucketPolicyForm', {
|
interceptForm('bucketPolicyForm', {
|
||||||
successMessage: 'Bucket policy saved',
|
successMessage: 'Bucket policy saved',
|
||||||
onSuccess: function (data) {
|
onSuccess: function (data) {
|
||||||
@@ -4145,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() {
|
function reloadReplicationPane() {
|
||||||
var replicationPane = document.getElementById('replication-pane');
|
var replicationPane = document.getElementById('replication-pane');
|
||||||
if (!replicationPane) return;
|
if (!replicationPane) return;
|
||||||
|
|||||||
@@ -101,6 +101,15 @@
|
|||||||
<span>Sites</span>
|
<span>Sites</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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>
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
<span class="nav-section-title">Resources</span>
|
<span class="nav-section-title">Resources</span>
|
||||||
@@ -192,6 +201,15 @@
|
|||||||
<span class="sidebar-link-text">Sites</span>
|
<span class="sidebar-link-text">Sites</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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>
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
<span class="nav-section-title">Resources</span>
|
<span class="nav-section-title">Resources</span>
|
||||||
|
|||||||
@@ -321,7 +321,8 @@
|
|||||||
<img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" />
|
<img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" />
|
||||||
<video id="preview-video" class="w-100 d-none" controls style="display: block;"></video>
|
<video id="preview-video" class="w-100 d-none" controls style="display: block;"></video>
|
||||||
<audio id="preview-audio" class="w-100 d-none" controls style="display: block;"></audio>
|
<audio id="preview-audio" class="w-100 d-none" controls style="display: block;"></audio>
|
||||||
<iframe id="preview-iframe" class="w-100 d-none" loading="lazy" style="min-height: 200px;"></iframe>
|
<pre id="preview-text" class="w-100 d-none m-0"></pre>
|
||||||
|
<iframe id="preview-iframe" class="w-100 d-none" style="min-height: 200px;"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -965,6 +966,89 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
|
|
||||||
bucketItems.forEach(item => {
|
bucketItems.forEach(item => {
|
||||||
const name = item.querySelector('.card-title').textContent.toLowerCase();
|
const name = item.querySelector('.bucket-name').textContent.toLowerCase();
|
||||||
if (name.includes(term)) {
|
if (name.includes(term)) {
|
||||||
item.classList.remove('d-none');
|
item.classList.remove('d-none');
|
||||||
visibleCount++;
|
visibleCount++;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
<li><a href="#advanced-ops">Advanced Operations</a></li>
|
<li><a href="#advanced-ops">Advanced Operations</a></li>
|
||||||
<li><a href="#acls">Access Control Lists</a></li>
|
<li><a href="#acls">Access Control Lists</a></li>
|
||||||
<li><a href="#tagging">Object & Bucket Tagging</a></li>
|
<li><a href="#tagging">Object & Bucket Tagging</a></li>
|
||||||
|
<li><a href="#website-hosting">Static Website Hosting</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,8 +98,8 @@ python run.py --mode ui
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>API_BASE_URL</code></td>
|
<td><code>API_BASE_URL</code></td>
|
||||||
<td><code>None</code></td>
|
<td><code>http://127.0.0.1:5000</code></td>
|
||||||
<td>The public URL of the API. <strong>Required</strong> if running behind a proxy. Ensures presigned URLs are generated correctly.</td>
|
<td>Internal S3 API URL used by the web UI proxy. Also used for presigned URL generation. Set to your public URL if running behind a reverse proxy.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>STORAGE_ROOT</code></td>
|
<td><code>STORAGE_ROOT</code></td>
|
||||||
@@ -451,10 +452,10 @@ sudo journalctl -u myfsio -f # View logs</code></pre>
|
|||||||
<span class="docs-section-kicker">03</span>
|
<span class="docs-section-kicker">03</span>
|
||||||
<h2 class="h4 mb-0">Authenticate & manage IAM</h2>
|
<h2 class="h4 mb-0">Authenticate & manage IAM</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted">MyFSIO seeds <code>data/.myfsio.sys/config/iam.json</code> with <code>localadmin/localadmin</code>. Sign in once, rotate it, then grant least-privilege access to teammates and tools.</p>
|
<p class="text-muted">On first startup, MyFSIO generates random admin credentials and prints them to the console. Missed it? Check <code>data/.myfsio.sys/config/iam.json</code> directly—credentials are stored in plaintext.</p>
|
||||||
<div class="docs-highlight mb-3">
|
<div class="docs-highlight mb-3">
|
||||||
<ol class="mb-0">
|
<ol class="mb-0">
|
||||||
<li>Visit <code>/ui/login</code>, enter the bootstrap credentials, and rotate them immediately from the IAM page.</li>
|
<li>Check the console output (or <code>iam.json</code>) for the generated <code>Access Key</code> and <code>Secret Key</code>, then visit <code>/ui/login</code>.</li>
|
||||||
<li>Create additional users with descriptive display names and AWS-style inline policies (for example <code>{"bucket": "*", "actions": ["list", "read"]}</code>).</li>
|
<li>Create additional users with descriptive display names and AWS-style inline policies (for example <code>{"bucket": "*", "actions": ["list", "read"]}</code>).</li>
|
||||||
<li>Rotate secrets when sharing with CI jobs—new secrets display once and persist to <code>data/.myfsio.sys/config/iam.json</code>.</li>
|
<li>Rotate secrets when sharing with CI jobs—new secrets display once and persist to <code>data/.myfsio.sys/config/iam.json</code>.</li>
|
||||||
<li>Bucket policies layer on top of IAM. Apply Private/Public presets or paste custom JSON; changes reload instantly.</li>
|
<li>Bucket policies layer on top of IAM. Apply Private/Public presets or paste custom JSON; changes reload instantly.</li>
|
||||||
@@ -2099,6 +2100,99 @@ curl -X PUT "{{ api_base }}/<bucket>?tagging" \
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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>
|
||||||
<div class="col-xl-4 docs-sidebar-col">
|
<div class="col-xl-4 docs-sidebar-col">
|
||||||
<aside class="card shadow-sm docs-sidebar">
|
<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="#advanced-ops">Advanced Operations</a></li>
|
||||||
<li><a href="#acls">Access Control Lists</a></li>
|
<li><a href="#acls">Access Control Lists</a></li>
|
||||||
<li><a href="#tagging">Object & Bucket Tagging</a></li>
|
<li><a href="#tagging">Object & Bucket Tagging</a></li>
|
||||||
|
<li><a href="#website-hosting">Static Website Hosting</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="docs-sidebar-callouts">
|
<div class="docs-sidebar-callouts">
|
||||||
<div>
|
<div>
|
||||||
@@ -2136,8 +2231,8 @@ curl -X PUT "{{ api_base }}/<bucket>?tagging" \
|
|||||||
<code class="d-block">{{ api_base }}</code>
|
<code class="d-block">{{ api_base }}</code>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="small text-uppercase text-muted">Sample user</div>
|
<div class="small text-uppercase text-muted">Initial credentials</div>
|
||||||
<code class="d-block">localadmin / localadmin</code>
|
<span class="text-muted small">Generated on first run (check console)</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="small text-uppercase text-muted">Logs</div>
|
<div class="small text-uppercase text-muted">Logs</div>
|
||||||
|
|||||||
@@ -398,6 +398,14 @@
|
|||||||
<option value="24" selected>Last 24 hours</option>
|
<option value="24" selected>Last 24 hours</option>
|
||||||
<option value="168">Last 7 days</option>
|
<option value="168">Last 7 days</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select class="form-select form-select-sm" id="maxDataPoints" style="width: auto;" title="Maximum data points to display">
|
||||||
|
<option value="100">100 points</option>
|
||||||
|
<option value="250">250 points</option>
|
||||||
|
<option value="500" selected>500 points</option>
|
||||||
|
<option value="1000">1000 points</option>
|
||||||
|
<option value="2000">2000 points</option>
|
||||||
|
<option value="0">Unlimited</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
@@ -817,8 +825,8 @@
|
|||||||
var diskChart = null;
|
var diskChart = null;
|
||||||
var historyStatus = document.getElementById('historyStatus');
|
var historyStatus = document.getElementById('historyStatus');
|
||||||
var timeRangeSelect = document.getElementById('historyTimeRange');
|
var timeRangeSelect = document.getElementById('historyTimeRange');
|
||||||
|
var maxDataPointsSelect = document.getElementById('maxDataPoints');
|
||||||
var historyTimer = null;
|
var historyTimer = null;
|
||||||
var MAX_DATA_POINTS = 500;
|
|
||||||
|
|
||||||
function createChart(ctx, label, color) {
|
function createChart(ctx, label, color) {
|
||||||
return new Chart(ctx, {
|
return new Chart(ctx, {
|
||||||
@@ -889,7 +897,8 @@
|
|||||||
if (historyStatus) historyStatus.textContent = 'No history data available yet. Data is recorded every ' + (data.interval_minutes || 5) + ' minutes.';
|
if (historyStatus) historyStatus.textContent = 'No history data available yet. Data is recorded every ' + (data.interval_minutes || 5) + ' minutes.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var history = data.history.slice(-MAX_DATA_POINTS);
|
var maxPoints = maxDataPointsSelect ? parseInt(maxDataPointsSelect.value, 10) : 500;
|
||||||
|
var history = maxPoints > 0 ? data.history.slice(-maxPoints) : data.history;
|
||||||
var labels = history.map(function(h) { return formatTime(h.timestamp); });
|
var labels = history.map(function(h) { return formatTime(h.timestamp); });
|
||||||
var cpuData = history.map(function(h) { return h.cpu_percent; });
|
var cpuData = history.map(function(h) { return h.cpu_percent; });
|
||||||
var memData = history.map(function(h) { return h.memory_percent; });
|
var memData = history.map(function(h) { return h.memory_percent; });
|
||||||
@@ -927,6 +936,10 @@
|
|||||||
timeRangeSelect.addEventListener('change', loadHistory);
|
timeRangeSelect.addEventListener('change', loadHistory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (maxDataPointsSelect) {
|
||||||
|
maxDataPointsSelect.addEventListener('change', loadHistory);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', function() {
|
document.addEventListener('visibilitychange', function() {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
if (historyTimer) clearInterval(historyTimer);
|
if (historyTimer) clearInterval(historyTimer);
|
||||||
|
|||||||
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 %}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
def _build_app(tmp_path: Path):
|
def _build_app(tmp_path: Path):
|
||||||
@@ -26,13 +30,32 @@ def _build_app(tmp_path: Path):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://localhost",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
app.config["API_BASE_URL"] = api_url
|
||||||
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
def _login(client):
|
def _login(client):
|
||||||
return client.post(
|
return client.post(
|
||||||
"/ui/login",
|
"/ui/login",
|
||||||
@@ -43,54 +66,60 @@ def _login(client):
|
|||||||
|
|
||||||
def test_bulk_delete_json_route(tmp_path: Path):
|
def test_bulk_delete_json_route(tmp_path: Path):
|
||||||
app = _build_app(tmp_path)
|
app = _build_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("demo")
|
storage = app.extensions["object_storage"]
|
||||||
storage.put_object("demo", "first.txt", io.BytesIO(b"first"))
|
storage.create_bucket("demo")
|
||||||
storage.put_object("demo", "second.txt", io.BytesIO(b"second"))
|
storage.put_object("demo", "first.txt", io.BytesIO(b"first"))
|
||||||
|
storage.put_object("demo", "second.txt", io.BytesIO(b"second"))
|
||||||
|
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
assert _login(client).status_code == 200
|
assert _login(client).status_code == 200
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/ui/buckets/demo/objects/bulk-delete",
|
"/ui/buckets/demo/objects/bulk-delete",
|
||||||
json={"keys": ["first.txt", "missing.txt"]},
|
json={"keys": ["first.txt", "missing.txt"]},
|
||||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = response.get_json()
|
payload = response.get_json()
|
||||||
assert payload["status"] == "ok"
|
assert payload["status"] == "ok"
|
||||||
assert set(payload["deleted"]) == {"first.txt", "missing.txt"}
|
assert set(payload["deleted"]) == {"first.txt", "missing.txt"}
|
||||||
assert payload["errors"] == []
|
assert payload["errors"] == []
|
||||||
|
|
||||||
listing = storage.list_objects_all("demo")
|
listing = storage.list_objects_all("demo")
|
||||||
assert {meta.key for meta in listing} == {"second.txt"}
|
assert {meta.key for meta in listing} == {"second.txt"}
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
|
|
||||||
def test_bulk_delete_validation(tmp_path: Path):
|
def test_bulk_delete_validation(tmp_path: Path):
|
||||||
app = _build_app(tmp_path)
|
app = _build_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("demo")
|
storage = app.extensions["object_storage"]
|
||||||
storage.put_object("demo", "keep.txt", io.BytesIO(b"keep"))
|
storage.create_bucket("demo")
|
||||||
|
storage.put_object("demo", "keep.txt", io.BytesIO(b"keep"))
|
||||||
|
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
assert _login(client).status_code == 200
|
assert _login(client).status_code == 200
|
||||||
|
|
||||||
bad_response = client.post(
|
bad_response = client.post(
|
||||||
"/ui/buckets/demo/objects/bulk-delete",
|
"/ui/buckets/demo/objects/bulk-delete",
|
||||||
json={"keys": []},
|
json={"keys": []},
|
||||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||||
)
|
)
|
||||||
assert bad_response.status_code == 400
|
assert bad_response.status_code == 400
|
||||||
assert bad_response.get_json()["status"] == "error"
|
assert bad_response.get_json()["status"] == "error"
|
||||||
|
|
||||||
too_many = [f"obj-{index}.txt" for index in range(501)]
|
too_many = [f"obj-{index}.txt" for index in range(501)]
|
||||||
limit_response = client.post(
|
limit_response = client.post(
|
||||||
"/ui/buckets/demo/objects/bulk-delete",
|
"/ui/buckets/demo/objects/bulk-delete",
|
||||||
json={"keys": too_many},
|
json={"keys": too_many},
|
||||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||||
)
|
)
|
||||||
assert limit_response.status_code == 400
|
assert limit_response.status_code == 400
|
||||||
assert limit_response.get_json()["status"] == "error"
|
assert limit_response.get_json()["status"] == "error"
|
||||||
|
|
||||||
still_there = storage.list_objects_all("demo")
|
still_there = storage.list_objects_all("demo")
|
||||||
assert {meta.key for meta in still_there} == {"keep.txt"}
|
assert {meta.key for meta in still_there} == {"keep.txt"}
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Tests for UI-based encryption configuration."""
|
"""Tests for UI-based encryption configuration."""
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
def get_csrf_token(response):
|
def get_csrf_token(response):
|
||||||
@@ -37,212 +40,224 @@ def _make_encryption_app(tmp_path: Path, *, kms_enabled: bool = True):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
iam_config.write_text(json.dumps(iam_payload))
|
iam_config.write_text(json.dumps(iam_payload))
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"TESTING": True,
|
"TESTING": True,
|
||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://testserver",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
"ENCRYPTION_ENABLED": True,
|
"ENCRYPTION_ENABLED": True,
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
if kms_enabled:
|
if kms_enabled:
|
||||||
config["KMS_ENABLED"] = True
|
config["KMS_ENABLED"] = True
|
||||||
config["KMS_KEYS_PATH"] = str(tmp_path / "kms_keys.json")
|
config["KMS_KEYS_PATH"] = str(tmp_path / "kms_keys.json")
|
||||||
config["ENCRYPTION_MASTER_KEY_PATH"] = str(tmp_path / "master.key")
|
config["ENCRYPTION_MASTER_KEY_PATH"] = str(tmp_path / "master.key")
|
||||||
|
|
||||||
app = create_app(config)
|
app = create_app(config)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
app.config["API_BASE_URL"] = api_url
|
||||||
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
|
|
||||||
storage = app.extensions["object_storage"]
|
storage = app.extensions["object_storage"]
|
||||||
storage.create_bucket("test-bucket")
|
storage.create_bucket("test-bucket")
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
class TestUIBucketEncryption:
|
class TestUIBucketEncryption:
|
||||||
"""Test bucket encryption configuration via UI."""
|
"""Test bucket encryption configuration via UI."""
|
||||||
|
|
||||||
def test_bucket_detail_shows_encryption_card(self, tmp_path):
|
def test_bucket_detail_shows_encryption_card(self, tmp_path):
|
||||||
"""Encryption card should be visible on bucket detail page."""
|
"""Encryption card should be visible on bucket detail page."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "Default Encryption" in html
|
||||||
|
assert "Encryption Algorithm" in html or "Default encryption disabled" in html
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "Default Encryption" in html
|
|
||||||
assert "Encryption Algorithm" in html or "Default encryption disabled" in html
|
|
||||||
|
|
||||||
def test_enable_aes256_encryption(self, tmp_path):
|
def test_enable_aes256_encryption(self, tmp_path):
|
||||||
"""Should be able to enable AES-256 encryption."""
|
"""Should be able to enable AES-256 encryption."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "AES-256" in html or "encryption enabled" in html.lower()
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/ui/buckets/test-bucket/encryption",
|
|
||||||
data={
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "AES256",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "AES-256" in html or "encryption enabled" in html.lower()
|
|
||||||
|
|
||||||
def test_enable_kms_encryption(self, tmp_path):
|
def test_enable_kms_encryption(self, tmp_path):
|
||||||
"""Should be able to enable KMS encryption."""
|
"""Should be able to enable KMS encryption."""
|
||||||
app = _make_encryption_app(tmp_path, kms_enabled=True)
|
app = _make_encryption_app(tmp_path, kms_enabled=True)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
with app.app_context():
|
||||||
|
kms = app.extensions.get("kms")
|
||||||
|
if kms:
|
||||||
|
key = kms.create_key("test-key")
|
||||||
|
key_id = key.key_id
|
||||||
|
else:
|
||||||
|
pytest.skip("KMS not available")
|
||||||
|
|
||||||
with app.app_context():
|
client = app.test_client()
|
||||||
kms = app.extensions.get("kms")
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
if kms:
|
|
||||||
key = kms.create_key("test-key")
|
|
||||||
key_id = key.key_id
|
|
||||||
else:
|
|
||||||
pytest.skip("KMS not available")
|
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
response = client.post(
|
||||||
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "aws:kms",
|
||||||
|
"kms_key_id": key_id,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
assert response.status_code == 200
|
||||||
csrf_token = get_csrf_token(response)
|
html = response.data.decode("utf-8")
|
||||||
|
assert "KMS" in html or "encryption enabled" in html.lower()
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/ui/buckets/test-bucket/encryption",
|
|
||||||
data={
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "aws:kms",
|
|
||||||
"kms_key_id": key_id,
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "KMS" in html or "encryption enabled" in html.lower()
|
|
||||||
|
|
||||||
def test_disable_encryption(self, tmp_path):
|
def test_disable_encryption(self, tmp_path):
|
||||||
"""Should be able to disable encryption."""
|
"""Should be able to disable encryption."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
client.post(
|
"action": "enable",
|
||||||
"/ui/buckets/test-bucket/encryption",
|
"algorithm": "AES256",
|
||||||
data={
|
},
|
||||||
"csrf_token": csrf_token,
|
)
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "AES256",
|
response = client.post(
|
||||||
},
|
"/ui/buckets/test-bucket/encryption",
|
||||||
)
|
data={
|
||||||
|
"action": "disable",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "disabled" in html.lower() or "Default encryption disabled" in html
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
|
||||||
csrf_token = get_csrf_token(response)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/ui/buckets/test-bucket/encryption",
|
|
||||||
data={
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "disable",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "disabled" in html.lower() or "Default encryption disabled" in html
|
|
||||||
|
|
||||||
def test_invalid_algorithm_rejected(self, tmp_path):
|
def test_invalid_algorithm_rejected(self, tmp_path):
|
||||||
"""Invalid encryption algorithm should be rejected."""
|
"""Invalid encryption algorithm should be rejected."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "INVALID",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
response = client.post(
|
assert response.status_code == 200
|
||||||
"/ui/buckets/test-bucket/encryption",
|
html = response.data.decode("utf-8")
|
||||||
data={
|
assert "Invalid" in html or "danger" in html
|
||||||
"csrf_token": csrf_token,
|
finally:
|
||||||
"action": "enable",
|
_shutdown_app(app)
|
||||||
"algorithm": "INVALID",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "Invalid" in html or "danger" in html
|
|
||||||
|
|
||||||
def test_encryption_persists_in_config(self, tmp_path):
|
def test_encryption_persists_in_config(self, tmp_path):
|
||||||
"""Encryption config should persist in bucket config."""
|
"""Encryption config should persist in bucket config."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
client.post(
|
with app.app_context():
|
||||||
"/ui/buckets/test-bucket/encryption",
|
storage = app.extensions["object_storage"]
|
||||||
data={
|
config = storage.get_bucket_encryption("test-bucket")
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "AES256",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with app.app_context():
|
assert "Rules" in config
|
||||||
storage = app.extensions["object_storage"]
|
assert len(config["Rules"]) == 1
|
||||||
config = storage.get_bucket_encryption("test-bucket")
|
assert config["Rules"][0]["SSEAlgorithm"] == "AES256"
|
||||||
|
finally:
|
||||||
assert "Rules" in config
|
_shutdown_app(app)
|
||||||
assert len(config["Rules"]) == 1
|
|
||||||
assert config["Rules"][0]["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"] == "AES256"
|
|
||||||
|
|
||||||
|
|
||||||
class TestUIEncryptionWithoutPermission:
|
class TestUIEncryptionWithoutPermission:
|
||||||
"""Test encryption UI when user lacks permissions."""
|
"""Test encryption UI when user lacks permissions."""
|
||||||
|
|
||||||
def test_readonly_user_cannot_change_encryption(self, tmp_path):
|
def test_readonly_user_cannot_change_encryption(self, tmp_path):
|
||||||
"""Read-only user should not be able to change encryption settings."""
|
"""Read-only user should not be able to change encryption settings."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "readonly", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "readonly", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
response = client.post(
|
assert response.status_code == 200
|
||||||
"/ui/buckets/test-bucket/encryption",
|
html = response.data.decode("utf-8")
|
||||||
data={
|
assert "Access denied" in html or "permission" in html.lower() or "not authorized" in html.lower()
|
||||||
"csrf_token": csrf_token,
|
finally:
|
||||||
"action": "enable",
|
_shutdown_app(app)
|
||||||
"algorithm": "AES256",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "Access denied" in html or "permission" in html.lower() or "not authorized" in html.lower()
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"""Tests for UI pagination of bucket objects."""
|
"""Tests for UI pagination of bucket objects."""
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
def _make_app(tmp_path: Path):
|
def _make_app(tmp_path: Path):
|
||||||
"""Create an app for testing."""
|
"""Create an app for testing with a live API server."""
|
||||||
storage_root = tmp_path / "data"
|
storage_root = tmp_path / "data"
|
||||||
iam_config = tmp_path / "iam.json"
|
iam_config = tmp_path / "iam.json"
|
||||||
bucket_policies = tmp_path / "bucket_policies.json"
|
bucket_policies = tmp_path / "bucket_policies.json"
|
||||||
@@ -33,157 +36,177 @@ def _make_app(tmp_path: Path):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, flask_app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
flask_app.config["API_BASE_URL"] = api_url
|
||||||
|
flask_app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
flask_app._test_server = server
|
||||||
|
flask_app._test_thread = thread
|
||||||
return flask_app
|
return flask_app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
class TestPaginatedObjectListing:
|
class TestPaginatedObjectListing:
|
||||||
"""Test paginated object listing API."""
|
"""Test paginated object listing API."""
|
||||||
|
|
||||||
def test_objects_api_returns_paginated_results(self, tmp_path):
|
def test_objects_api_returns_paginated_results(self, tmp_path):
|
||||||
"""Objects API should return paginated results."""
|
"""Objects API should return paginated results."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create 10 test objects
|
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
# Login first
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
|
||||||
|
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=3")
|
||||||
# Request first page of 3 objects
|
assert resp.status_code == 200
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=3")
|
|
||||||
assert resp.status_code == 200
|
data = resp.get_json()
|
||||||
|
assert len(data["objects"]) == 3
|
||||||
data = resp.get_json()
|
assert data["is_truncated"] is True
|
||||||
assert len(data["objects"]) == 3
|
assert data["next_continuation_token"] is not None
|
||||||
assert data["is_truncated"] is True
|
finally:
|
||||||
assert data["next_continuation_token"] is not None
|
_shutdown_app(app)
|
||||||
assert data["total_count"] == 10
|
|
||||||
|
|
||||||
def test_objects_api_pagination_continuation(self, tmp_path):
|
def test_objects_api_pagination_continuation(self, tmp_path):
|
||||||
"""Objects API should support continuation tokens."""
|
"""Objects API should support continuation tokens."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create 5 test objects
|
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# Get first page
|
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=2")
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=2")
|
assert resp.status_code == 200
|
||||||
assert resp.status_code == 200
|
data = resp.get_json()
|
||||||
data = resp.get_json()
|
|
||||||
|
first_page_keys = [obj["key"] for obj in data["objects"]]
|
||||||
first_page_keys = [obj["key"] for obj in data["objects"]]
|
assert len(first_page_keys) == 2
|
||||||
assert len(first_page_keys) == 2
|
assert data["is_truncated"] is True
|
||||||
assert data["is_truncated"] is True
|
|
||||||
|
token = data["next_continuation_token"]
|
||||||
# Get second page
|
resp = client.get(f"/ui/buckets/test-bucket/objects?max_keys=2&continuation_token={token}")
|
||||||
token = data["next_continuation_token"]
|
assert resp.status_code == 200
|
||||||
resp = client.get(f"/ui/buckets/test-bucket/objects?max_keys=2&continuation_token={token}")
|
data = resp.get_json()
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
second_page_keys = [obj["key"] for obj in data["objects"]]
|
||||||
|
assert len(second_page_keys) == 2
|
||||||
second_page_keys = [obj["key"] for obj in data["objects"]]
|
|
||||||
assert len(second_page_keys) == 2
|
assert set(first_page_keys).isdisjoint(set(second_page_keys))
|
||||||
|
finally:
|
||||||
# No overlap between pages
|
_shutdown_app(app)
|
||||||
assert set(first_page_keys).isdisjoint(set(second_page_keys))
|
|
||||||
|
|
||||||
def test_objects_api_prefix_filter(self, tmp_path):
|
def test_objects_api_prefix_filter(self, tmp_path):
|
||||||
"""Objects API should support prefix filtering."""
|
"""Objects API should support prefix filtering."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create objects with different prefixes
|
|
||||||
storage.put_object("test-bucket", "logs/access.log", BytesIO(b"log"))
|
storage.put_object("test-bucket", "logs/access.log", BytesIO(b"log"))
|
||||||
storage.put_object("test-bucket", "logs/error.log", BytesIO(b"log"))
|
storage.put_object("test-bucket", "logs/error.log", BytesIO(b"log"))
|
||||||
storage.put_object("test-bucket", "data/file.txt", BytesIO(b"data"))
|
storage.put_object("test-bucket", "data/file.txt", BytesIO(b"data"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# Filter by prefix
|
resp = client.get("/ui/buckets/test-bucket/objects?prefix=logs/")
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects?prefix=logs/")
|
assert resp.status_code == 200
|
||||||
assert resp.status_code == 200
|
data = resp.get_json()
|
||||||
data = resp.get_json()
|
|
||||||
|
keys = [obj["key"] for obj in data["objects"]]
|
||||||
keys = [obj["key"] for obj in data["objects"]]
|
assert all(k.startswith("logs/") for k in keys)
|
||||||
assert all(k.startswith("logs/") for k in keys)
|
assert len(keys) == 2
|
||||||
assert len(keys) == 2
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
def test_objects_api_requires_authentication(self, tmp_path):
|
def test_objects_api_requires_authentication(self, tmp_path):
|
||||||
"""Objects API should require login."""
|
"""Objects API should require login."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
with app.test_client() as client:
|
|
||||||
# Don't login
|
with app.test_client() as client:
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects")
|
resp = client.get("/ui/buckets/test-bucket/objects")
|
||||||
# Should redirect to login
|
assert resp.status_code == 302
|
||||||
assert resp.status_code == 302
|
assert "/ui/login" in resp.headers.get("Location", "")
|
||||||
assert "/ui/login" in resp.headers.get("Location", "")
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
def test_objects_api_returns_object_metadata(self, tmp_path):
|
def test_objects_api_returns_object_metadata(self, tmp_path):
|
||||||
"""Objects API should return complete object metadata."""
|
"""Objects API should return complete object metadata."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
storage.put_object("test-bucket", "test.txt", BytesIO(b"test content"))
|
storage.create_bucket("test-bucket")
|
||||||
|
storage.put_object("test-bucket", "test.txt", BytesIO(b"test content"))
|
||||||
with app.test_client() as client:
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
|
||||||
|
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
|
||||||
|
|
||||||
assert len(data["objects"]) == 1
|
|
||||||
obj = data["objects"][0]
|
|
||||||
|
|
||||||
# Check all expected fields
|
with app.test_client() as client:
|
||||||
assert obj["key"] == "test.txt"
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
assert obj["size"] == 12 # len("test content")
|
|
||||||
assert "last_modified" in obj
|
resp = client.get("/ui/buckets/test-bucket/objects")
|
||||||
assert "last_modified_display" in obj
|
assert resp.status_code == 200
|
||||||
assert "etag" in obj
|
data = resp.get_json()
|
||||||
|
|
||||||
|
assert len(data["objects"]) == 1
|
||||||
|
obj = data["objects"][0]
|
||||||
|
|
||||||
|
assert obj["key"] == "test.txt"
|
||||||
|
assert obj["size"] == 12
|
||||||
|
assert "last_modified" in obj
|
||||||
|
assert "last_modified_display" in obj
|
||||||
|
assert "etag" in obj
|
||||||
|
|
||||||
|
assert "url_templates" in data
|
||||||
|
templates = data["url_templates"]
|
||||||
|
assert "preview" in templates
|
||||||
|
assert "download" in templates
|
||||||
|
assert "delete" in templates
|
||||||
|
assert "KEY_PLACEHOLDER" in templates["preview"]
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
# URLs are now returned as templates (not per-object) for performance
|
|
||||||
assert "url_templates" in data
|
|
||||||
templates = data["url_templates"]
|
|
||||||
assert "preview" in templates
|
|
||||||
assert "download" in templates
|
|
||||||
assert "delete" in templates
|
|
||||||
assert "KEY_PLACEHOLDER" in templates["preview"]
|
|
||||||
|
|
||||||
def test_bucket_detail_page_loads_without_objects(self, tmp_path):
|
def test_bucket_detail_page_loads_without_objects(self, tmp_path):
|
||||||
"""Bucket detail page should load even with many objects."""
|
"""Bucket detail page should load even with many objects."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create many objects
|
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
storage.put_object("test-bucket", f"file{i:03d}.txt", BytesIO(b"x"))
|
storage.put_object("test-bucket", f"file{i:03d}.txt", BytesIO(b"x"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# The page should load quickly (objects loaded via JS)
|
resp = client.get("/ui/buckets/test-bucket")
|
||||||
resp = client.get("/ui/buckets/test-bucket")
|
assert resp.status_code == 200
|
||||||
assert resp.status_code == 200
|
|
||||||
|
html = resp.data.decode("utf-8")
|
||||||
html = resp.data.decode("utf-8")
|
assert "bucket-detail-main.js" in html
|
||||||
# Should have the JavaScript loading infrastructure (external JS file)
|
finally:
|
||||||
assert "bucket-detail-main.js" in html
|
_shutdown_app(app)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
DENY_LIST_ALLOW_GET_POLICY = {
|
DENY_LIST_ALLOW_GET_POLICY = {
|
||||||
@@ -47,11 +50,25 @@ def _make_ui_app(tmp_path: Path, *, enforce_policies: bool):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://testserver",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
"UI_ENFORCE_BUCKET_POLICIES": enforce_policies,
|
"UI_ENFORCE_BUCKET_POLICIES": enforce_policies,
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
app.config["API_BASE_URL"] = api_url
|
||||||
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
|
|
||||||
storage = app.extensions["object_storage"]
|
storage = app.extensions["object_storage"]
|
||||||
storage.create_bucket("testbucket")
|
storage.create_bucket("testbucket")
|
||||||
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
||||||
@@ -60,22 +77,28 @@ def _make_ui_app(tmp_path: Path, *, enforce_policies: bool):
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("enforce", [True, False])
|
@pytest.mark.parametrize("enforce", [True, False])
|
||||||
def test_ui_bucket_policy_enforcement_toggle(tmp_path: Path, enforce: bool):
|
def test_ui_bucket_policy_enforcement_toggle(tmp_path: Path, enforce: bool):
|
||||||
app = _make_ui_app(tmp_path, enforce_policies=enforce)
|
app = _make_ui_app(tmp_path, enforce_policies=enforce)
|
||||||
client = app.test_client()
|
try:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client = app.test_client()
|
||||||
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
if enforce:
|
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
||||||
assert b"Access denied by bucket policy" in response.data
|
if enforce:
|
||||||
else:
|
assert b"Access denied by bucket policy" in response.data
|
||||||
assert response.status_code == 200
|
else:
|
||||||
assert b"Access denied by bucket policy" not in response.data
|
assert response.status_code == 200
|
||||||
# Objects are now loaded via async API - check the objects endpoint
|
assert b"Access denied by bucket policy" not in response.data
|
||||||
objects_response = client.get("/ui/buckets/testbucket/objects")
|
objects_response = client.get("/ui/buckets/testbucket/objects")
|
||||||
assert objects_response.status_code == 200
|
assert objects_response.status_code == 403
|
||||||
data = objects_response.get_json()
|
finally:
|
||||||
assert any(obj["key"] == "vid.mp4" for obj in data["objects"])
|
_shutdown_app(app)
|
||||||
|
|
||||||
|
|
||||||
def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
||||||
@@ -99,23 +122,37 @@ def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://testserver",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
storage = app.extensions["object_storage"]
|
|
||||||
storage.create_bucket("testbucket")
|
|
||||||
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
|
||||||
policy_store = app.extensions["bucket_policies"]
|
|
||||||
policy_store.set_policy("testbucket", DENY_LIST_ALLOW_GET_POLICY)
|
|
||||||
|
|
||||||
client = app.test_client()
|
server = make_server("127.0.0.1", 0, app)
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
host, port = server.server_address
|
||||||
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
api_url = f"http://{host}:{port}"
|
||||||
assert response.status_code == 200
|
app.config["API_BASE_URL"] = api_url
|
||||||
assert b"Access denied by bucket policy" not in response.data
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
# Objects are now loaded via async API - check the objects endpoint
|
|
||||||
objects_response = client.get("/ui/buckets/testbucket/objects")
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
assert objects_response.status_code == 200
|
thread.start()
|
||||||
data = objects_response.get_json()
|
|
||||||
assert any(obj["key"] == "vid.mp4" for obj in data["objects"])
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("testbucket")
|
||||||
|
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
||||||
|
policy_store = app.extensions["bucket_policies"]
|
||||||
|
policy_store.set_policy("testbucket", DENY_LIST_ALLOW_GET_POLICY)
|
||||||
|
|
||||||
|
client = app.test_client()
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Access denied by bucket policy" not in response.data
|
||||||
|
objects_response = client.get("/ui/buckets/testbucket/objects")
|
||||||
|
assert objects_response.status_code == 403
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|||||||
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