#include "net/nl_api.h" #include #include #include "cJSON.h" #define P_STATE "/api/v1/device/state" #define P_INFO "/api/v1/system/info" #define P_SETTINGS "/api/v1/system/settings" #define P_COMMAND "/api/v1/system/command" static const char *const POB_NAMES[] = {"last", "on", "off"}; static void reply(nl_api_response_t *r, int status, const char *json) { r->status = status; snprintf(r->body, sizeof r->body, "%s", json); } static void reply_error(nl_api_response_t *r, int status, const char *msg) { r->status = status; snprintf(r->body, sizeof r->body, "{\"error\":\"%s\"}", msg); } /* Takes ownership of obj. */ static void reply_json(nl_api_response_t *r, int status, cJSON *obj) { if (obj && cJSON_PrintPreallocated(obj, r->body, (int)sizeof r->body, 0)) r->status = status; else reply_error(r, 500, "response too large"); cJSON_Delete(obj); } /* Compares in time independent of where the first mismatch is. */ static bool token_ok(const char *given, const char *expected) { size_t lg = strlen(given), le = strlen(expected); unsigned diff = (lg != le || le == 0) ? 1u : 0u; for (size_t i = 0; i < NL_TOKEN_LEN - 1; i++) { unsigned char a = i < lg ? (unsigned char)given[i] : 0; unsigned char b = i < le ? (unsigned char)expected[i] : 0; diff |= (unsigned)(a ^ b); } return diff == 0; } /* 1 = present and valid, 0 = absent, -1 = present but invalid */ static int get_int(const cJSON *obj, const char *name, double lo, double hi, long *out) { const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, name); double d; if (!v) return 0; if (!cJSON_IsNumber(v)) return -1; d = v->valuedouble; /* M5: reject NaN and out-of-range values before casting to (long) - a huge or NaN * double is undefined behaviour to cast, so lo/hi must be checked first. */ if (d != d || d < lo || d > hi) return -1; if (d != (double)(long)d) return -1; *out = (long)d; return 1; } static int get_string(const cJSON *obj, const char *name, size_t min, size_t max, const char **out) { const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, name); size_t n; if (!v) return 0; if (!cJSON_IsString(v) || !v->valuestring) return -1; n = strlen(v->valuestring); if (n < min || n > max) return -1; *out = v->valuestring; return 1; } static cJSON *parse_body(const nl_http_request_t *req, nl_api_response_t *resp) { cJSON *j = req->body_len ? cJSON_ParseWithLength(req->body, req->body_len) : NULL; if (!j || !cJSON_IsObject(j)) { cJSON_Delete(j); reply_error(resp, 400, "invalid json"); return NULL; } return j; } static cJSON *state_json(const nl_api_ctx_t *ctx) { nl_light_state_t st; nl_settings_t s; cJSON *o, *c, *cap; int ct[2]; ctx->get_state(&st); ctx->get_settings(&s); o = cJSON_CreateObject(); cJSON_AddBoolToObject(o, "on", st.on); cJSON_AddNumberToObject(o, "brightness", st.brightness); cJSON_AddStringToObject(o, "mode", st.mode == NL_MODE_COLOR ? "color" : "white"); c = cJSON_AddObjectToObject(o, "color"); cJSON_AddNumberToObject(c, "r", st.r); cJSON_AddNumberToObject(c, "g", st.g); cJSON_AddNumberToObject(c, "b", st.b); cJSON_AddNumberToObject(o, "color_temp", st.ct); cJSON_AddStringToObject(o, "power_on_behaviour", POB_NAMES[s.power_on_behaviour <= NL_POB_OFF ? s.power_on_behaviour : NL_POB_LAST]); cJSON_AddNumberToObject(o, "fade_in", s.fade_in_ms); cJSON_AddNumberToObject(o, "fade_out", s.fade_out_ms); /* Named "transition_default", not "transition": the latter is already the per-call field * above the settings section of this same request/response and a second field with the * same key in one JSON object would collide with it. */ cJSON_AddNumberToObject(o, "transition_default", s.transition_ms); cJSON_AddBoolToObject(o, "wake_on_change", s.wake_on_change != 0); cap = cJSON_AddObjectToObject(o, "capabilities"); cJSON_AddBoolToObject(cap, "color", ctx->board->has_rgb); ct[0] = ctx->board->ct_min; ct[1] = ctx->board->ct_max; cJSON_AddItemToObject(cap, "color_temp", cJSON_CreateIntArray(ct, 2)); return o; } static void handle_post_state(const nl_api_ctx_t *ctx, const nl_http_request_t *req, nl_api_response_t *resp) { cJSON *j = parse_body(req, resp); const cJSON *it; nl_light_change_t ch; nl_settings_t s; const char *err = NULL; bool light = false, settings = false; long v, r, g, b; if (!j) return; memset(&ch, 0, sizeof ch); ctx->get_settings(&s); it = cJSON_GetObjectItemCaseSensitive(j, "on"); if (it) { if (!cJSON_IsBool(it)) { err = "invalid on"; goto out; } ch.has_on = true; ch.on = cJSON_IsTrue(it) != 0; light = true; } switch (get_int(j, "brightness", 1, 100, &v)) { case -1: err = "invalid brightness"; goto out; case 1: ch.has_brightness = true; ch.brightness = (uint8_t)v; light = true; break; default: break; } it = cJSON_GetObjectItemCaseSensitive(j, "color"); if (it) { if (!cJSON_IsObject(it) || get_int(it, "r", 0, 255, &r) != 1 || get_int(it, "g", 0, 255, &g) != 1 || get_int(it, "b", 0, 255, &b) != 1) { err = "invalid color"; goto out; } ch.has_color = true; ch.r = (uint8_t)r; ch.g = (uint8_t)g; ch.b = (uint8_t)b; light = true; } switch (get_int(j, "color_temp", 1000, 20000, &v)) { case -1: err = "invalid color_temp"; goto out; case 1: ch.has_ct = true; ch.ct = (uint16_t)v; light = true; break; default: break; } switch (get_int(j, "transition", 0, NL_MAX_TRANSITION_MS, &v)) { case -1: err = "invalid transition"; goto out; case 1: ch.has_transition = true; ch.transition_ms = (uint32_t)v; break; default: break; } it = cJSON_GetObjectItemCaseSensitive(j, "power_on_behaviour"); if (it) { int idx = -1; for (int i = 0; i < 3 && cJSON_IsString(it); i++) if (strcmp(it->valuestring, POB_NAMES[i]) == 0) idx = i; if (idx < 0) { err = "invalid power_on_behaviour"; goto out; } s.power_on_behaviour = (uint8_t)idx; settings = true; } switch (get_int(j, "fade_in", 0, NL_MAX_FADE_MS, &v)) { case -1: err = "invalid fade_in"; goto out; case 1: s.fade_in_ms = (uint16_t)v; settings = true; break; default: break; } switch (get_int(j, "fade_out", 0, NL_MAX_FADE_MS, &v)) { case -1: err = "invalid fade_out"; goto out; case 1: s.fade_out_ms = (uint16_t)v; settings = true; break; default: break; } switch (get_int(j, "transition_default", 0, NL_MAX_TRANSITION_MS, &v)) { case -1: err = "invalid transition_default"; goto out; case 1: s.transition_ms = (uint16_t)v; settings = true; break; default: break; } it = cJSON_GetObjectItemCaseSensitive(j, "wake_on_change"); if (it) { if (!cJSON_IsBool(it)) { err = "invalid wake_on_change"; goto out; } s.wake_on_change = cJSON_IsTrue(it) ? 1u : 0u; settings = true; } if (light) { switch (ctx->apply(&ch)) { case NL_CTRL_OK: break; case NL_CTRL_ERR_NO_RGB: err = "color not supported"; goto out; case NL_CTRL_ERR_COLOR_AND_CT: err = "color and color_temp are exclusive"; goto out; case NL_CTRL_ERR_BRIGHTNESS: err = "invalid brightness"; goto out; default: err = "invalid transition"; goto out; } } if (settings && ctx->save_settings(&s) != 0) { cJSON_Delete(j); reply_error(resp, 500, "save failed"); return; } out: cJSON_Delete(j); if (err) reply_error(resp, 400, err); else reply_json(resp, 200, state_json(ctx)); } static void handle_info(const nl_api_ctx_t *ctx, nl_api_response_t *resp) { nl_sys_info_t in; cJSON *o, *w; memset(&in, 0, sizeof in); ctx->get_info(&in); o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "device_type", ctx->board->id); cJSON_AddStringToObject(o, "product_name", ctx->board->name); cJSON_AddStringToObject(o, "fw_version", ctx->fw_version); cJSON_AddStringToObject(o, "build_date", ctx->build_date); cJSON_AddStringToObject(o, "mac", in.mac); cJSON_AddStringToObject(o, "name", in.name); w = cJSON_AddObjectToObject(o, "wifi"); cJSON_AddStringToObject(w, "mode", in.wifi_mode ? in.wifi_mode : "sta"); cJSON_AddStringToObject(w, "ssid", in.ssid); cJSON_AddNumberToObject(w, "rssi", in.rssi); cJSON_AddStringToObject(w, "ip", in.ip); if (in.sta_ip[0]) cJSON_AddStringToObject(w, "sta_ip", in.sta_ip); else cJSON_AddNullToObject(w, "sta_ip"); if (in.wifi_error) cJSON_AddStringToObject(w, "error", in.wifi_error); else cJSON_AddNullToObject(w, "error"); cJSON_AddNumberToObject(o, "uptime", in.uptime_s); cJSON_AddNumberToObject(o, "free_heap", in.free_heap); if (in.last_ota_error) cJSON_AddStringToObject(o, "last_ota_error", in.last_ota_error); else cJSON_AddNullToObject(o, "last_ota_error"); reply_json(resp, 200, o); } static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp) { nl_settings_t s; cJSON *o, *w; ctx->get_settings(&s); o = cJSON_CreateObject(); cJSON_AddStringToObject(o, "name", s.name); w = cJSON_AddObjectToObject(o, "wifi"); cJSON_AddStringToObject(w, "ssid", s.wifi_ssid); /* "ssid" alone can't be told apart from "kein WLAN konfiguriert": ein leerer * gespeicherter Datensatz laesst app_main.c beim Boot auf die einkompilierten * NINLIGHT_WIFI_SSID/-PASSWORD zurueckfallen (siehe dort), und die Lampe kommt * trotzdem zuverlaessig ins WLAN. Wer nur "ssid" sieht, kann das nicht * unterscheiden - "source" macht additiv sichtbar, woher die Zugangsdaten * kommen, ohne die Bedeutung von "ssid" zu aendern. */ cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled"); cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0); reply_json(resp, 200, o); } static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t *req, nl_api_response_t *resp) { cJSON *j = parse_body(req, resp); const char *type = NULL, *a = NULL, *b = NULL, *err = NULL; nl_settings_t s; int pr = 0; if (!j) return; ctx->get_settings(&s); if (get_string(j, "type", 1, 32, &type) != 1) { err = "invalid type"; } else if (strcmp(type, "wifi") == 0) { if (get_string(j, "ssid", 1, NL_SSID_LEN - 1, &a) != 1) { err = "invalid ssid"; } else if ((pr = get_string(j, "password", 0, NL_PASS_LEN - 1, &b)) < 0) { err = "invalid password"; } else if (pr == 1 && strlen(b) != 0 && strlen(b) < 8) { /* M2: WPA2 passwords must be empty (open network) or 8..64 chars */ err = "invalid password"; } else { if (ctx->wifi_configure(a, pr == 1 ? b : "") != 0) reply_error(resp, 503, "wifi busy"); else reply(resp, 202, "{\"status\":\"connecting\"}"); cJSON_Delete(j); return; } } else if (strcmp(type, "device_name") == 0) { if (get_string(j, "name", 1, NL_NAME_LEN - 1, &a) != 1) err = "invalid name"; else strcpy(s.name, a); } else if (strcmp(type, "token") == 0) { if (get_string(j, "token", 8, NL_TOKEN_LEN - 1, &a) != 1) err = "invalid token"; else strcpy(s.token, a); } else if (strcmp(type, "auth") == 0) { const cJSON *all = cJSON_GetObjectItemCaseSensitive(j, "all"); if (!cJSON_IsBool(all)) err = "invalid all"; else s.auth_all = cJSON_IsTrue(all) ? 1 : 0; } else { err = "invalid type"; } cJSON_Delete(j); if (err) reply_error(resp, 400, err); else if (ctx->save_settings(&s) != 0) reply_error(resp, 500, "save failed"); else reply(resp, 200, "{\"ok\":true}"); } static void handle_command(const nl_api_ctx_t *ctx, const nl_http_request_t *req, nl_api_response_t *resp) { cJSON *j = parse_body(req, resp); const cJSON *cmd; const char *type = NULL, *url = NULL; if (!j) return; cmd = cJSON_GetObjectItemCaseSensitive(j, "command"); if (!cJSON_IsObject(cmd) || get_string(cmd, "type", 1, 32, &type) != 1) { reply_error(resp, 400, "invalid command"); } else if (strcmp(type, "fw_update") == 0) { if (get_string(cmd, "url", 8, 255, &url) != 1 || strncmp(url, "http://", 7) != 0) reply_error(resp, 400, "invalid url"); else if (ctx->fw_update(url) != 0) { /* fw_update() itself can genuinely fail now (busy, or a rejected URL) - 409 with the * reason, not a blanket "not implemented". */ /* Do not reuse last_ota_error for this message: it belongs to the running or last * update, not to the request we are rejecting right now. */ reply_error(resp, 409, "an update is already running"); } else reply(resp, 202, "{\"status\":\"updating\"}"); } else if (strcmp(type, "reboot") == 0) { reply(resp, 200, "{\"ok\":true}"); resp->after = NL_API_AFTER_REBOOT; } else if (strcmp(type, "factory_reset") == 0) { ctx->factory_reset(); reply(resp, 200, "{\"ok\":true}"); } else if (strcmp(type, "ping") == 0) { ctx->ping(); reply(resp, 200, "{\"ok\":true}"); } else { reply_error(resp, 400, "invalid command"); } cJSON_Delete(j); } void nl_api_handle(const nl_api_ctx_t *ctx, const nl_http_request_t *req, nl_api_response_t *resp) { nl_settings_t s; bool authed; memset(resp, 0, sizeof *resp); ctx->get_settings(&s); /* M4: a truncated Authorization value must never accidentally match a shorter token. */ authed = ctx->open_access() || (!req->auth_too_long && token_ok(req->auth, s.token)); if (strcmp(req->path, P_STATE) == 0) { if (s.auth_all && !authed) reply_error(resp, 401, "unauthorized"); else if (req->method == NL_HTTP_GET) reply_json(resp, 200, state_json(ctx)); else if (req->method == NL_HTTP_POST) handle_post_state(ctx, req, resp); else reply_error(resp, 405, "method not allowed"); } else if (strcmp(req->path, P_INFO) == 0) { if (req->method == NL_HTTP_GET) handle_info(ctx, resp); else reply_error(resp, 405, "method not allowed"); } else if (strcmp(req->path, P_SETTINGS) == 0) { if (!authed) reply_error(resp, 401, "unauthorized"); else if (req->method == NL_HTTP_GET) handle_get_settings(ctx, resp); else if (req->method == NL_HTTP_PUT) handle_put_settings(ctx, req, resp); else reply_error(resp, 405, "method not allowed"); } else if (strcmp(req->path, P_COMMAND) == 0) { if (!authed) reply_error(resp, 401, "unauthorized"); else if (req->method == NL_HTTP_PUT) handle_command(ctx, req, resp); else reply_error(resp, 405, "method not allowed"); } else { reply_error(resp, 404, "not found"); } }