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"
#define MAX_GROUPS 12
#define MAX_SECRETS 4
#define MAX_BODY 8192
#define NVS_NAMESPACE "cfg"
@@ -24,6 +25,8 @@ typedef struct {
cJSON *values;
cfg_validate_cb_t validate;
cfg_apply_cb_t apply;
char secrets[MAX_SECRETS][24]; // write-only keys
int n_secrets;
} cfg_group_t;
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;
}
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)
{
if (!s_lock) {
@@ -270,7 +302,7 @@ static esp_err_t config_get(httpd_req_t *req)
cJSON *all = cJSON_CreateObject();
xSemaphoreTake(s_lock, portMAX_DELAY);
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);
esp_err_t err = web_send_json(req, all);
@@ -331,7 +363,18 @@ static esp_err_t config_post(httpd_req_t *req)
break;
}
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) {
char verr[96] = "";
ok = g->validate(cand[i], verr, sizeof(verr));