mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-04 04:23:02 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
import datetime as dt
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from flask import Response, jsonify, request
|
||||
from flask_compress import Compress
|
||||
from flask_cors import CORS
|
||||
from flask_jwt_extended import (
|
||||
JWTManager,
|
||||
create_access_token,
|
||||
get_jwt,
|
||||
get_jwt_identity,
|
||||
set_access_cookies,
|
||||
verify_jwt_in_request,
|
||||
)
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.errors import RateLimitExceeded
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_openapi3 import Info, OpenAPI
|
||||
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.db.userdata import UserTable
|
||||
from swingmusic.services.setup_state import get_setup_status, is_setup_complete
|
||||
from swingmusic.settings import Metadata, Paths
|
||||
from swingmusic.utils.paths import get_client_files_extensions
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
# # # # # # # # # # # # # # # # # #
|
||||
# Grouped configuration function #
|
||||
# # # # # # # # # # # # # # # # # #
|
||||
|
||||
|
||||
def config_app(web):
|
||||
|
||||
# CORS - configurable via environment variable
|
||||
cors_origins = os.getenv("SWINGMUSIC_CORS_ORIGINS", "*")
|
||||
if cors_origins != "*":
|
||||
# Parse comma-separated list of origins
|
||||
cors_origins = [
|
||||
origin.strip() for origin in cors_origins.split(",") if origin.strip()
|
||||
]
|
||||
CORS(web, origins=cors_origins, supports_credentials=True)
|
||||
|
||||
# RESPONSE COMPRESSION
|
||||
# Only compress JSON responses
|
||||
Compress(web)
|
||||
web.config["COMPRESS_MIMETYPES"] = [
|
||||
"application/json",
|
||||
]
|
||||
|
||||
|
||||
def config_jwt(web):
|
||||
# JWT CONFIGS
|
||||
web.config["JWT_VERIFY_SUB"] = False
|
||||
web.config["JWT_SECRET_KEY"] = UserConfig().serverId
|
||||
web.config["JWT_TOKEN_LOCATION"] = ["cookies", "headers"]
|
||||
# Enable CSRF protection for cookie-based auth
|
||||
web.config["JWT_COOKIE_CSRF_PROTECT"] = True
|
||||
web.config["JWT_CSRF_IN_COOKIES"] = True
|
||||
web.config["JWT_CSRF_HEADER_NAME"] = "X-CSRF-TOKEN"
|
||||
web.config["JWT_SESSION_COOKIE"] = False
|
||||
|
||||
jwt_expiry = int(dt.timedelta(days=30).total_seconds())
|
||||
web.config["JWT_ACCESS_TOKEN_EXPIRES"] = jwt_expiry
|
||||
|
||||
jwt = JWTManager(web)
|
||||
|
||||
@jwt.user_lookup_loader
|
||||
def user_lookup_callback(_jwt_header, jwt_data):
|
||||
identity = jwt_data["sub"]
|
||||
userid = identity["id"]
|
||||
user = UserTable.get_by_id(userid)
|
||||
|
||||
if user:
|
||||
return user.todict()
|
||||
|
||||
|
||||
# Rate limiter instance - configured in build()
|
||||
limiter: Limiter | None = None
|
||||
|
||||
|
||||
def get_limiter() -> Limiter:
|
||||
"""Get the rate limiter instance."""
|
||||
global limiter
|
||||
if limiter is None:
|
||||
raise RuntimeError("Limiter not initialized. Call build() first.")
|
||||
return limiter
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiRegistration:
|
||||
module_path: str
|
||||
symbol: str
|
||||
register_as: Literal["api", "blueprint", "callable"]
|
||||
required: bool = True
|
||||
feature_flag: str | None = None
|
||||
enabled_by_default: bool = True
|
||||
|
||||
|
||||
_BOOT_REGISTRATION_STATE: dict[str, list[str]] = {
|
||||
"registered": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
|
||||
def _feature_enabled(flag: str | None, default: bool = True) -> bool:
|
||||
if flag is None:
|
||||
return True
|
||||
|
||||
value = os.getenv(flag)
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
CORE_API_REGISTRATIONS: list[ApiRegistration] = [
|
||||
ApiRegistration("swingmusic.api.auth", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.setup", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.album", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.artist", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.stream", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.search", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.folder", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.playlist", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.favorites", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.imgserver", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.settings", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.colors", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.lyrics", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.backup_and_restore", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.collections", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.scrobble", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.home", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.getall", "api", "api", required=True),
|
||||
ApiRegistration("swingmusic.api.spotify", "spotify_bp", "api", required=False),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.spotify_settings", "spotify_settings_bp", "api", required=False
|
||||
),
|
||||
ApiRegistration("swingmusic.api.upload", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.downloads", "api", "api", required=True),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.music_catalog", "music_catalog_bp", "blueprint", required=True
|
||||
),
|
||||
ApiRegistration("swingmusic.api.plugins", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.plugins.lyrics", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.plugins.mixes", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.dragonfly", "api", "api", required=False),
|
||||
ApiRegistration("swingmusic.api.recently_played", "api", "api", required=False),
|
||||
]
|
||||
|
||||
|
||||
OPTIONAL_API_REGISTRATIONS: list[ApiRegistration] = [
|
||||
ApiRegistration(
|
||||
"swingmusic.api.enhanced_search",
|
||||
"register_enhanced_search_api",
|
||||
"callable",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_ENHANCED_SEARCH",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.universal_downloader",
|
||||
"register_universal_downloader_api",
|
||||
"callable",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_UNIVERSAL_DOWNLOADER",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.update_tracking",
|
||||
"update_tracking_bp",
|
||||
"blueprint",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_UPDATE_TRACKING",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.audio_quality",
|
||||
"audio_quality_bp",
|
||||
"blueprint",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_AUDIO_QUALITY",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.advanced_ux",
|
||||
"advanced_ux_bp",
|
||||
"blueprint",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_ADVANCED_UX",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.recap",
|
||||
"recap_bp",
|
||||
"blueprint",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_RECAP",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
ApiRegistration(
|
||||
"swingmusic.api.mobile_offline",
|
||||
"mobile_offline_bp",
|
||||
"blueprint",
|
||||
required=False,
|
||||
feature_flag="SWINGMUSIC_ENABLE_MOBILE_OFFLINE",
|
||||
enabled_by_default=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _register_entry(web: OpenAPI, entry: ApiRegistration):
|
||||
if not _feature_enabled(entry.feature_flag, entry.enabled_by_default):
|
||||
log.info("Skipping feature-gated API module: %s", entry.module_path)
|
||||
return
|
||||
|
||||
try:
|
||||
module = importlib.import_module(entry.module_path)
|
||||
symbol = getattr(module, entry.symbol)
|
||||
|
||||
if entry.register_as == "api":
|
||||
web.register_api(symbol)
|
||||
elif entry.register_as == "blueprint":
|
||||
web.register_blueprint(symbol)
|
||||
elif entry.register_as == "callable":
|
||||
symbol(web)
|
||||
else:
|
||||
raise RuntimeError(f"Unknown register type: {entry.register_as}")
|
||||
|
||||
_BOOT_REGISTRATION_STATE["registered"].append(
|
||||
f"{entry.module_path}:{entry.symbol}"
|
||||
)
|
||||
except Exception as error:
|
||||
detail = f"{entry.module_path}:{entry.symbol} ({error})"
|
||||
_BOOT_REGISTRATION_STATE["failed"].append(detail)
|
||||
log.exception(
|
||||
"Failed to register API module %s.%s", entry.module_path, entry.symbol
|
||||
)
|
||||
|
||||
strict_boot = _feature_enabled("SWINGMUSIC_STRICT_BOOT", default=False)
|
||||
if entry.required and strict_boot:
|
||||
raise
|
||||
|
||||
|
||||
def load_endpoints(web: OpenAPI):
|
||||
_BOOT_REGISTRATION_STATE["registered"].clear()
|
||||
_BOOT_REGISTRATION_STATE["failed"].clear()
|
||||
|
||||
with web.app_context():
|
||||
for entry in CORE_API_REGISTRATIONS:
|
||||
_register_entry(web, entry)
|
||||
|
||||
for entry in OPTIONAL_API_REGISTRATIONS:
|
||||
_register_entry(web, entry)
|
||||
|
||||
# Keep client contracts stable even when optional modules are disabled.
|
||||
from swingmusic.api.optional_feature_fallbacks import (
|
||||
register_optional_feature_fallbacks,
|
||||
)
|
||||
|
||||
register_optional_feature_fallbacks(web)
|
||||
|
||||
|
||||
def run_boot_smoke_checks(web: OpenAPI):
|
||||
required_rules = {
|
||||
"/auth/login",
|
||||
"/auth/bootstrap/status",
|
||||
"/setup/status",
|
||||
"/api/downloads/jobs",
|
||||
"/api/catalog/search",
|
||||
}
|
||||
|
||||
current_rules = {rule.rule for rule in web.url_map.iter_rules()}
|
||||
missing_rules = sorted(required_rules - current_rules)
|
||||
|
||||
if missing_rules:
|
||||
log.error("Boot smoke check failed. Missing routes: %s", missing_rules)
|
||||
else:
|
||||
log.info("Boot smoke check passed (%s routes).", len(current_rules))
|
||||
|
||||
strict_boot = _feature_enabled("SWINGMUSIC_STRICT_BOOT", default=False)
|
||||
if strict_boot and (missing_rules or _BOOT_REGISTRATION_STATE["failed"]):
|
||||
raise RuntimeError(
|
||||
"Strict boot failed. Missing routes or API module registration failures detected."
|
||||
)
|
||||
|
||||
|
||||
# # # # # # # # # # #
|
||||
# Create App object #
|
||||
# # # # # # # # # # #
|
||||
|
||||
api_info = Info(
|
||||
title="Swing Music",
|
||||
version=f"v{Metadata.version}",
|
||||
description="The REST API exposed by your Swing Music server",
|
||||
)
|
||||
|
||||
app = OpenAPI(__name__, info=api_info, doc_prefix="/docs")
|
||||
|
||||
|
||||
def check_auth_need() -> bool:
|
||||
"""
|
||||
Check if the current request is for a static file.
|
||||
We do not need auth for index or static images of index.
|
||||
|
||||
:return: True if static file else False
|
||||
"""
|
||||
|
||||
# INFO: Routes that don't need authentication
|
||||
urls = {
|
||||
"/auth/login",
|
||||
"/auth/user",
|
||||
"/auth/users",
|
||||
"/auth/pair",
|
||||
"/auth/logout",
|
||||
"/auth/refresh",
|
||||
"/auth/bootstrap",
|
||||
"/auth/invite/accept",
|
||||
"/setup",
|
||||
"/docs",
|
||||
"/healthz",
|
||||
}
|
||||
files = {".webp", ".jpg", *get_client_files_extensions()}
|
||||
|
||||
urls = tuple(urls)
|
||||
files = tuple(files)
|
||||
|
||||
if request.path == "/" or request.path.endswith(files):
|
||||
return True
|
||||
|
||||
# if request path starts with any of the blacklisted routes, don't verify jwt
|
||||
return bool(request.path.startswith(urls))
|
||||
|
||||
|
||||
# # # # # # # # # # # # #
|
||||
# global endpoint logic #
|
||||
# # # # # # # # # # # # #
|
||||
|
||||
|
||||
@app.route("/<path:path>")
|
||||
def serve_client_files(path: str):
|
||||
"""
|
||||
Serves the static files in the client folder.
|
||||
"""
|
||||
|
||||
# Handle potential double /client path (e.g., '/client/some.js' -> '/client/client/some.js')
|
||||
# This can occur with certain proxy configurations
|
||||
if path.startswith("client/"):
|
||||
path = path[7:] # Remove duplicate 'client/' prefix
|
||||
|
||||
js_or_css = path.endswith(".js") or path.endswith(".css")
|
||||
|
||||
if not js_or_css:
|
||||
return app.send_static_file(path)
|
||||
|
||||
# INFO: Safari doesn't support gzip encoding
|
||||
# See issue: https://github.com/swingmx/swingmusic/issues/155
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
if "Safari" in user_agent and "Chrome" not in user_agent:
|
||||
return app.send_static_file(path)
|
||||
|
||||
if "gzip" in request.headers.get("Accept-Encoding", ""):
|
||||
gz_name = path + ".gz"
|
||||
gzipped_path = pathlib.Path(app.static_folder or "") / gz_name
|
||||
|
||||
if gzipped_path.exists():
|
||||
response = app.make_response(app.send_static_file(gz_name))
|
||||
response.headers["Content-Encoding"] = "gzip"
|
||||
return response
|
||||
|
||||
return app.send_static_file(path)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def serve_client():
|
||||
"""
|
||||
Serves the index.html file at `client/index.html`.
|
||||
"""
|
||||
return app.send_static_file("index.html")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
setup = get_setup_status()
|
||||
failed = list(_BOOT_REGISTRATION_STATE["failed"])
|
||||
|
||||
status_code = 200
|
||||
if failed and _feature_enabled("SWINGMUSIC_STRICT_BOOT", default=False):
|
||||
status_code = 503
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"ok": status_code == 200,
|
||||
"setup_completed": setup.get("setup_completed", False),
|
||||
"onboarding_required": setup.get("required", True),
|
||||
"registered_modules": list(_BOOT_REGISTRATION_STATE["registered"]),
|
||||
"failed_modules": failed,
|
||||
}
|
||||
),
|
||||
status_code,
|
||||
)
|
||||
|
||||
|
||||
@app.errorhandler(RateLimitExceeded)
|
||||
def handle_rate_limit(error: RateLimitExceeded):
|
||||
retry_after = getattr(error, "retry_after", None)
|
||||
response = jsonify(
|
||||
{
|
||||
"msg": "Too many requests. Please wait before trying again.",
|
||||
"error": "rate_limited",
|
||||
"retry_after": retry_after,
|
||||
}
|
||||
)
|
||||
if retry_after is not None:
|
||||
response.headers["Retry-After"] = str(retry_after)
|
||||
return response, 429
|
||||
|
||||
|
||||
def build() -> OpenAPI:
|
||||
"""
|
||||
Call this function to obtain the final flask/openapi object.
|
||||
|
||||
Do not import app directly as the static_folder can only be set
|
||||
when cli args are parsed.
|
||||
|
||||
:return: OpenApi object with all config set
|
||||
"""
|
||||
|
||||
# set late state config
|
||||
app.static_folder = Paths().client_path
|
||||
|
||||
@app.before_request
|
||||
def verify_auth():
|
||||
"""
|
||||
Verifies the JWT token before each request.
|
||||
"""
|
||||
|
||||
if check_auth_need():
|
||||
return
|
||||
|
||||
if not is_setup_complete():
|
||||
setup = get_setup_status()
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "setup_incomplete",
|
||||
"msg": "Initial setup must be completed before using product APIs.",
|
||||
"setup": setup,
|
||||
}
|
||||
),
|
||||
423,
|
||||
)
|
||||
|
||||
verify_jwt_in_request()
|
||||
|
||||
@app.after_request
|
||||
def refresh_expiring_jwt(response: Response):
|
||||
"""
|
||||
Refreshes the cookies JWT token after each request.
|
||||
"""
|
||||
|
||||
# INFO: If the request has an Authorization header, don't refresh the jwt
|
||||
# Request is probably from the mobile client or a third party
|
||||
if check_auth_need() or request.headers.get("Authorization"):
|
||||
return response
|
||||
|
||||
try:
|
||||
exp_timestamp = get_jwt()["exp"]
|
||||
until = dt.datetime.now(dt.UTC) + dt.timedelta(days=7)
|
||||
|
||||
if until.timestamp() > exp_timestamp:
|
||||
access_token = create_access_token(identity=get_jwt_identity())
|
||||
set_access_cookies(response, access_token)
|
||||
|
||||
return response
|
||||
except (RuntimeError, KeyError):
|
||||
return response
|
||||
|
||||
config_app(app)
|
||||
config_jwt(app)
|
||||
|
||||
# Initialize rate limiter
|
||||
global limiter
|
||||
rate_limit = os.getenv("SWINGMUSIC_RATE_LIMIT", "200 per hour;50 per minute")
|
||||
limiter = Limiter(
|
||||
app=app,
|
||||
key_func=get_remote_address,
|
||||
default_limits=[rate_limit],
|
||||
default_limits_exempt_when=check_auth_need,
|
||||
storage_uri="memory://",
|
||||
)
|
||||
|
||||
load_endpoints(app)
|
||||
run_boot_smoke_checks(app)
|
||||
|
||||
return app
|
||||
Reference in New Issue
Block a user