MyFSIO v0.3.0 Release #22

Merged
kqjy merged 15 commits from next into main 2026-02-22 10:22:36 +00:00
4 changed files with 172 additions and 47 deletions
Showing only changes of commit f3f52f14a5 - Show all commits

View File

@@ -17,7 +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 from .website_domains import WebsiteDomainStore, normalize_domain, is_valid_domain
def _is_safe_url(url: str, allow_internal: bool = False) -> bool: def _is_safe_url(url: str, allow_internal: bool = False) -> bool:
@@ -704,10 +704,12 @@ def create_website_domain():
if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False):
return _json_error("InvalidRequest", "Website hosting is not enabled", 400) return _json_error("InvalidRequest", "Website hosting is not enabled", 400)
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
domain = (payload.get("domain") or "").strip().lower() domain = normalize_domain(payload.get("domain") or "")
bucket = (payload.get("bucket") or "").strip() bucket = (payload.get("bucket") or "").strip()
if not domain: if not domain:
return _json_error("ValidationError", "domain is required", 400) return _json_error("ValidationError", "domain is required", 400)
if not is_valid_domain(domain):
return _json_error("ValidationError", f"Invalid domain: '{domain}'", 400)
if not bucket: if not bucket:
return _json_error("ValidationError", "bucket is required", 400) return _json_error("ValidationError", "bucket is required", 400)
storage = _storage() storage = _storage()
@@ -730,10 +732,11 @@ def get_website_domain(domain: str):
return error return error
if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False):
return _json_error("InvalidRequest", "Website hosting is not enabled", 400) return _json_error("InvalidRequest", "Website hosting is not enabled", 400)
domain = normalize_domain(domain)
bucket = _website_domains().get_bucket(domain) bucket = _website_domains().get_bucket(domain)
if not bucket: if not bucket:
return _json_error("NotFound", f"No mapping found for domain '{domain}'", 404) return _json_error("NotFound", f"No mapping found for domain '{domain}'", 404)
return jsonify({"domain": domain.lower(), "bucket": bucket}) return jsonify({"domain": domain, "bucket": bucket})
@admin_api_bp.route("/website-domains/<domain>", methods=["PUT"]) @admin_api_bp.route("/website-domains/<domain>", methods=["PUT"])
@@ -744,6 +747,7 @@ def update_website_domain(domain: str):
return error return error
if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False):
return _json_error("InvalidRequest", "Website hosting is not enabled", 400) return _json_error("InvalidRequest", "Website hosting is not enabled", 400)
domain = normalize_domain(domain)
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
bucket = (payload.get("bucket") or "").strip() bucket = (payload.get("bucket") or "").strip()
if not bucket: if not bucket:
@@ -752,9 +756,11 @@ def update_website_domain(domain: str):
if not storage.bucket_exists(bucket): if not storage.bucket_exists(bucket):
return _json_error("NoSuchBucket", f"Bucket '{bucket}' does not exist", 404) return _json_error("NoSuchBucket", f"Bucket '{bucket}' does not exist", 404)
store = _website_domains() store = _website_domains()
if not store.get_bucket(domain):
return _json_error("NotFound", f"No mapping found for domain '{domain}'", 404)
store.set_mapping(domain, bucket) store.set_mapping(domain, bucket)
logger.info("Website domain mapping updated: %s -> %s", domain, bucket) logger.info("Website domain mapping updated: %s -> %s", domain, bucket)
return jsonify({"domain": domain.lower(), "bucket": bucket}) return jsonify({"domain": domain, "bucket": bucket})
@admin_api_bp.route("/website-domains/<domain>", methods=["DELETE"]) @admin_api_bp.route("/website-domains/<domain>", methods=["DELETE"])
@@ -765,6 +771,7 @@ def delete_website_domain(domain: str):
return error return error
if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False): if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False):
return _json_error("InvalidRequest", "Website hosting is not enabled", 400) return _json_error("InvalidRequest", "Website hosting is not enabled", 400)
domain = normalize_domain(domain)
if not _website_domains().delete_mapping(domain): if not _website_domains().delete_mapping(domain):
return _json_error("NotFound", f"No mapping found for domain '{domain}'", 404) return _json_error("NotFound", f"No mapping found for domain '{domain}'", 404)
logger.info("Website domain mapping deleted: %s", domain) logger.info("Website domain mapping deleted: %s", domain)

View File

