#!/usr/bin/env python3 # /// script # dependencies = [ # "boto3>=1.34.0", # ] # /// """ Publish files and static HTML documents to public S3 hosting at hosting.haumdaucher.de. Organizes artifacts under /{category}/{YYYYMMDD}-{slug}.{ext}. """ import argparse import datetime import mimetypes import os import re import sys from pathlib import Path import boto3 from botocore.client import Config # Configuration defaults DEFAULT_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio.minio.svc.cluster.local:9000") DEFAULT_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "openclaw") DEFAULT_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "5uu23t69xC2Oigf8Bky0KTlI") DEFAULT_BUCKET = os.environ.get("MINIO_BUCKET", "hosting") DEFAULT_BASE_URL = os.environ.get("HOSTING_BASE_URL", "https://hosting.haumdaucher.de").rstrip("/") MIME_OVERRIDES = { ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8", ".md": "text/markdown; charset=utf-8", ".json": "application/json; charset=utf-8", ".pdf": "application/pdf", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".svg": "image/svg+xml", ".txt": "text/plain; charset=utf-8", } def get_s3_client(): if not DEFAULT_SECRET_KEY: raise ValueError("MINIO_SECRET_KEY environment variable is not configured.") return boto3.client( "s3", endpoint_url=DEFAULT_ENDPOINT, aws_access_key_id=DEFAULT_ACCESS_KEY, aws_secret_access_key=DEFAULT_SECRET_KEY, config=Config(signature_version="s3v4"), region_name="us-east-1", ) def slugify(text: str) -> str: """Converts raw string to URL-safe slug.""" text = text.strip().lower() text = re.sub(r"[^\w\s-]", "", text) text = re.sub(r"[\s_-]+", "-", text) return text.strip("-") or "document" def detect_mime(ext: str) -> str: ext_lower = ext.lower() if ext_lower in MIME_OVERRIDES: return MIME_OVERRIDES[ext_lower] guessed, _ = mimetypes.guess_type(f"file{ext_lower}") return guessed or "application/octet-stream" def upload_artifact(file_path: str = None, content: str = None, category: str = "research", slug: str = None, ext: str = None) -> dict: s3 = get_s3_client() now_str = datetime.datetime.now().strftime("%Y%m%d") # Determine file data and extension if file_path: p = Path(file_path) if not p.exists(): raise FileNotFoundError(f"File not found: {file_path}") with open(p, "rb") as f: data = f.read() chosen_ext = (ext or p.suffix).lstrip(".") chosen_slug = slug or slugify(p.stem) elif content is not None: data = content.encode("utf-8") chosen_ext = (ext or "html").lstrip(".") chosen_slug = slug or f"doc-{datetime.datetime.now().strftime('%H%M%S')}" else: raise ValueError("Either --file or --content must be specified.") chosen_slug = slugify(chosen_slug) category_slug = slugify(category) # Construct standard object key: {category}/{YYYYMMDD}-{slug}.{ext} filename = f"{now_str}-{chosen_slug}.{chosen_ext}" object_key = f"{category_slug}/{filename}" content_type = detect_mime(f".{chosen_ext}") s3.put_object( Bucket=DEFAULT_BUCKET, Key=object_key, Body=data, ContentType=content_type, ) public_url = f"{DEFAULT_BASE_URL}/{object_key}" return { "url": public_url, "key": object_key, "category": category_slug, "filename": filename, "content_type": content_type, "bytes": len(data), } def delete_artifact(object_key: str): s3 = get_s3_client() s3.delete_object(Bucket=DEFAULT_BUCKET, Key=object_key) print(f"Deleted: {object_key}") def main(): parser = argparse.ArgumentParser(description="Publish files to hosting.haumdaucher.de.") parser.add_argument("--file", "-f", help="Local file path to upload") parser.add_argument("--content", "-c", help="Inline text or HTML string to publish") parser.add_argument("--category", default="research", help="Category folder (e.g. research, recipes, images, docs)") parser.add_argument("--slug", "-s", help="Readable slug identifier for the document") parser.add_argument("--ext", help="File extension override (e.g. html, pdf, png)") parser.add_argument("--delete", help="Delete an object key from the hosting bucket") parser.add_argument("--url-only", action="store_true", help="Print only the public URL to stdout") args = parser.parse_args() if args.delete: delete_artifact(args.delete) return if not args.file and args.content is None: parser.error("You must provide either --file or --content.") result = upload_artifact( file_path=args.file, content=args.content, category=args.category, slug=args.slug, ext=args.ext, ) if args.url_only: print(result["url"]) else: print(f"URL: {result['url']}") print(f"KEY: {result['key']}") print(f"TYPE: {result['content_type']}") print(f"SIZE: {result['bytes']} bytes") if __name__ == "__main__": main()