This commit is contained in:
2025-12-02 07:33:21 -05:00
parent 68769f4bd7
commit d860c1fc30
6 changed files with 217 additions and 14 deletions

153
mirage/api.py Normal file
View File

@@ -0,0 +1,153 @@
# mirage/api.py
from __future__ import annotations
from pathlib import Path
from typing import Optional
from flask import Flask, jsonify, request, abort, Response
from .config import Config, load_config
from .models import Mirror
from . import storage, jobs
from .search import search_content
from .updater import log_path_for
def _mirror_to_dict(m: Mirror) -> dict:
# Reuse your models shape, but make sure slug is present.
d = m.to_dict()
if "slug" not in d:
d["slug"] = m.slug
return d
def create_api_app(config_path: Optional[Path] = None) -> Flask:
"""
Build a Flask app that exposes Mirage as an HTTP JSON API.
This should be run as its own service (e.g. `mirage api serve`)
and NOT share process space with the daemon.
"""
cfg: Config = load_config(config_path=config_path)
app = Flask(__name__)
# -------- Mirrors --------
@app.get("/api/v1/mirrors")
def list_mirrors():
mirrors = storage.list_mirrors(cfg=cfg)
return jsonify({"mirrors": [_mirror_to_dict(m) for m in mirrors]})
@app.get("/api/v1/mirrors/<slug>")
def get_mirror(slug: str):
m = storage.get_mirror(slug, cfg=cfg)
if not m:
abort(404)
return jsonify(_mirror_to_dict(m))
@app.post("/api/v1/mirrors")
def create_mirror():
data = request.get_json(silent=True) or {}
slug = (data.get("slug") or "").strip()
url = (data.get("url") or "").strip()
categories = data.get("categories") or []
if isinstance(categories, str):
categories = [c.strip()
for c in categories.split(",") if c.strip()]
ignore_robots = bool(data.get("ignore_robots", False))
enqueue = data.get("enqueue", True)
if not slug or not url:
return jsonify({"error": "slug and url are required"}), 400
if " " in slug:
return jsonify({"error": "slug may not contain spaces"}), 400
if storage.get_mirror(slug, cfg=cfg):
return jsonify({"error": f"mirror {slug!r} already exists"}), 409
m = Mirror(
slug=slug,
url=url,
categories=categories,
ignore_robots=ignore_robots,
)
storage.upsert_mirror(m, cfg=cfg)
if enqueue:
jobs.enqueue_update(slug, cfg=cfg)
return jsonify(_mirror_to_dict(m)), 201
@app.post("/api/v1/mirrors/<slug>/update")
def enqueue_single(slug: str):
m = storage.get_mirror(slug, cfg=cfg)
if not m:
abort(404)
jobs.enqueue_update(slug, cfg=cfg)
return jsonify({"queued": True}), 202
@app.post("/api/v1/mirrors/update-all")
def enqueue_all():
mirrors = storage.list_mirrors(cfg=cfg)
count = 0
for m in mirrors:
if m.status in ("queued", "updating"):
continue
jobs.enqueue_update(m.slug, cfg=cfg)
count += 1
return jsonify({"queued": count}), 202
# -------- Logs --------
@app.get("/api/v1/mirrors/<slug>/log")
def tail_log(slug: str):
"""
Return the last N bytes of the wget log for a mirror as plain text.
"""
log_path = log_path_for(slug)
if not log_path.exists():
# 404 vs 200 "" is your choice; for UI it's easier to treat missing as empty.
return Response("", mimetype="text/plain; charset=utf-8")
try:
max_bytes = int(request.args.get("bytes", "65536"))
except ValueError:
max_bytes = 65536
try:
with log_path.open("rb") as f:
f.seek(0, 2)
size = f.tell()
if size > max_bytes:
f.seek(-max_bytes, 2)
else:
f.seek(0)
data = f.read()
except OSError:
return Response("", mimetype="text/plain; charset=utf-8")
return Response(data, mimetype="text/plain; charset=utf-8")
# -------- Content search --------
@app.get("/api/v1/search")
def api_search():
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"results": []})
results = search_content(q, limit=50)
return jsonify({"results": results})
# -------- Config introspection (nice for admin / UI) --------
@app.get("/api/v1/config")
def api_config():
return jsonify({
"mirror_root": str(cfg.mirror_root),
"data_dir": str(cfg.data_dir),
"log_dir": str(cfg.log_dir),
"max_concurrent_updates": cfg.max_concurrent_updates,
"source": cfg.describe_source(),
})
return app

