mirror of https://github.com/Nezreka/SoulSync.git
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.
190 lines
6.2 KiB
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
|