You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/api/settings.py

190 lines
6.2 KiB

"""
Settings and API key management endpoints.
"""
from flask import request, current_app
from .auth import require_api_key, generate_api_key, _hash_key
from .helpers import api_success, api_error
# Keys that must NEVER be exposed via the API
_SENSITIVE_KEYS = {
"spotify.client_id",
"spotify.client_secret",
"tidal.client_id",
"tidal.client_secret",
"tidal_tokens",
"tidal_download.session",
"qobuz.session",
"plex.token",
"jellyfin.api_key",
"navidrome.password",
"soulseek.api_key",
"listenbrainz.token",
"acoustid.api_key",
"lastfm.api_key",
"genius.access_token",
"hydrabase.api_key",
}
def register_routes(bp):
# ---- Settings ----
@bp.route("/settings", methods=["GET"])
@require_api_key
def get_settings():
"""Get current settings (sensitive values redacted)."""
try:
cfg = current_app.soulsync["config_manager"]
raw = dict(cfg.config_data) if hasattr(cfg, "config_data") else {}
sanitized = _redact_sensitive(raw)
return api_success({"settings": sanitized})
except Exception as e:
return api_error("SETTINGS_ERROR", str(e), 500)
@bp.route("/settings", methods=["PATCH"])
@require_api_key
def update_settings():
"""Update settings (partial).
Body: {"key": "value", ...} — dot-notation keys accepted.
"""
body = request.get_json(silent=True) or {}
if not body:
return api_error("BAD_REQUEST", "Empty body.", 400)
try:
cfg = current_app.soulsync["config_manager"]
updated = []
for key, value in body.items():
# Block writing API keys through settings endpoint
if key == "api_keys":
continue
cfg.set(key, value)
updated.append(key)
return api_success({"message": "Settings updated.", "updated_keys": updated})
except Exception as e:
return api_error("SETTINGS_ERROR", str(e), 500)
# ---- API Key Management ----
@bp.route("/api-keys", methods=["GET"])
@require_api_key
def list_api_keys():
"""List all API keys (prefix + label only, never the full key)."""
try:
cfg = current_app.soulsync["config_manager"]
keys = cfg.get("api_keys", [])
return api_success({
"keys": [
{
"id": k.get("id"),
"label": k.get("label", ""),
"key_prefix": k.get("key_prefix", ""),
"created_at": k.get("created_at"),
"last_used_at": k.get("last_used_at"),
}
for k in keys
]
})
except Exception as e:
return api_error("SETTINGS_ERROR", str(e), 500)
@bp.route("/api-keys", methods=["POST"])
@require_api_key
def create_api_key():
"""Generate a new API key.
Body: {"label": "My Bot"}
The raw key is returned ONCE in the response.
"""
body = request.get_json(silent=True) or {}
label = body.get("label", "")
try:
cfg = current_app.soulsync["config_manager"]
raw_key, record = generate_api_key(label)
keys = cfg.get("api_keys", [])
keys.append(record)
cfg.set("api_keys", keys)
return api_success({
"key": raw_key,
"id": record["id"],
"label": record["label"],
"key_prefix": record["key_prefix"],
"created_at": record["created_at"],
}, status=201)
except Exception as e:
return api_error("SETTINGS_ERROR", str(e), 500)
@bp.route("/api-keys/<key_id>", methods=["DELETE"])
@require_api_key
def revoke_api_key(key_id):
"""Revoke (delete) an API key by its ID."""
try:
cfg = current_app.soulsync["config_manager"]
keys = cfg.get("api_keys", [])
original_len = len(keys)
keys = [k for k in keys if k.get("id") != key_id]
if len(keys) == original_len:
return api_error("NOT_FOUND", "API key not found.", 404)
cfg.set("api_keys", keys)
return api_success({"message": "API key revoked."})
except Exception as e:
return api_error("SETTINGS_ERROR", str(e), 500)
# ---- Bootstrap endpoint (no auth required) ----
@bp.route("/api-keys/bootstrap", methods=["POST"])
def bootstrap_api_key():
"""Generate the first API key when none exist (no auth required).
This endpoint only works when zero API keys are configured.
Body: {"label": "My First Key"}
"""
try:
cfg = current_app.soulsync["config_manager"]
existing = cfg.get("api_keys", [])
if existing:
return api_error("FORBIDDEN",
"API keys already exist. Use an authenticated request to create more.", 403)
body = request.get_json(silent=True) or {}
label = body.get("label", "Default")
raw_key, record = generate_api_key(label)
cfg.set("api_keys", [record])
return api_success({
"key": raw_key,
"id": record["id"],
"label": record["label"],
"key_prefix": record["key_prefix"],
"created_at": record["created_at"],
}, status=201)
except Exception as e:
return api_error("SETTINGS_ERROR", str(e), 500)
def _redact_sensitive(config, prefix=""):
"""Recursively redact sensitive values from a config dict."""
if not isinstance(config, dict):
return config
result = {}
for key, value in config.items():
full_key = f"{prefix}.{key}" if prefix else key
if any(full_key.startswith(s) for s in _SENSITIVE_KEYS):
result[key] = "***REDACTED***"
elif isinstance(value, dict):
result[key] = _redact_sensitive(value, full_key)
else:
result[key] = value
return result