@@ -51,6 +51,7 @@ from .s3_client import (
from .secret_store import EphemeralSecretStore from .secret_store import EphemeralSecretStore
from .site_registry import SiteRegistry, SiteInfo, PeerSite from .site_registry import SiteRegistry, SiteInfo, PeerSite
from .storage import ObjectStorage, StorageError from .storage import ObjectStorage, StorageError
from .website_domains import normalize_domain, is_valid_domain
ui_bp = Blueprint("ui", __name__, template_folder="../templates", url_prefix="/ui") ui_bp = Blueprint("ui", __name__, template_folder="../templates", url_prefix="/ui")
@@ -2400,7 +2401,7 @@ def create_website_domain():
flash("Website hosting is not enabled", "warning") flash("Website hosting is not enabled", "warning")
return redirect(url_for("ui.buckets_overview")) return redirect(url_for("ui.buckets_overview"))
domain = (request.form.get("domain") or "").strip().lower() domain = normalize_domain(request.form.get("domain") or "")
bucket = (request.form.get("bucket") or "").strip() bucket = (request.form.get("bucket") or "").strip()
if not domain: if not domain:
@@ -2409,6 +2410,12 @@ def create_website_domain():
flash("Domain is required", "danger") flash("Domain is required", "danger")
return redirect(url_for("ui.website_domains_dashboard")) return redirect(url_for("ui.website_domains_dashboard"))
if not is_valid_domain(domain):
if _wants_json():
return jsonify({"error": f"Invalid domain format: '{domain}'"}), 400
flash(f"Invalid domain format: '{domain}'. Use a hostname like www.example.com", "danger")
return redirect(url_for("ui.website_domains_dashboard"))
if not bucket: if not bucket:
if _wants_json(): if _wants_json():
return jsonify({"error": "Bucket is required"}), 400 return jsonify({"error": "Bucket is required"}), 400
@@ -2447,6 +2454,7 @@ def update_website_domain(domain: str):
flash("Access denied", "danger") flash("Access denied", "danger")
return redirect(url_for("ui.website_domains_dashboard")) return redirect(url_for("ui.website_domains_dashboard"))
domain = normalize_domain(domain)
bucket = (request.form.get("bucket") or "").strip() bucket = (request.form.get("bucket") or "").strip()
if not bucket: if not bucket:
if _wants_json(): if _wants_json():
@@ -2462,9 +2470,14 @@ def update_website_domain(domain: str):
return redirect(url_for("ui.website_domains_dashboard")) return redirect(url_for("ui.website_domains_dashboard"))
store = current_app.extensions.get("website_domains") store = current_app.extensions.get("website_domains")
if not store.get_bucket(domain):
if _wants_json():
return jsonify({"error": f"No mapping for domain '{domain}'"}), 404
flash(f"No mapping for domain '{domain}'", "danger")
return redirect(url_for("ui.website_domains_dashboard"))
store.set_mapping(domain, bucket) store.set_mapping(domain, bucket)
if _wants_json(): if _wants_json():
return jsonify({"success": True, "domain": domain.lower(), "bucket": bucket}) return jsonify({"success": True, "domain": domain, "bucket": bucket})
flash(f"Domain '{domain}' updated to bucket '{bucket}'", "success") flash(f"Domain '{domain}' updated to bucket '{bucket}'", "success")
return redirect(url_for("ui.website_domains_dashboard")) return redirect(url_for("ui.website_domains_dashboard"))
@@ -2480,6 +2493,7 @@ def delete_website_domain(domain: str):
flash("Access denied", "danger") flash("Access denied", "danger")
return redirect(url_for("ui.website_domains_dashboard")) return redirect(url_for("ui.website_domains_dashboard"))
domain = normalize_domain(domain)
store = current_app.extensions.get("website_domains") store = current_app.extensions.get("website_domains")
if not store.delete_mapping(domain): if not store.delete_mapping(domain):
if _wants_json(): if _wants_json():

View File

@@ -1,10 +1,34 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
_DOMAIN_RE = re.compile(
r"^(?!-)[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$"
)
def normalize_domain(raw: str) -> str:
raw = raw.strip().lower()
for prefix in ("https://", "http://"):
if raw.startswith(prefix):
raw = raw[len(prefix):]
raw = raw.split("/", 1)[0]
raw = raw.split("?", 1)[0]
raw = raw.split("#", 1)[0]
if ":" in raw:
raw = raw.rsplit(":", 1)[0]
return raw
def is_valid_domain(domain: str) -> bool:
if not domain or len(domain) > 253:
return False
return bool(_DOMAIN_RE.match(domain))
class WebsiteDomainStore: class WebsiteDomainStore:
def __init__(self, config_path: Path) -> None: def __init__(self, config_path: Path) -> None:

View File

@@ -38,8 +38,16 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3"> <div class="mb-3">
<label for="domain" class="form-label fw-medium">Domain</label> <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"> <input type="text" class="form-control" id="domain" name="domain" required
<div class="form-text">The hostname that will serve website content.</div> placeholder="www.example.com"
pattern="^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$"
title="Enter a valid hostname (e.g. www.example.com). Do not include http:// or trailing slashes.">
<div class="form-text">Hostname only &mdash; no <code>http://</code> prefix or trailing slash.</div>
<div class="invalid-feedback">Enter a valid hostname like www.example.com</div>
</div>
<div id="domainPreview" class="alert alert-light border small py-2 px-3 mb-3 d-none">
<span class="text-muted">Will be accessible at:</span>
<code id="domainPreviewUrl" class="ms-1"></code>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="bucket" class="form-label fw-medium">Bucket</label> <label for="bucket" class="form-label fw-medium">Bucket</label>
@@ -56,7 +64,7 @@
<div class="form-text">The bucket must have website hosting enabled.</div> <div class="form-text">The bucket must have website hosting enabled.</div>
</div> </div>
<div class="d-grid"> <div class="d-grid">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary" id="addMappingBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16"> <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"/> <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> </svg>
@@ -86,22 +94,32 @@
<div class="col-lg-8 col-md-7"> <div class="col-lg-8 col-md-7">
<div class="card shadow-sm border-0" style="border-radius: 1rem;"> <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 class="card-header bg-transparent border-0 pt-4 pb-0 px-4">
<div> <div class="d-flex justify-content-between align-items-center mb-1">
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-1"> <h5 class="fw-semibold d-flex align-items-center gap-2 mb-0">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16"> <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="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"/> <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> </svg>
Active Mappings Active Mappings
</h5> </h5>
<p class="text-muted small mb-0">Domains currently serving website content</p>
</div> </div>
<p class="text-muted small mb-0">Domains currently serving website content</p>
{% if mappings|length > 3 %}
<div class="mt-3">
<div class="search-input-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="search-icon" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
<input type="text" class="form-control" id="domainSearch" placeholder="Filter by domain or bucket..." autocomplete="off" />
</div>
</div>
{% endif %}
</div> </div>
<div class="card-body px-4 pb-4"> <div class="card-body px-4 pb-4">
{% if mappings %} {% if mappings %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0" id="domainTable">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th scope="col">Domain</th> <th scope="col">Domain</th>
@@ -111,13 +129,16 @@
</thead> </thead>
<tbody> <tbody>
{% for m in mappings %} {% for m in mappings %}
<tr> <tr data-domain="{{ m.domain }}" data-bucket="{{ m.bucket }}">
<td> <td>
<div class="d-flex align-items-center gap-2"> <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"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success flex-shrink-0" 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"/> <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> </svg>
<code class="fw-medium">{{ m.domain }}</code> <div>
<code class="fw-medium">{{ m.domain }}</code>
<div class="text-muted small">http://{{ m.domain }}</div>
</div>
</div> </div>
</td> </td>
<td><span class="badge bg-primary bg-opacity-10 text-primary">{{ m.bucket }}</span></td> <td><span class="badge bg-primary bg-opacity-10 text-primary">{{ m.bucket }}</span></td>
@@ -151,6 +172,9 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="noSearchResults" class="text-center py-4 d-none">
<p class="text-muted mb-0">No mappings match your search.</p>
</div>
{% else %} {% else %}
<div class="empty-state text-center py-5"> <div class="empty-state text-center py-5">
<div class="empty-state-icon mx-auto mb-3"> <div class="empty-state-icon mx-auto mb-3">
@@ -184,7 +208,7 @@
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-medium">Domain</label> <label class="form-label fw-medium">Domain</label>
<input type="text" class="form-control" id="editDomainName" disabled> <input type="text" class="form-control bg-light" id="editDomainName" disabled>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="editBucket" class="form-label fw-medium">Bucket</label> <label for="editBucket" class="form-label fw-medium">Bucket</label>
@@ -216,29 +240,30 @@
<div class="modal fade" id="deleteDomainModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="deleteDomainModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header border-0 pb-0"> <form method="POST" id="deleteDomainForm">
<h5 class="modal-title fw-semibold"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16"> <div class="modal-header border-0 pb-0">
<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"/> <h5 class="modal-title fw-semibold">
<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 xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
</svg> <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"/>
Delete Domain Mapping <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"/>
</h5> </svg>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> Delete Domain Mapping
</div> </h5>
<div class="modal-body"> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<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> <div class="modal-body">
<div class="modal-footer"> <p>Are you sure you want to delete the mapping for <strong><code id="deleteDomainName"></code></strong>?</p>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <p class="text-muted small mb-0">Mapped to bucket: <code id="deleteBucketName"></code></p>
<form method="POST" id="deleteDomainForm"> <div class="alert alert-warning d-flex align-items-start small mt-3 mb-0" role="alert">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <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.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 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>
<button type="submit" class="btn btn-danger"> <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"> <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 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"/>
@@ -246,8 +271,8 @@
</svg> </svg>
Delete Delete
</button> </button>
</form> </div>
</div> </form>
</div> </div>
</div> </div>
</div> </div>
@@ -256,6 +281,43 @@
{% block extra_scripts %} {% block extra_scripts %}
<script> <script>
(function () { (function () {
function normalizeDomain(val) {
val = val.trim().toLowerCase();
if (val.indexOf('https://') === 0) val = val.substring(8);
else if (val.indexOf('http://') === 0) val = val.substring(7);
var slashIdx = val.indexOf('/');
if (slashIdx !== -1) val = val.substring(0, slashIdx);
var qIdx = val.indexOf('?');
if (qIdx !== -1) val = val.substring(0, qIdx);
var hIdx = val.indexOf('#');
if (hIdx !== -1) val = val.substring(0, hIdx);
var colonIdx = val.indexOf(':');
if (colonIdx !== -1) val = val.substring(0, colonIdx);
return val;
}
var domainInput = document.getElementById('domain');
var preview = document.getElementById('domainPreview');
var previewUrl = document.getElementById('domainPreviewUrl');
if (domainInput && preview) {
domainInput.addEventListener('input', function () {
var clean = normalizeDomain(this.value);
if (clean && /^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)*$/.test(clean)) {
previewUrl.textContent = 'http://' + clean;
preview.classList.remove('d-none');
} else {
preview.classList.add('d-none');
}
});
var createForm = document.getElementById('createDomainForm');
if (createForm) {
createForm.addEventListener('submit', function () {
domainInput.value = normalizeDomain(domainInput.value);
});
}
}
var editModal = document.getElementById('editDomainModal'); var editModal = document.getElementById('editDomainModal');
if (editModal) { if (editModal) {
editModal.addEventListener('show.bs.modal', function (event) { editModal.addEventListener('show.bs.modal', function (event) {
@@ -264,11 +326,7 @@
var bucket = btn.getAttribute('data-bucket'); var bucket = btn.getAttribute('data-bucket');
document.getElementById('editDomainName').value = domain; document.getElementById('editDomainName').value = domain;
var editBucket = document.getElementById('editBucket'); var editBucket = document.getElementById('editBucket');
if (editBucket.tagName === 'SELECT') { editBucket.value = bucket;
editBucket.value = bucket;
} else {
editBucket.value = bucket;
}
document.getElementById('editDomainForm').action = '{{ url_for("ui.update_website_domain", domain="__DOMAIN__") }}'.replace('__DOMAIN__', encodeURIComponent(domain)); document.getElementById('editDomainForm').action = '{{ url_for("ui.update_website_domain", domain="__DOMAIN__") }}'.replace('__DOMAIN__', encodeURIComponent(domain));
}); });
} }
@@ -278,10 +336,32 @@
deleteModal.addEventListener('show.bs.modal', function (event) { deleteModal.addEventListener('show.bs.modal', function (event) {
var btn = event.relatedTarget; var btn = event.relatedTarget;
var domain = btn.getAttribute('data-domain'); var domain = btn.getAttribute('data-domain');
var bucket = btn.getAttribute('data-bucket') || '';
document.getElementById('deleteDomainName').textContent = domain; document.getElementById('deleteDomainName').textContent = domain;
document.getElementById('deleteBucketName').textContent = bucket;
document.getElementById('deleteDomainForm').action = '{{ url_for("ui.delete_website_domain", domain="__DOMAIN__") }}'.replace('__DOMAIN__', encodeURIComponent(domain)); document.getElementById('deleteDomainForm').action = '{{ url_for("ui.delete_website_domain", domain="__DOMAIN__") }}'.replace('__DOMAIN__', encodeURIComponent(domain));
}); });
} }
var searchInput = document.getElementById('domainSearch');
if (searchInput) {
searchInput.addEventListener('input', function () {
var q = this.value.toLowerCase();
var rows = document.querySelectorAll('#domainTable tbody tr');
var visible = 0;
rows.forEach(function (row) {
var domain = (row.getAttribute('data-domain') || '').toLowerCase();
var bucket = (row.getAttribute('data-bucket') || '').toLowerCase();
var match = !q || domain.indexOf(q) !== -1 || bucket.indexOf(q) !== -1;
row.style.display = match ? '' : 'none';
if (match) visible++;
});
var noResults = document.getElementById('noSearchResults');
if (noResults) {
noResults.classList.toggle('d-none', visible > 0);
}
});
}
})(); })();
</script> </script>
{% endblock %} {% endblock %}