feat(security): apply AdminPortFilter to AppleMusic and DownloadActivity controllers, restore Deezer, configure Apple Music env variables

This commit is contained in:
joshpatra committed 2026-07-07 17:49:55 -04:00
1 parent 96925aebbf
commit fafc1468f9
22 files changed
+42 -253

No files matched your search

+1 -7
View File
@@ -98,7 +98,6 @@ public class JavaScriptSyntaxTests
var filePath = Path.Combine(_wwwrootPath, "app.js");
var content = File.ReadAllText(filePath);
// Check that the file is now just a deprecation notice
Assert.Contains("DEPRECATED", content);
Assert.Contains("main.js", content);
}
@@ -150,8 +149,6 @@ public class JavaScriptSyntaxTests
[Fact]
public void AppJs_ShouldHaveBalancedBraces()
{
// app.js is now deprecated and just contains comments
// Skip this test or check main.js instead
var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
var content = File.ReadAllText(filePath);
@@ -164,12 +161,9 @@ public class JavaScriptSyntaxTests
[Fact]
public void AppJs_ShouldHaveBalancedParentheses()
{
// app.js is now deprecated and just contains comments
// Skip this test or check main.js instead
var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
// Use Node.js to validate syntax instead of counting parentheses
// This is more reliable than regex-based string/comment removal
// Validate syntax with Node.js instead of counting parentheses.
string error;
var isValid = ValidateJavaScriptSyntax(filePath, out error);
@@ -3,11 +3,13 @@ using Microsoft.Extensions.Options;
using System.Net.Http.Headers;
using System.Text.Json;
using allstarr.Models.Settings;
using allstarr.Filters;
namespace allstarr.Controllers;
[ApiController]
[Route("api/admin/applemusic")]
[ServiceFilter(typeof(AdminPortFilter))]
public class AppleMusicController : ControllerBase
{
private readonly HttpClient _httpClient;
@@ -3,11 +3,13 @@ using allstarr.Models.Download;
using allstarr.Services;
using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Mvc;
using allstarr.Filters;
namespace allstarr.Controllers;
[ApiController]
[Route("api/admin/downloads")]
[ServiceFilter(typeof(AdminPortFilter))]
public class DownloadActivityController : ControllerBase
{
private readonly IEnumerable<IDownloadService> _downloadServices;
@@ -1529,11 +1529,7 @@ public partial class JellyfinController
}
/// <summary>
/// Catch-all for any other session-related requests.
/// <summary>
/// Catch-all proxy for any other session-related endpoints we haven't explicitly implemented.
/// This ensures all session management calls get proxied to Jellyfin.
/// Examples: GET /Sessions, POST /Sessions/Logout, etc.
/// Proxy unhandled session-related endpoints to Jellyfin.
/// </summary>
[HttpGet("Sessions")]
[HttpPost("Sessions")]
@@ -48,18 +48,6 @@ public partial class JellyfinController
searchTerm ?? string.Empty,
includeItemTypes ?? string.Empty);
// ============================================================================
// REQUEST ROUTING LOGIC (Priority Order)
// ============================================================================
// 1. ArtistIds present (external) → Handle external artists (even with ParentId)
// 2. AlbumIds present (external) → Handle external albums (even with ParentId)
// 3. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
// 4. ArtistIds present (library) → Proxy to Jellyfin with artist filter
// 5. SearchTerm present → Integrated search (Jellyfin + external sources)
// 6. Otherwise → Proxy browse request transparently to Jellyfin
// ============================================================================
// PRIORITY 1: External artist filter - takes precedence over everything (including ParentId)
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
@@ -87,7 +75,6 @@ public partial class JellyfinController
// If library artist, fall through to handle with ParentId or proxy
}
// PRIORITY 2: External album filter
if (!string.IsNullOrWhiteSpace(albumIds))
{
var albumId = albumIds.Split(',')[0]; // Take first album if multiple
@@ -123,7 +110,6 @@ public partial class JellyfinController
// If library album, fall through to handle with ParentId or proxy
}
// PRIORITY 3: ParentId present - check if external first
if (!string.IsNullOrWhiteSpace(parentId))
{
// Check if this is an external playlist
@@ -165,7 +151,6 @@ public partial class JellyfinController
}
}
// PRIORITY 4: Library artist filter (already checked for external above)
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{
// Library artist - proxy transparently with full query string
@@ -177,7 +162,6 @@ public partial class JellyfinController
return HandleProxyResponse(result, statusCode);
}
// PRIORITY 5: Search term present - do integrated search (Jellyfin + external)
if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Check cache for search results (only cache pure searches, not filtered searches)
@@ -205,7 +189,6 @@ public partial class JellyfinController
// Fall through to integrated search below
}
// PRIORITY 6: No filters, no search - proxy browse request transparently
else
{
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
@@ -469,7 +469,6 @@ public partial class JellyfinController
return false;
}
/// <summary>
/// <summary>
/// Copies an external track to the kept folder when favorited.
/// </summary>
@@ -961,11 +960,7 @@ public partial class JellyfinController
#endregion
/// <summary>
/// Loads missing tracks from file cache as fallback when Redis is empty.
/// <summary>
/// Gets a signature (hash) of the Jellyfin playlist to detect changes.
/// This is a cheap operation compared to re-matching all tracks.
/// Signature includes: track count + concatenated track IDs.
/// Gets a signature of the Jellyfin playlist to detect changes.
/// </summary>
private async Task<string> GetJellyfinPlaylistSignatureAsync(string playlistId)
{
@@ -266,9 +266,4 @@ public class MappingController : ControllerBase
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
_logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId);
}
/// <summary>
/// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID
/// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP
/// </summary>
}
@@ -554,10 +554,6 @@ public class PlaylistController : ControllerBase
}
else
{
// This else block is reached when:
// 1. JellyfinId is empty, OR
// 2. totalPlayable > 0 (modern path already worked), OR
// 3. spotifyTrackCount == 0
// Only log if JellyfinId is actually missing
if (string.IsNullOrEmpty(config.JellyfinId))
{
+2 -21
View File
@@ -6,23 +6,7 @@ using allstarr.Models.Settings;
namespace allstarr.Filters;
/// <summary>
/// REMOVED: Authentication filter for Jellyfin API endpoints.
///
/// This filter has been removed because Allstarr acts as a TRANSPARENT PROXY.
/// Clients authenticate directly with Jellyfin through the proxy, not with the proxy itself.
///
/// Authentication flow:
/// 1. Client sends credentials to /Users/AuthenticateByName
/// 2. Proxy forwards request to Jellyfin (no validation)
/// 3. Jellyfin validates credentials and returns AccessToken
/// 4. Client uses AccessToken in subsequent requests
/// 5. Proxy forwards token to Jellyfin for validation
///
/// The proxy NEVER validates credentials or tokens - that's Jellyfin's job.
/// The proxy only forwards authentication headers transparently.
///
/// If you need to restrict access to the proxy itself, use network-level controls
/// (firewall, VPN, reverse proxy with auth) instead of application-level auth.
/// Legacy no-op filter retained for compatibility.
/// </summary>
public class JellyfinAuthFilter : IAsyncActionFilter
{
@@ -35,11 +19,8 @@ public class JellyfinAuthFilter : IAsyncActionFilter
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// This filter is now a no-op - all authentication is handled by Jellyfin
// Keeping the class for backwards compatibility but it does nothing
_logger.LogTrace("JellyfinAuthFilter: Transparent proxy mode - no authentication check");
await next();
}
}
+1 -3
View File
@@ -1028,9 +1028,7 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
var isController = base.IsController(typeInfo);
if (!isController) return false;
// All admin controllers should always be registered (for admin UI)
// This includes: AdminController, ConfigController, DiagnosticsController, DownloadsController,
// PlaylistController, JellyfinAdminController, SpotifyAdminController, LyricsController, MappingController, ScrobblingAdminController
// All admin controllers should always be registered for the admin UI.
if (typeInfo.Name == "AdminController" ||
typeInfo.Name == "AdminAuthController" ||
typeInfo.Name == "ConfigController" ||
@@ -58,12 +58,6 @@ public class RedisPersistenceService : BackgroundService
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss");
var snapshotFile = Path.Combine(SnapshotDirectory, $"snapshot_{timestamp}.json");
// For now, we'll rely on Redis's built-in RDB + AOF persistence
// This service is a placeholder for future enhancements like:
// - Exporting specific key patterns to JSON
// - Creating human-readable backups
// - Syncing to external storage
_logger.LogDebug("Redis snapshot service running (using Redis native persistence)");
// Clean up old snapshots (keep last 10)
@@ -7,16 +7,7 @@ using Cronos;
namespace allstarr.Services.Spotify;
/// <summary>
/// Background service that fetches playlist tracks directly from Spotify's API.
///
/// This replaces the Jellyfin Spotify Import plugin dependency with key advantages:
/// - Track ordering is preserved (critical for playlists like Release Radar)
/// - ISRC codes available for exact matching
/// - Real-time data without waiting for plugin sync schedules
/// - Full track metadata (duration, release date, etc.)
///
/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
/// Cache persists until next cron run to prevent excess Spotify API calls.
/// Background service that fetches playlist tracks directly from Spotify on each playlist's cron schedule.
/// </summary>
public class SpotifyPlaylistFetcher : BackgroundService
{
@@ -1100,13 +1100,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
/// <summary>
/// Returns multiple candidate matches with scores for greedy assignment.
/// FOLLOWS OPTIMAL ORDER:
/// 1. Strip decorators (done in FuzzyMatcher)
/// <summary>
/// Attempts to match a track by title and artist using fuzzy matching.
/// SEARCHES LOCAL FIRST, then external if no local match found.
/// Returns multiple candidates for greedy assignment.
/// Attempts to match a track by title and artist and returns scored candidates.
/// </summary>
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
string title,
+4 -6
View File
@@ -817,7 +817,6 @@
<button class="primary" onclick="saveAllSettings('tab-services')">💾 Save Services Settings</button>
</div>
</div>
</div>
<!-- Configuration Tab -->
<div class="tab-content" id="tab-config">
@@ -1008,11 +1007,6 @@
</div>
</div>
<div class="save-bar-sticky">
<button class="primary" onclick="saveAllSettings('tab-config')">💾 Save Configuration</button>
</div>
</div>
<div class="card" id="config-backup-card">
<h2>Configuration Backup</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;" id="config-backup-description">
@@ -1039,6 +1033,10 @@
<button class="danger" onclick="restartContainer()">Restart Allstarr</button>
</div>
</div>
<div class="save-bar-sticky">
<button class="primary" onclick="saveAllSettings('tab-config')">💾 Save Configuration</button>
</div>
</div>
<!-- Report Issues Tab -->
+25 -7
View File
@@ -1309,9 +1309,8 @@ async function pollAppleMusicStatus() {
const card = document.getElementById("applemusic-manager-card");
if (!card) return;
// Only poll if AppleMusic is the active provider or if we are looking at configuration
const currentTab = document.querySelector(".sidebar-link.active")?.getAttribute("data-tab");
if (currentTab !== "config") return;
if (currentTab !== "services") return;
try {
const res = await fetch("/api/admin/applemusic/status");
@@ -1421,6 +1420,20 @@ async function uploadAppleMusicApk(file) {
xhr.send(formData);
}
async function readAppleMusicError(response, fallbackMessage) {
const rawText = await response.text();
if (!rawText) {
return fallbackMessage;
}
try {
const payload = JSON.parse(rawText);
return payload.detail || payload.error || rawText;
} catch {
return rawText;
}
}
// Login
async function submitAppleMusicLogin() {
const username = document.getElementById("am-username-input").value.trim();
@@ -1440,14 +1453,16 @@ async function submitAppleMusicLogin() {
if (res.status === 200) {
alert("Login successful!");
document.getElementById("am-password-input").value = "";
document.getElementById("am-tfa-input").value = "";
pollAppleMusicStatus();
} else if (res.status === 202) {
// 2FA required
document.getElementById("am-login-section").style.display = "none";
document.getElementById("am-tfa-section").style.display = "block";
document.getElementById("am-tfa-input").focus();
} else {
const text = await res.text();
alert("Login failed: " + text);
const message = await readAppleMusicError(res, "Login failed.");
alert("Login failed: " + message);
}
} catch (err) {
alert("Error logging in: " + err.message);
@@ -1470,10 +1485,13 @@ async function submitAppleMusic2fa() {
if (res.ok) {
alert("2FA Verification successful! You are now logged in.");
document.getElementById("am-password-input").value = "";
document.getElementById("am-tfa-input").value = "";
document.getElementById("am-tfa-section").style.display = "none";
pollAppleMusicStatus();
} else {
const text = await res.text();
alert("Verification failed: " + text);
const message = await readAppleMusicError(res, "Verification failed.");
alert("Verification failed: " + message);
}
} catch (err) {
alert("Error verifying code: " + err.message);
+1 -1
View File
@@ -1014,7 +1014,7 @@ textarea {
padding: 16px 24px;
display: flex;
justify-content: flex-end;
margin: 32px -24px -24px -24px;
margin: 32px -24px 0 -24px;
border-bottom-left-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
z-index: 100;
@@ -2531,7 +2531,6 @@ def _clean_stsd_content(
version_flags = stsd_content[:4]
entry_count = struct.unpack(">I", stsd_content[4:8])[0]
# Parse and clean each sample entry.
cleaned_entries = []
offset = 8
@@ -2547,9 +2546,7 @@ def _clean_stsd_content(
entry_data = stsd_content[offset : offset + entry_size]
# Check if this is an encrypted entry
if entry_type in (b"enca", b"encv", b"encs", b"encm"):
# Clean the encrypted entry
cleaned_entry = _clean_encrypted_sample_entry(entry_data)
cleaned_entries.append(cleaned_entry)
else:
@@ -2568,7 +2565,6 @@ def _clean_stsd_content(
else:
cleaned_entries = [cleaned_entries[0]]
# Rebuild stsd content
result = version_flags + struct.pack(">I", len(cleaned_entries))
for entry in cleaned_entries:
result += entry
@@ -2614,7 +2610,6 @@ def _clean_encrypted_sample_entry(entry_data: bytes) -> bytes:
if len(entry_data) < sample_entry_header_size:
return entry_data
# Find the original format from sinf/frma
original_format = _find_original_format(entry_data)
if not original_format:
# If we can't find frma, try common mappings
@@ -2681,7 +2676,6 @@ def _find_original_format(entry_data: bytes) -> Optional[bytes]:
if frma_size != 12: # frma is always 12 bytes: size(4) + type(4) + format(4)
return None
# Extract the original format
return sinf_data[frma_idx + 4 : frma_idx + 8]
@@ -2696,11 +2690,9 @@ def _remove_sinf_from_entry(entry_data: bytes) -> bytes:
if len(entry_data) < sample_entry_header_size:
return entry_data
# Check if sinf exists
if b"sinf" not in entry_data:
return entry_data
# Rebuild entry without sinf
new_entry = entry_data[:sample_entry_header_size]
child_offset = sample_entry_header_size
@@ -2725,12 +2717,10 @@ def _remove_sinf_from_entry(entry_data: bytes) -> bytes:
def _extract_alac_config(data: bytes) -> Optional[bytes]:
"""Extract ALAC configuration from moov/stsd box (for backwards compatibility)."""
# Simple search for 'alac' box in data
idx = data.find(b"alac")
if idx < 4:
return None
# Check if it's inside stsd (look for full structure)
# The 'alac' cookie box follows the sample entry
alac_idx = idx
while alac_idx < len(data) - 100:
-24
View File
@@ -12,15 +12,12 @@ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
# Set up logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("gamdl-aio")
# Add gamdl to sys.path
PROJECT_ROOT = Path(__file__).parent.resolve()
sys.path.insert(0, str(PROJECT_ROOT / "gamdl"))
# Import gamdl modules
try:
from gamdl.api import AppleMusicApi
from gamdl.api.wrapper import WrapperApi
@@ -65,7 +62,6 @@ class Login2FARequest(BaseModel):
async def start_wrapper_daemon():
global wrapper_proc, wrapper_api, apple_music_api
# Check if native libraries are staged
sentinel = SYSTEM_LIBS_DIR / "libandroidappmusic.so"
if not sentinel.exists():
logger.warning(f"Native Apple libraries not staged at {sentinel}. Wrapper daemon cannot start yet.")
@@ -73,7 +69,6 @@ async def start_wrapper_daemon():
logger.info("Starting wrapper-v2 daemon...")
# Configure env for wrapper launcher
env = os.environ.copy()
env["HTTP_PORT"] = str(HTTP_PORT)
env["TARGET_ARCH"] = TARGET_ARCH
@@ -88,7 +83,6 @@ async def start_wrapper_daemon():
return False
try:
# Start wrapper daemon as supervisor
wrapper_proc = subprocess.Popen(
[str(wrapper_bin)],
cwd=str(WRAPPER_DIR),
@@ -98,11 +92,9 @@ async def start_wrapper_daemon():
text=True
)
# Monitor startup output
await asyncio.sleep(2)
logger.info("wrapper-v2 daemon started successfully.")
# Initialize Wrapper API and Apple Music API
await init_apple_music_api()
return True
except Exception as e:
@@ -217,7 +209,6 @@ async def login_2fa(req: Login2FARequest):
@app.post("/api/setup")
async def upload_apk(file: UploadFile = File(...)):
# Save uploaded file
temp_apk = DATA_DIR / file.filename
try:
with open(temp_apk, "wb") as buffer:
@@ -225,14 +216,12 @@ async def upload_apk(file: UploadFile = File(...)):
logger.info(f"Received APK file: {temp_apk.name}. Extracting libraries...")
# Run stage system libraries first
stage_cmd = ["bash", "tools/stage-system.sh", "--arch", TARGET_ARCH]
stage_res = subprocess.run(stage_cmd, cwd=str(WRAPPER_DIR), capture_output=True, text=True)
if stage_res.returncode != 0:
logger.error(f"stage-system failed: {stage_res.stderr}")
raise HTTPException(status_code=500, detail=f"Failed to stage system libs: {stage_res.stderr}")
# Run extract libraries
extract_cmd = [
"bash", "tools/extract-libs.sh",
"--bundle", str(temp_apk),
@@ -244,10 +233,8 @@ async def upload_apk(file: UploadFile = File(...)):
logger.error(f"extract-libs failed: {extract_res.stderr}")
raise HTTPException(status_code=500, detail=f"Failed to extract Apple libs: {extract_res.stderr}")
# Clean up uploaded APK file to save disk space
temp_apk.unlink()
# Start/Restart the daemon
global wrapper_proc
if wrapper_proc:
wrapper_proc.terminate()
@@ -266,7 +253,6 @@ async def search(q: str, type: str = "song", limit: int = 20):
raise HTTPException(status_code=401, detail="Apple Music subscription not authenticated")
try:
# Map types
results = []
if type == "song":
res = await apple_music_api.get_search_results(q, limit=limit, types="songs")
@@ -324,7 +310,6 @@ async def get_song(track_id: str):
s = song_data[0]
attrs = s["attributes"]
# Extract metadata
return {
"id": s["id"],
"title": attrs["name"],
@@ -348,12 +333,10 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
if not apple_music_api or not apple_music_api.active_subscription:
raise HTTPException(status_code=401, detail="Apple Music subscription not authenticated")
# Setup temporary directory for download/transcode
temp_dir = DATA_DIR / f"temp_{track_id}"
temp_dir.mkdir(exist_ok=True)
try:
# Determine codec matching the requested quality
codec_priority = [SongCodec.ALAC]
if quality == "alac-16-44":
codec_priority = [SongCodec.ALAC_16_44]
@@ -368,7 +351,6 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
logger.info(f"Downloading stream for track {track_id} with codec {codec_priority[0].value}")
# Instantiate gamdl interfaces programmatically
base_interface = await AppleMusicBaseInterface.create(
apple_music_api=apple_music_api,
cover_format=CoverFormat.JPG,
@@ -403,7 +385,6 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
no_synced_lyrics=True,
)
# Get download items and execute
url = f"https://music.apple.com/us/song/{track_id}"
download_queue = []
async for item in downloader.get_download_item_from_url(url):
@@ -415,12 +396,10 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
item = download_queue[0]
await downloader.download(item)
# Find the decrypted M4A file
m4a_path = Path(item.staged_path)
if not m4a_path.exists():
raise FileNotFoundError("M4A download file was not generated.")
# Transcode on-the-fly to FLAC
flac_path = temp_dir / f"{track_id}.flac"
logger.info(f"Transcoding {m4a_path.name} to FLAC...")
@@ -439,8 +418,6 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
logger.error(f"FFmpeg failed: {res.stderr.decode('utf-8')}")
raise Exception("FFmpeg transcode failed")
# Return streaming file response
# Background task cleans up files AFTER response is sent
def cleanup():
try:
shutil.rmtree(temp_dir)
@@ -461,7 +438,6 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
shutil.rmtree(temp_dir)
raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}")
# Serve API status on root
@app.get("/")
async def root():
return {"name": "Gamdl All-in-One", "status": "online"}
@@ -1,20 +1,3 @@
// Token-harvest helpers.
//
// Post-login, Apple's `RequestContext` carries the iTunes-side auth
// state (DSID + signed device blobs). We use that to:
// - Read the storefront identifier directly from RequestContext.
// - GET sf-api-token-service.itunes.apple.com/apiToken?clientId=musicAndroid
// &version=1 for the developer token (JWT; JSON key `token`).
// - POST to play.itunes.apple.com/.../createMusicToken for the
// Music User Token (MusicKit-side token derived from this login).
// - Decode the `dsid` claim out of the dev-token JWT.
//
// Everything goes through Apple's URLRequest so requests are
// transparently signed with X-Dsid / X-Token / X-iTunes-Storefront
// headers; we don't have to reimplement that. The URL signing
// machinery lives in libstoreservicescore.so and consumes the same
// RequestContext that AuthenticateFlow just populated.
#pragma once
#include <optional>
@@ -29,10 +12,6 @@ struct Tokens;
namespace tokens {
// One-shot orchestrator used by the worker thread. Populates the
// storefront / dev_token / music_user_token / dsid fields on `out`
// (the caller is responsible for apple_id and logged_in_at). Returns
// false on the first hard failure with stderr-logged context.
bool harvest_all(const Symbols& s,
abi::shared_ptr req_ctx,
abi::shared_ptr device_guid,
-36
View File
@@ -1,39 +1,3 @@
// wrapper-v2 daemon entry point.
//
// The daemon starts in LoggedOut state, expects credentials via HTTP,
// and drives Apple's AuthenticateFlow under the hood:
//
// GET /health
// GET /me
// POST /login body: { "apple_id": "...", "password": "..." }
// POST /login/2fa body: { "code": "123456" }
// DELETE /login
//
// Persistence: mount WRAPPER_BASE_DIR so Apple keeps mpl_db across
// restarts. After a prior POST /login (or first-time -L style login),
// startup may restore tokens from that session without password
// (WRAPPER_RESTORE_SESSION=1, default).
//
// Configuration is environment-only. Optional argv: --help. All knobs
// use the WRAPPER_ prefix:
//
// WRAPPER_HOST Bind address (default 0.0.0.0)
// WRAPPER_PORT Bind port (default 80)
// WRAPPER_MODE supervisor (default) or worker
// WRAPPER_WORKER_PORT Private supervisor->worker port (default 18080)
// WRAPPER_BASE_DIR Apple-lib working dir (default
// /data/data/com.apple.android.music/files)
// WRAPPER_DEVICE_INFO 9-tuple device identifier
// WRAPPER_APPLE_INIT "0" to skip Apple lib init at startup
// (useful for /health-only smoke tests).
// WRAPPER_RESTORE_SESSION "0" skips on-disk session restore after init.
// WRAPPER_APPLE_ID Optional label for GET /me apple_id after restore
// (not sent to Apple).
// WRAPPER_USERNAME With WRAPPER_PASSWORD, run password sign-in at
// startup if not already authenticated (same as
// POST /login username field — Apple ID email).
// WRAPPER_PASSWORD App-specific password for WRAPPER_USERNAME auto-login.
#include <atomic>
#include <chrono>
#include <csignal>
@@ -286,10 +286,6 @@ Server::Server(httplib::Server& svr,
: svr_(svr), rt_(rt), loader_(loader), account_(account), info_(std::move(info)) {}
void Server::mount() {
// ---- GET /health ----
// Liveness + runtime debug info. Always returns 200 if the
// process is up; consumers should treat runtime.initialized==false
// as a soft failure (auth/decrypt endpoints won't work) rather than a hard one.
svr_.Get("/health", [this](const httplib::Request& req, httplib::Response& res) {
access_log("GET", req);
json runtime = {
@@ -312,11 +308,6 @@ void Server::mount() {
});
});
// ---- GET /me ----
// Combined daemon snapshot: version, runtime probe (same facts as
// /health.runtime), and auth (Apple ID state + harvested tokens after
// a successful POST /login). iTunes account-token / X-Token are NOT
// exposed — only dev_token, music_user_token, storefront, dsid.
svr_.Get("/me", [this](const httplib::Request& req, httplib::Response& res) {
access_log("GET", req);
if (rt_.initialized() && restore_session_enabled()) {
@@ -330,17 +321,6 @@ void Server::mount() {
respond_json(res, 200, std::move(body));
});
// ---- POST /login ----
// Body: { "username": "...", "password": "..." }
// or { "apple_id": "...", "password": "..." } (synonyms)
// Returns:
// 200 if AuthenticateFlow completed (state=authenticated, tokens present)
// 202 if Apple asked for HSA2 (state=awaiting_2fa) - follow up with
// POST /login/2fa
// 401 if Apple rejected credentials (state=failed)
// 409 if a login is already in progress
// 503 if the runtime is not initialized
// 504 if the flow has not produced any state inside kLoginTimeout
svr_.Post("/login", [this](const httplib::Request& req, httplib::Response& res) {
access_log("POST", req);
if (!rt_.initialized()) {
@@ -415,9 +395,6 @@ void Server::mount() {
respond_json(res, http_status_for(state), snapshot_to_json(account_.public_snapshot()));
});
// ---- POST /login/2fa ----
// Body: { "code": "123456" }
// Returns 200 / 401 / 409 / 504 with the same shape as /login.
svr_.Post("/login/2fa", [this](const httplib::Request& req, httplib::Response& res) {
access_log("POST", req);
json body;
@@ -456,15 +433,6 @@ void Server::mount() {
respond_json(res, http_status_for(state), snapshot_to_json(account_.public_snapshot()));
});
// ---- GET /playback ----
// Returns Apple's full MZ-protocol playback dispatch as native JSON
// (the CFDictionary plist tree walked into nlohmann::json: dict ->
// object, array -> array, string/number/bool -> matching JSON types,
// CFData -> base64 string, CFDate -> ISO 8601). Driven by
// storeservicescore::PurchaseRequest with urlBagKey="subDownload"
// (matching upstream wrapper's get_m3u8_method_download). Unlike
// upstream which extracts just the last asset's URL, we hand back
// every flavor / key URI / metadata field Apple included.
svr_.Get("/playback", [this](const httplib::Request& req, httplib::Response& res) {
access_log("GET", req);
if (!rt_.initialized()) {
@@ -522,15 +490,6 @@ void Server::mount() {
respond_json(res, 200, std::move(pr.body));
});
// ---- POST /decrypt ----
// FairPlay sample decrypt. Binary request:
// u32be adam_id_len, u32be uri_len, u32be sample_count,
// u32be sample_len[sample_count], adam_id bytes, uri bytes,
// concatenated ciphertext samples.
// Binary response:
// u32be sample_count, u32be sample_len[sample_count],
// concatenated plaintext samples.
// Requires authenticated + playback_ready.
svr_.Post("/decrypt", [this](const httplib::Request& req, httplib::Response& res) {
access_log("POST", req);
if (!rt_.initialized()) {
@@ -593,10 +552,6 @@ void Server::mount() {
res.set_content(response_body, "application/octet-stream");
});
// ---- DELETE /login ----
// Clears in-memory tokens and (if a flow is running) signals the
// worker thread to abort. Apple's kvs.sqlitedb cache is NOT
// touched; the next POST /login will reuse it if still valid.
svr_.Delete("/login", [this](const httplib::Request& req, httplib::Response& res) {
access_log("DELETE", req);
auto prev = account_.state();
@@ -607,7 +562,6 @@ void Server::mount() {
});
});
// ---- exception fallback ----
svr_.set_exception_handler([](const httplib::Request& req, httplib::Response& res,
std::exception_ptr ep) {
std::string what = "unknown";
@@ -1,9 +1,3 @@
// HTTP server wiring.
//
// The Server class owns an httplib::Server and mounts the routes
// wrapper-v2 exposes. References to the runtime modules are
// captured by reference - the Server does not own them.
#pragma once
#include <string>
@@ -17,12 +11,8 @@
namespace wrapper {
struct ServerInfo {
// Free-form version string surfaced via /health and /me.
std::string version = "0.0.1";
// True iff Apple lib initialization is enabled (controlled by
// WRAPPER_APPLE_INIT). Surfaced via /health so it is obvious when
// the daemon is running in stub-only mode.
bool apple_init_enabled = true;
};
@@ -34,7 +24,6 @@ public:
apple::Account& account,
ServerInfo info);
// Mount all routes onto the underlying httplib::Server.
void mount();
private: