diff --git a/mongoose.h b/mongoose.h index 081446fe..279fbd6b 100644 --- a/mongoose.h +++ b/mongoose.h @@ -3152,7 +3152,13 @@ void mg_rpc_list(struct mg_rpc_req *r); -enum mg_val_type { MG_VAL_INT, MG_VAL_BOOL, MG_VAL_DBL, MG_VAL_STR, MG_VAL_FN }; +enum mg_val_type { + MG_VAL_INT, + MG_VAL_BOOL, + MG_VAL_DBL, + MG_VAL_STR, + MG_VAL_RAW +}; union mg_val { int i; @@ -3262,9 +3268,7 @@ static inline struct mg_field *mg_dash_find_field(struct mg_field *fields, static inline size_t mg_print_field(mg_pfn_t fn, void *arg, va_list *ap) { struct mg_field *f = va_arg(*ap, struct mg_field *); - // union mg_val val = {0}; size_t n = 0; - // if (f->get) f->get(&val); n += mg_xprintf(fn, arg, "%m:", MG_ESC(f->name)); if (f->type == MG_VAL_BOOL) { n += mg_xprintf(fn, arg, "%s", *(bool *) f->value ? "true" : "false"); @@ -3274,6 +3278,8 @@ static inline size_t mg_print_field(mg_pfn_t fn, void *arg, va_list *ap) { n += mg_xprintf(fn, arg, "%.2f", *(double *) f->value); } else if (f->type == MG_VAL_STR) { n += mg_xprintf(fn, arg, "%m", MG_ESC(f->value)); + } else if (f->type == MG_VAL_RAW) { + n += mg_xprintf(fn, arg, "%s", f->value); } else { n += mg_xprintf(fn, arg, "null"); } @@ -3347,6 +3353,9 @@ static inline int mg_dash_parse_field(struct mg_str json, struct mg_field *f) { ok = mg_json_get_num(json, json_path, (double *) f->value); } else if (f->type == MG_VAL_STR) { ok = mg_json_unescape(json, json_path, (char *) f->value, f->value_size); + } else if (f->type == MG_VAL_RAW) { + ok = mg_snprintf((char *) f->value, f->value_size, "%.*s", json.len, + json.buf) == json.len; } return ok; } diff --git a/resources/dashboard.js b/resources/dashboard.js index f3c39c61..40ca9d60 100644 --- a/resources/dashboard.js +++ b/resources/dashboard.js @@ -63,7 +63,6 @@ const isMock = !!window.frameElement || (window !== window.top); const rpc = isMock ? null : Rpc('api/websocket'); const settings = {data: {}, edits: {}, debug: true}; - const substitutions = {}; const userhandlers = {}; // User event handlers const fromPath = (path, value) => path.split('.').reverse().reduce((acc, k) => ({ [k]: acc }), value); @@ -138,7 +137,7 @@ return false; }; - function scan_bind(el, key, context) { + function handle_bind(el, key, context) { if (!has(context, key)) { console.error('EVAL', key, context); return; } const v = get(context, key) // console.log(2, key, v); @@ -173,37 +172,55 @@ rescan(); }; - function scan_save(el, keys) { + function handle_save(el, keys) { el.disabled = !edited(keys); if (!el.bound) el.addEventListener("click", ev => save(keys)), el.bound = true; }; - function scan_cancel(el, keys) { + function handle_cancel(el, keys) { if (!el.bound) el.addEventListener("click", ev => cancel(keys)), el.bound = true; el.disabled = !edited(keys); }; - function createSubstitutions() { - substitutions.nodes = [], substitutions.attributes = [], substitutions.bound = []; - document.querySelectorAll("*").forEach(function (el) { - if (el.tagName === "SCRIPT" || el.tagName === "STYLE") return; - for (const node of el.childNodes) { - if (node.nodeType === Node.TEXT_NODE && node.nodeValue.includes('${')) { - node.originalValue = node.nodeValue; - substitutions.nodes.push(node); - } - } - for (const attr of el.attributes) { - if (attr.value.includes('${')) { - attr.originalValue = attr.value; - substitutions.attributes.push(attr); - } - } - for (const [key, val] of Object.entries(el.dataset)) { - if (['bind', 'save', 'cancel'].includes(key)) substitutions.bound.push(el); - } + function handle_repeat(el, key, context) { + if (!has(context, key)) { console.error('EVAL', key, context); return; } + if (!el.orig) el.orig = el.children[0]; + const v = get(context, key), v2 = JSON.stringify(v); + // if (el.v2 === v2) return; + const frag = document.createDocumentFragment(); + const it = el.dataset.iterator ?? '__obj'; + v.forEach(function (item, index) { + const child = el.orig.cloneNode(true); + const ctx = { ...context, [it]: item }; + process(child, ctx); + frag.appendChild(child); + }); + el.replaceChildren(frag); + // el.v2 = v2; + }; + + function handle_upload(el) { + if (el.bound) return; + el.bound = true; + el.addEventListener("change", function (ev) { + const f = ev.target.files[0]; + if (!f) return; + const reader = new FileReader(); + reader.readAsArrayBuffer(f); + reader.onload = function () { + const url = `files/${encodeURIComponent(f.name)}`; + let begin = Date.now(), body = reader.result, ok = false; + // console.log(2, url, body); + fetch(url, { method: 'POST', body }) + .then(r => { ok = r.ok; }) + .catch(() => true) + .finally(() => { + console.log('UPLOAD finshed in ', Date.now() - begin, 'ms'); + }); + }; + ev.target.value = ''; + ev.preventDefault(); }); - // console.log(substitutions); }; function substituteExpressions(text, context) { @@ -215,27 +232,46 @@ return typeof text == "string" ? text.replace(/\${(.+?)}/g, f) : text; } - function realrescan() { - const context = JSON.parse(JSON.stringify(settings.data)); - apply(context, settings.edits); - if (!substitutions.nodes) createSubstitutions(); - substitutions.attributes.forEach(attr => { - const v = substituteExpressions(attr.originalValue, context); - if (v !== attr.value) attr.value = v; - }); - substitutions.nodes.forEach(node => { - const v = substituteExpressions(node.originalValue, context); - if (v !== node.nodeValue) node.nodeValue = v; - }); - substitutions.bound.forEach(el => { + function process(el, context) { + el.querySelectorAll("*").forEach(function (el) { + if (el.tagName === "SCRIPT" || el.tagName === "STYLE") return; + for (const node of el.childNodes) { + if (node.nodeType === Node.TEXT_NODE && + (node.originalValue || node.nodeValue.includes('${'))) { + if (!node.originalValue) node.originalValue = node.nodeValue; + const v = substituteExpressions(node.originalValue, context); + if (v !== node.nodeValue) node.nodeValue = v; + } + } + for (const attr of el.attributes) { + const cv = decodeURIComponent(attr.value || ''); + if (attr.originalValue || cv.includes('${')) { + if (!attr.originalValue) attr.originalValue = cv; + const v = substituteExpressions(attr.originalValue, context); + if (v !== attr.value) attr.value = v; + } + } + const datakeys = { bind: 1, save: 1, cancel: 1, repeat: 1, upload: 1 }; for (const [key, val] of Object.entries(el.dataset)) { - if (key == 'bind') scan_bind(el, val, context); - if (key == 'save') scan_save(el, val.split(/\s*,\s*/)); - if (key == 'cancel') scan_cancel(el, val.split(/\s*,\s*/)); + if (!datakeys[key]) continue; + if (key == 'bind') handle_bind(el, val, context); + if (key == 'save') handle_save(el, val.split(/\s*,\s*/)); + if (key == 'cancel') handle_cancel(el, val.split(/\s*,\s*/)); + if (key == 'repeat') handle_repeat(el, val, context); + if (key == 'upload') handle_upload(el); } }); }; + function realrescan() { + // const t0 = performance.now(); + const context = JSON.parse(JSON.stringify(settings.data)); + apply(context, settings.edits); + process(document, context); + // const t1 = performance.now(); + // console.log(`Took2 ${t1 - t0} ms`); + }; + function rescan() { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", realrescan, { once: true }); @@ -250,7 +286,8 @@ if (isMock) { return delay(750, true).then(val => console.log('response', name)); } else { - return rpc.call(name, args).catch(err => console.log('CALL FAILED', name, args, err)); + return rpc.call(name, args) + .catch(err => console.log('CALL FAILED', name, args, err)); } }; @@ -291,5 +328,5 @@ } }; - global.Dashboard = { init, call, on}; + global.Dashboard = { init, call, on }; })(window); diff --git a/src/dash.h b/src/dash.h index 357c368c..9d491162 100644 --- a/src/dash.h +++ b/src/dash.h @@ -7,7 +7,13 @@ #include "str.h" #include "ws.h" -enum mg_val_type { MG_VAL_INT, MG_VAL_BOOL, MG_VAL_DBL, MG_VAL_STR, MG_VAL_FN }; +enum mg_val_type { + MG_VAL_INT, + MG_VAL_BOOL, + MG_VAL_DBL, + MG_VAL_STR, + MG_VAL_RAW, +}; union mg_val { int i; @@ -117,9 +123,7 @@ static inline struct mg_field *mg_dash_find_field(struct mg_field *fields, static inline size_t mg_print_field(mg_pfn_t fn, void *arg, va_list *ap) { struct mg_field *f = va_arg(*ap, struct mg_field *); - // union mg_val val = {0}; size_t n = 0; - // if (f->get) f->get(&val); n += mg_xprintf(fn, arg, "%m:", MG_ESC(f->name)); if (f->type == MG_VAL_BOOL) { n += mg_xprintf(fn, arg, "%s", *(bool *) f->value ? "true" : "false"); @@ -129,6 +133,8 @@ static inline size_t mg_print_field(mg_pfn_t fn, void *arg, va_list *ap) { n += mg_xprintf(fn, arg, "%.2f", *(double *) f->value); } else if (f->type == MG_VAL_STR) { n += mg_xprintf(fn, arg, "%m", MG_ESC(f->value)); + } else if (f->type == MG_VAL_RAW) { + n += mg_xprintf(fn, arg, "%s", f->value); } else { n += mg_xprintf(fn, arg, "null"); } @@ -202,6 +208,9 @@ static inline int mg_dash_parse_field(struct mg_str json, struct mg_field *f) { ok = mg_json_get_num(json, json_path, (double *) f->value); } else if (f->type == MG_VAL_STR) { ok = mg_json_unescape(json, json_path, (char *) f->value, f->value_size); + } else if (f->type == MG_VAL_RAW) { + ok = mg_snprintf((char *) f->value, f->value_size, "%.*s", json.len, + json.buf) == json.len; } return ok; } diff --git a/test/unit_test.c b/test/unit_test.c index c3bc4650..6c3e7918 100644 --- a/test/unit_test.c +++ b/test/unit_test.c @@ -1970,6 +1970,15 @@ static void test_http_parse(void) { ASSERT(mg_http_parse(s, strlen(s), &hm) == (int) strlen(s)); ASSERT(mg_strcmp(hm.headers[0].value, mg_str("e")) == 0); } + + { + // Test that query-string gets stripped + struct mg_http_message hm; + const char *s = "GET /foo?bar HTTP/1.0\n\n"; + ASSERT(mg_http_parse(s, strlen(s), &hm) == (int) strlen(s)); + ASSERT(mg_strcmp(hm.uri, mg_str("/foo")) == 0); + ASSERT(mg_strcmp(hm.query, mg_str("bar")) == 0); + } } static void ehr(struct mg_connection *c, int ev, void *ev_data) { diff --git a/tutorials/device-dashboard/Makefile b/tutorials/device-dashboard/Makefile index ce28b833..e3646848 100644 --- a/tutorials/device-dashboard/Makefile +++ b/tutorials/device-dashboard/Makefile @@ -16,7 +16,7 @@ $(PROG): $(SOURCES) $(MONGOOSE_DIR)/mongoose.h # Generate a single, self-contained dashboard.html from the UI in web_root/ dashboard.html: $(wildcard web_root/*) - monolith web_root/index.html > $@ + monolith web_root/index.html -o $@ # Create an embedded filesystem containing compressed dashboard.html dashboard.html.c: dashboard.html @@ -25,7 +25,7 @@ dashboard.html.c: dashboard.html # For development: rebuild and restart if any of the sources change watch: - watchexec -r -w main.c -w mongoose_dashboard.c -w $(MONGOOSE_DIR)/mongoose.h -w web_root -e c,h,html,css,js -- make + watchexec -r -w main.c -w mongoose_dashboard.c -w $(MONGOOSE_DIR)/mongoose.h -w web_root -e c,h,html,css,js,svg,png -- make clean: rm -rf $(PROG) *.exe *.obj *.dSYM mongoose dashboard.* diff --git a/tutorials/device-dashboard/mongoose_dashboard.c b/tutorials/device-dashboard/mongoose_dashboard.c index 4c09243b..3a4354d6 100644 --- a/tutorials/device-dashboard/mongoose_dashboard.c +++ b/tutorials/device-dashboard/mongoose_dashboard.c @@ -87,7 +87,7 @@ struct point { static char s_chart1_data[NUM_POINTS * POINT_SIZE + 2 + 1]; static struct mg_field fields_chart1[] = { - {"data", MG_VAL_STR, s_chart1_data, sizeof(s_chart1_data)}, + {"data", MG_VAL_RAW, s_chart1_data, sizeof(s_chart1_data)}, {NULL, 0, NULL, 0}, }; @@ -107,19 +107,50 @@ void sync_chart1(bool is_write) { len += mg_snprintf(s_chart1_data + len, sizeof(s_chart1_data) - len, "]"); } -static int s_num_log_files = 0; -static int s_log_file_index = 0; -static char s_log_file_name[100]; -static struct mg_field fields_log_files[] = { - {"size", MG_VAL_INT, &s_num_log_files, sizeof(s_num_log_files)}, - {"index", MG_VAL_INT, &s_log_file_index, sizeof(s_log_file_index)}, - {"name", MG_VAL_STR, &s_log_file_name, sizeof(s_log_file_name)}, +struct file { + struct file *next; + char *name; + size_t size; +} *s_file_list; + +static void file_add(struct mg_str name, size_t size) { + struct file *f = mg_calloc(1, sizeof(*f)); + f->name = mg_strdup(name).buf; + f->size = size; + f->next = s_file_list; + s_file_list = f; +} + +static void file_del(struct mg_str name) { + struct file **head, *f; + for (head = &s_file_list, f = *head; f; head = &(*head)->next, f = *head) { + if (mg_strcmp(mg_str(f->name), name) == 0) { + MG_INFO(("Deleting %s", f->name)); + *head = f->next; + mg_free(f->name); + mg_free(f); + return; + } + } +} + +// Files array: [{"name": "foo.txt", "size": 1234}] +static char s_files[1024]; +static struct mg_field fields_files[] = { + {"data", MG_VAL_RAW, s_files, sizeof(s_files)}, {NULL, 0, NULL, 0}, }; -static void sync_log_files(bool is_write) { +static void sync_files(bool is_write) { + size_t len = 0; + struct file *f; if (is_write) return; - mg_log_level = s_log_level; - s_log_level = mg_log_level; + len += mg_snprintf(s_files + len, sizeof(s_files) - len, "["); + for (f = s_file_list; f; f = f->next) { + len += mg_snprintf(s_files + len, sizeof(s_files) - len, "%s{%m:%m,%m:%u}", + len > 1 ? "," : "", MG_ESC("name"), MG_ESC(f->name), + MG_ESC("size"), f->size); + } + len += mg_snprintf(s_files + len, sizeof(s_files) - len, "]"); } static struct mg_field_set field_sets[] = { @@ -127,19 +158,82 @@ static struct mg_field_set field_sets[] = { {"metrics", fields_metrics, sync_metrics, 0, 0}, {"settings", fields_settings, sync_settings, 0, 0}, {"chart1", fields_chart1, sync_chart1, 0, 0}, - {"log_files", fields_log_files, sync_log_files, 0, 0}, + {"files", fields_files, sync_files, 0, 0}, {0, 0, 0, 0, 0}, }; static struct mg_dash s_dashboard = {field_sets, NULL}; -static void log_ev_handler(struct mg_connection *c, int ev, void *ev_data) { - if (ev == MG_EV_HTTP_MSG) { - mg_http_serve_file(c, ev_data, "Makefile", NULL); +struct upload_state { + char marker; // Tells that we're a file upload connection + size_t expected; // POST data length, bytes + size_t received; // Already received bytes + void *ctx; // OTA context +}; + +static void file_ev_handler(struct mg_connection *c, int ev, void *ev_data) { + struct upload_state *us = (struct upload_state *) c->data; + if ((ev == MG_EV_HTTP_HDRS || ev == MG_EV_HTTP_MSG) && us->marker == 0) { + struct mg_http_message *hm = (struct mg_http_message *) ev_data; + if (hm->uri.len > 7) hm->uri.len -= 7, hm->uri.buf += 7; // Strip /files/ + if (sizeof(*us) > sizeof(c->data)) { + mg_error(c, + "FAILURE: sizeof(c->data) == %lu, need %lu." + " Set #define MG_DATA_SIZE XXX", + sizeof(c->data), sizeof(*us)); + } else if (mg_strcmp(hm->query, mg_str("action=delete")) == 0) { + // Query string ?action=delete - file deletion + file_del(hm->uri); + mg_dash_send_change(c->mgr, &s_dashboard, "files"); + mg_http_reply(c, 200, NULL, "true"); + } else if (mg_path_is_sane(hm->uri) == false) { + // Bad file name, return error. Protect from traversal, etc + mg_http_reply(c, 400, NULL, "Bad name"); + } else if (mg_match(hm->method, mg_str("POST"), NULL) || + mg_match(hm->method, mg_str("PUT"), NULL)) { + // File upload + char path[128]; + mg_snprintf(path, sizeof(path), "/tmp/%.*s", hm->uri.len, hm->uri.buf); + us->ctx = fopen(path, "wb+"); + if (us->ctx == NULL) { + mg_http_reply(c, 500, NULL, "File upload error %d\n", errno); + } else { + file_add(hm->uri, hm->body.len); + MG_DEBUG(("Starting upload, [%.*s] %lu", hm->uri.len, hm->uri.buf, hm->body.len)); + us->marker = 'U'; + us->expected = hm->body.len; + us->received = 0; + mg_iobuf_del(&c->recv, 0, hm->head.len); + c->pfn = NULL; + c->fn = file_ev_handler; + mg_call(c, MG_EV_READ, &c->recv.len); + } + } else { + // Serve file + char path[128]; + mg_snprintf(path, sizeof(path), "/tmp/%.*s", hm->uri.len, hm->uri.buf); + mg_http_serve_file(c, ev_data, path, NULL); + } + } else if (ev == MG_EV_READ && us->marker == 'U') { + // Write uploaded data + MG_DEBUG(("Uploading.. recv.len=%u", c->recv.len)); + us->received += c->recv.len; + fwrite(c->recv.buf, 1, c->recv.len, us->ctx); + c->recv.len = 0; + if (us->received >= us->expected) { + fclose(us->ctx); + MG_DEBUG(("Uploaded %lu", us->expected)); + mg_http_reply(c, 200, NULL, "%lu uploaded\n", us->expected); + mg_dash_send_change(c->mgr, &s_dashboard, "files"); + memset(us, 0, sizeof(*us)); + c->is_draining = 1; + } } } void mg_dash_init(struct mg_mgr *mgr) { - MG_DASH_REGISTER_CUSTOM_HANDLER(&s_dashboard, "/logs/#", log_ev_handler); + file_add(mg_str("device-config.json"), 1234); + file_add(mg_str("device-log-2026-04-25.txt"), 1327854); + MG_DASH_REGISTER_CUSTOM_HANDLER(&s_dashboard, "/files/#", file_ev_handler); mg_http_listen(mgr, HTTP_ADDR, mg_dash_ev_handler, &s_dashboard); } diff --git a/tutorials/device-dashboard/web_root/index.html b/tutorials/device-dashboard/web_root/index.html index 578bc356..2575f708 100644 --- a/tutorials/device-dashboard/web_root/index.html +++ b/tutorials/device-dashboard/web_root/index.html @@ -4,15 +4,17 @@ My Dashboard - + + @@ -55,22 +57,21 @@ Firmware Update - + - - - - + + + - Event Log + File Manager - -
live
+
+
-