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:
@@ -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));
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user