Step 7.5a: Spotify client credentials in config/UI, write-only secrets

- Core config: cfg_mark_secret(group, key). GET /api/config returns ""
  for secret keys; a POST with "" keeps the stored value, null clears it;
  cfg_get() returns the real value. Documented in aes67-core-base.md.
- source.spotify_client_id / spotify_client_secret (secret write-only):
  each user's own Spotify developer app, needed by the maintained cspot
  fork (philippe44/cspot) since Spotify's 2025 API restrictions.
- UI: client ID field, password field for the secret ("leave empty to
  keep"), link to developer.spotify.com; enabled for spotify/auto modes.
- status.spotify_state: "no client credentials" while either is missing.
- Verified: secret never appears in GET; UI-style re-save keeps it;
  survives reboot; null clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 15:16:42 +10:00
parent c0e0388ff0
commit 7bbda0a1e8
7 changed files with 69 additions and 6 deletions
+45 -2
View File
@@ -13,6 +13,7 @@
#include "nvs_flash.h" #include "nvs_flash.h"
#define MAX_GROUPS 12 #define MAX_GROUPS 12
#define MAX_SECRETS 4
#define MAX_BODY 8192 #define MAX_BODY 8192
#define NVS_NAMESPACE "cfg" #define NVS_NAMESPACE "cfg"
@@ -24,6 +25,8 @@ typedef struct {
cJSON *values; cJSON *values;
cfg_validate_cb_t validate; cfg_validate_cb_t validate;
cfg_apply_cb_t apply; cfg_apply_cb_t apply;
char secrets[MAX_SECRETS][24]; // write-only keys
int n_secrets;
} cfg_group_t; } cfg_group_t;
static cfg_group_t s_groups[MAX_GROUPS]; static cfg_group_t s_groups[MAX_GROUPS];
@@ -220,6 +223,35 @@ esp_err_t cfg_register(const char *group, const char *defaults_json,
return ESP_OK; return ESP_OK;
} }
esp_err_t cfg_mark_secret(const char *group, const char *key)
{
if (!s_lock) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
cfg_group_t *g = find(group);
esp_err_t err = ESP_OK;
if (!g || !cJSON_IsString(cJSON_GetObjectItemCaseSensitive(g->defaults, key))) {
err = ESP_ERR_NOT_FOUND;
} else if (g->n_secrets >= MAX_SECRETS || strlen(key) >= sizeof(g->secrets[0])) {
err = ESP_ERR_NO_MEM;
} else {
strcpy(g->secrets[g->n_secrets++], key);
}
xSemaphoreGive(s_lock);
return err;
}
// Group values for the API: secrets blanked.
static cJSON *public_values(const cfg_group_t *g)
{
cJSON *v = cJSON_Duplicate(g->values, true);
for (int i = 0; i < g->n_secrets; i++) {
cJSON_ReplaceItemInObjectCaseSensitive(v, g->secrets[i], cJSON_CreateString(""));
}
return v;
}
cJSON *cfg_get(const char *group) cJSON *cfg_get(const char *group)
{ {
if (!s_lock) { if (!s_lock) {
@@ -270,7 +302,7 @@ static esp_err_t config_get(httpd_req_t *req)
cJSON *all = cJSON_CreateObject(); cJSON *all = cJSON_CreateObject();
xSemaphoreTake(s_lock, portMAX_DELAY); xSemaphoreTake(s_lock, portMAX_DELAY);
for (int i = 0; i < s_group_n; i++) { for (int i = 0; i < s_group_n; i++) {
cJSON_AddItemToObject(all, s_groups[i].name, cJSON_Duplicate(s_groups[i].values, true)); cJSON_AddItemToObject(all, s_groups[i].name, public_values(&s_groups[i]));
} }
xSemaphoreGive(s_lock); xSemaphoreGive(s_lock);
esp_err_t err = web_send_json(req, all); esp_err_t err = web_send_json(req, all);
@@ -331,7 +363,18 @@ static esp_err_t config_post(httpd_req_t *req)
break; break;
} }
cand[i] = cJSON_Duplicate(g->values, true); cand[i] = cJSON_Duplicate(g->values, true);
ok = merge_known(cand[i], g->defaults, src, g->name, err, sizeof(err)); // Secrets: "" = unchanged (the API never returns the stored value), null = clear.
cJSON *in_g = cJSON_Duplicate(src, true);
for (int k = 0; k < g->n_secrets; k++) {
const cJSON *sv = cJSON_GetObjectItemCaseSensitive(in_g, g->secrets[k]);
if (cJSON_IsString(sv) && !sv->valuestring[0]) {
cJSON_DeleteItemFromObjectCaseSensitive(in_g, g->secrets[k]);
} else if (cJSON_IsNull(sv)) {
cJSON_ReplaceItemInObjectCaseSensitive(in_g, g->secrets[k], cJSON_CreateString(""));
}
}
ok = merge_known(cand[i], g->defaults, in_g, g->name, err, sizeof(err));
cJSON_Delete(in_g);
if (ok && g->validate) { if (ok && g->validate) {
char verr[96] = ""; char verr[96] = "";
ok = g->validate(cand[i], verr, sizeof(verr)); ok = g->validate(cand[i], verr, sizeof(verr));
+3
View File
@@ -18,6 +18,9 @@ esp_err_t cfg_override_defaults(const char *group, const char *json);
// Register a group. Stored values (NVS) are loaded immediately, merged over the defaults. // Register a group. Stored values (NVS) are loaded immediately, merged over the defaults.
esp_err_t cfg_register(const char *group, const char *defaults_json, esp_err_t cfg_register(const char *group, const char *defaults_json,
cfg_validate_cb_t validate, cfg_apply_cb_t apply); cfg_validate_cb_t validate, cfg_apply_cb_t apply);
// Write-only key (e.g. a password): GET /api/config returns "" for it; a POST with "" keeps the
// stored value, null clears it. cfg_get() still returns the real value. Call after cfg_register().
esp_err_t cfg_mark_secret(const char *group, const char *key);
// Copy of a group's current values; caller frees with cJSON_Delete. NULL if not registered. // Copy of a group's current values; caller frees with cJSON_Delete. NULL if not registered.
cJSON *cfg_get(const char *group); cJSON *cfg_get(const char *group);
// Firmware-side change of one number (e.g. aes67.session_ver): stored like a POST, no validation. // Firmware-side change of one number (e.g. aes67.session_ver): stored like a POST, no validation.
+1
View File
@@ -25,6 +25,7 @@ Reference devices for UI and defaults: Riedel Bolero (PTP status), Riedel Direct
## Core API ## Core API
- GET/POST /api/config (full JSON; POST validates, stores to NVS, applies live where possible, returns 4xx with message on bad values) - GET/POST /api/config (full JSON; POST validates, stores to NVS, applies live where possible, returns 4xx with message on bad values)
- Write-only keys (`cfg_mark_secret`, e.g. passwords/client secrets): GET returns "" for them; a POST with "" keeps the stored value, null clears it. Stored in NVS in plain text (NVS encryption is not enabled).
- GET /api/status -> {model?, fw?, power?, ip, aes67_ip, inet_ip, inet_vlan, mac, link, tx_packets, underruns, uptime_s, heap_free, psram_free, temps:[…], ptp:{…}, …project fields} - GET /api/status -> {model?, fw?, power?, ip, aes67_ip, inet_ip, inet_vlan, mac, link, tx_packets, underruns, uptime_s, heap_free, psram_free, temps:[…], ptp:{…}, …project fields}
- model, fw, power are optional; rows only appear when present (power e.g. "PoE" / "USB" if the board can detect it). - model, fw, power are optional; rows only appear when present (power e.g. "PoE" / "USB" if the board can detect it).
- GET /stream.sdp, POST /api/reboot, POST /api/log/test - GET /stream.sdp, POST /api/reboot, POST /api/log/test
+2 -1
View File
@@ -30,7 +30,8 @@ source (cspot Spotify Connect | HLS player) -> decode (Vorbis/AAC/MP3) -> SRC 44
- Firmware needs Spotify events: cspot connect, disconnect, play, pause. - Firmware needs Spotify events: cspot connect, disconnect, play, pause.
## Project config group ## Project config group
- source: {mode: spotify|hls|auto|tone|off, spotify_name, spotify_bitrate, hls_url, autoplay, gain_db, failover_delay_s, failover_on_pause} - source: {mode: spotify|hls|auto|tone|off, spotify_name, spotify_bitrate, hls_url, autoplay, gain_db, failover_delay_s, failover_on_pause, spotify_client_id, spotify_client_secret}
- spotify_client_id / spotify_client_secret: each user's own Spotify developer app (developer.spotify.com, Premium account), needed by the maintained cspot fork (philippe44/cspot) since Spotify's 2025 API restrictions. The secret is write-only (never returned by GET /api/config). spotify_state reports "no client credentials" while either is missing.
- Project status fields: active_source, source_state, spotify_state, buffer_ms - Project status fields: active_source, source_state, spotify_state, buffer_ms
- mode "tone": the core's 1 kHz / -18 dBFS test tone, phase-locked to PTP (commissioning, e.g. Riedel import tests). "off": silence. - mode "tone": the core's 1 kHz / -18 dBFS test tone, phase-locked to PTP (commissioning, e.g. Riedel import tests). "off": silence.
+5 -1
View File
@@ -95,7 +95,11 @@ static void player_status(cJSON *st)
s_src == SRC_SPOTIFY ? "not implemented" : s_playing ? "playing" : "buffering"; s_src == SRC_SPOTIFY ? "not implemented" : s_playing ? "playing" : "buffering";
cJSON_AddStringToObject(st, "active_source", SRC_NAME[s_src]); cJSON_AddStringToObject(st, "active_source", SRC_NAME[s_src]);
cJSON_AddStringToObject(st, "source_state", state); cJSON_AddStringToObject(st, "source_state", state);
cJSON_AddStringToObject(st, "spotify_state", "not implemented"); cJSON *src = cfg_get("source");
bool creds = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring[0] &&
cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring[0];
cJSON_Delete(src);
cJSON_AddStringToObject(st, "spotify_state", creds ? "not implemented" : "no client credentials");
cJSON_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE)); cJSON_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE));
} }
+6 -1
View File
@@ -7,7 +7,8 @@
static const char SOURCE_DEFAULTS[] = static const char SOURCE_DEFAULTS[] =
"{\"mode\":\"spotify\",\"spotify_name\":\"P4 AES67\",\"spotify_bitrate\":320,\"hls_url\":\"\"," "{\"mode\":\"spotify\",\"spotify_name\":\"P4 AES67\",\"spotify_bitrate\":320,\"hls_url\":\"\","
"\"autoplay\":false,\"gain_db\":0,\"failover_delay_s\":5,\"failover_on_pause\":false}"; "\"autoplay\":false,\"gain_db\":0,\"failover_delay_s\":5,\"failover_on_pause\":false,"
"\"spotify_client_id\":\"\",\"spotify_client_secret\":\"\"}";
static bool source_validate(const cJSON *g, char *err, size_t n) static bool source_validate(const cJSON *g, char *err, size_t n)
{ {
@@ -17,6 +18,8 @@ static bool source_validate(const cJSON *g, char *err, size_t n)
cfg_check_str(g, "spotify_name", 1, 63, err, n) && cfg_check_str(g, "spotify_name", 1, 63, err, n) &&
cfg_check_num_in(g, "spotify_bitrate", bitrates, 3, err, n) && cfg_check_num_in(g, "spotify_bitrate", bitrates, 3, err, n) &&
cfg_check_str(g, "hls_url", 0, 255, err, n) && cfg_check_str(g, "hls_url", 0, 255, err, n) &&
cfg_check_str(g, "spotify_client_id", 0, 64, err, n) &&
cfg_check_str(g, "spotify_client_secret", 0, 64, err, n) &&
cfg_check_num(g, "gain_db", -60, 12, err, n) && cfg_check_num(g, "gain_db", -60, 12, err, n) &&
cfg_check_int(g, "failover_delay_s", 0, 3600, err, n); cfg_check_int(g, "failover_delay_s", 0, 3600, err, n);
} }
@@ -31,4 +34,6 @@ void project_cfg_defaults(void)
void project_cfg_register(void) void project_cfg_register(void)
{ {
ESP_ERROR_CHECK(cfg_register("source", SOURCE_DEFAULTS, source_validate, player_apply)); ESP_ERROR_CHECK(cfg_register("source", SOURCE_DEFAULTS, source_validate, player_apply));
// Each user's own Spotify developer app (developer.spotify.com); the secret is write-only.
ESP_ERROR_CHECK(cfg_mark_secret("source", "spotify_client_secret"));
} }
+7 -1
View File
@@ -71,6 +71,11 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next
<label><span>Spotify device name</span><input name="source.spotify_name"></label> <label><span>Spotify device name</span><input name="source.spotify_name"></label>
<label><span>Spotify bitrate</span><select name="source.spotify_bitrate"> <label><span>Spotify bitrate</span><select name="source.spotify_bitrate">
<option>96</option><option>160</option><option>320</option></select></label> <option>96</option><option>160</option><option>320</option></select></label>
<label><span>Spotify client ID</span><input name="source.spotify_client_id" autocomplete="off"></label>
<label><span>Spotify client secret</span><input name="source.spotify_client_secret" type="password"
autocomplete="new-password" placeholder="stored; leave empty to keep"></label>
<small>Your own app from <a href="https://developer.spotify.com/dashboard" target="_blank">developer.spotify.com</a>
(Premium account). The secret is never shown again once saved.</small>
<label><span>Stream URL</span><input name="source.hls_url" type="url" placeholder="https://…/playlist.m3u8"></label> <label><span>Stream URL</span><input name="source.hls_url" type="url" placeholder="https://…/playlist.m3u8"></label>
<label><span>Autoplay stream on boot</span><input name="source.autoplay" type="checkbox"></label> <label><span>Autoplay stream on boot</span><input name="source.autoplay" type="checkbox"></label>
<label><span>Output gain (dB)</span><input name="source.gain_db" type="number" min="-60" max="12" step="0.5"></label> <label><span>Output gain (dB)</span><input name="source.gain_db" type="number" min="-60" max="12" step="0.5"></label>
@@ -208,7 +213,7 @@ const PROJECT = {
title: 'P4 AES67 Streamer', title: 'P4 AES67 Streamer',
def: { def: {
source:{mode:'spotify',spotify_name:'P4 AES67',spotify_bitrate:'320',hls_url:'',autoplay:false,gain_db:0, source:{mode:'spotify',spotify_name:'P4 AES67',spotify_bitrate:'320',hls_url:'',autoplay:false,gain_db:0,
failover_delay_s:5,failover_on_pause:false}, failover_delay_s:5,failover_on_pause:false,spotify_client_id:'',spotify_client_secret:''},
aes67:{name:'P4 AES67'}, aes67:{name:'P4 AES67'},
net:{hostname:'p4-aes67'} net:{hostname:'p4-aes67'}
}, },
@@ -216,6 +221,7 @@ const PROJECT = {
const m = f['source.mode'].value; const m = f['source.mode'].value;
const sp = m === 'spotify' || m === 'auto', hl = m === 'hls' || m === 'auto'; const sp = m === 'spotify' || m === 'auto', hl = m === 'hls' || m === 'auto';
f['source.spotify_name'].disabled = f['source.spotify_bitrate'].disabled = !sp; f['source.spotify_name'].disabled = f['source.spotify_bitrate'].disabled = !sp;
f['source.spotify_client_id'].disabled = f['source.spotify_client_secret'].disabled = !sp;
f['source.hls_url'].disabled = !hl; f['source.hls_url'].disabled = !hl;
f['source.autoplay'].disabled = m !== 'hls'; f['source.autoplay'].disabled = m !== 'hls';
f['source.failover_delay_s'].disabled = f['source.failover_on_pause'].disabled = m !== 'auto'; f['source.failover_delay_s'].disabled = f['source.failover_on_pause'].disabled = m !== 'auto';