View File

@@ -6,19 +6,19 @@ from typing import Optional
import typer
from .config import load_config
from .commands import mirrors_app, admin_app
from .commands import mirrors_app, admin_app, api_app
app = typer.Typer(
help="Mirage offline website mirror manager.",
rich_markup_mode="rich",
)
# Attach sub-command groups
app.add_typer(mirrors_app, name="mirrors",
help="Manage mirrors (add, list, update, daemon, etc.).")
app.add_typer(admin_app, name="admin",
help="Admin / debug utilities (config, paths, etc.).")
app.add_typer(api_app, name="api",
help="Run Mirage HTTP API.")
@app.callback()
@@ -30,17 +30,11 @@ def main(
"-c",
dir_okay=False,
file_okay=True,
exists=False, # we handle missing ourselves
exists=False,
readable=True,
help="Explicit path to mirage config file (overrides env and defaults).",
),
):
"""
Mirage command-line interface.
You can override which config file is used with [bold]--config[/bold]
or via the [bold]MIRAGE_CONFIG[/bold] environment variable.
"""
cfg = load_config(config_path=config)
# Stash config in Typer's context so all subcommands can get at it.
ctx.obj = {"config": cfg}

View File

@@ -1,4 +1,5 @@
from .mirrors import mirrors_app
from .admin import admin_app
from .api import api_app
__all__ = ["mirrors_app", "admin_app"]
__all__ = ["mirrors_app", "admin_app", "api_app"]

37
mirage/commands/api.py Normal file
View File

@@ -0,0 +1,37 @@
# mirage/commands/api.py
from __future__ import annotations
from pathlib import Path
from typing import Optional
import typer
from ..config import Config
from ..api import create_api_app
api_app = typer.Typer(help="Run the Mirage HTTP API server.")
@api_app.command("serve")
def serve_api(
ctx: typer.Context,
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Bind host"),
port: int = typer.Option(5151, "--port", "-p", help="Bind port"),
debug: bool = typer.Option(
False, "--debug", help="Enable Flask debug server (dev only)."),
):
"""
Start the Mirage API server.
In production you probably want this behind a real HTTP server, but
the built-in one is fine for a local box.
"""
cfg: Config = ctx.obj["config"] # type: ignore[assignment]
# Use the same config source path the CLI loaded (if any).
config_path: Optional[Path] = cfg.source_path
app = create_api_app(config_path=config_path)
# For now: simple run(). If you want gunicorn/waitress later we can wrap that.
app.run(host=host, port=port, debug=debug)

View File

@@ -162,14 +162,16 @@ fi
if command -v systemctl >/dev/null 2>&1; then
echo "==> Installing systemd units"
install -D -m 644 systemd/mirage.service /etc/systemd/system/mirage.service
install -D -m 644 systemd/mirage-api.service /etc/systemd/system/mirage-api.service
install -D -m 644 systemd/mirage-update.service /etc/systemd/system/mirage-update.service
install -D -m 644 systemd/mirage-update.timer /etc/systemd/system/mirage-update.timer
echo "==> Reloading systemd"
systemctl daemon-reload
echo "==> Enabling and starting mirage daemon + timer"
echo "==> Enabling and starting mirage daemon + api + timer"
systemctl enable --now mirage.service
systemctl enable --now mirage-api.service
systemctl enable --now mirage-update.timer
else
echo "==> systemctl not found; skipping systemd unit installation."
@@ -227,4 +229,3 @@ else
echo " sudo usermod -aG $MIRAGE_GROUP <username>"
echo
fi

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Mirage mirror daemon
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mirage
Group=mirage
WorkingDirectory=/var/lib/mirage
UMask=0007
ExecStart=/opt/mirage/venv/bin/mirage api serve --host 127.0.0.1 --port 5151
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target