New API
This commit is contained in:
153
mirage/api.py
Normal file
153
mirage/api.py
Normal 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 model’s 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
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
37
mirage/commands/api.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
17
systemd/mirage-api.service
Normal file
17
systemd/mirage-api.service
Normal 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
|
||||
Reference in New Issue
Block a user