Compare commits
4 Commits
32db18b0d6
...
68817feeec
Author | SHA1 | Date | |
---|---|---|---|
68817feeec | |||
97661e2ca2 | |||
72def5ae6d | |||
e638b155a1 |
@ -10,7 +10,7 @@ let gSessionIndex = 0;
|
||||
* @returns
|
||||
*/
|
||||
function makeSessionId() {
|
||||
return (gSessionIndex++).toString();
|
||||
return 'session_' + (gSessionIndex++).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -172,7 +172,7 @@ async function socket(request, response, client) {
|
||||
0x1
|
||||
);
|
||||
} else {
|
||||
process = await core.getSessionProcessBlob(
|
||||
process = await core.getProcessBlob(
|
||||
blobId,
|
||||
sessionId,
|
||||
options
|
||||
|
196
core/core.js
196
core/core.js
@ -87,10 +87,6 @@ const k_global_settings = {
|
||||
},
|
||||
};
|
||||
|
||||
let gGlobalSettings = {
|
||||
index: '/~core/apps/',
|
||||
};
|
||||
|
||||
let kPingInterval = 60 * 1000;
|
||||
|
||||
/**
|
||||
@ -262,23 +258,6 @@ function postMessageInternal(from, to, message) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODOC
|
||||
* @param {*} blobId
|
||||
* @param {*} session
|
||||
* @param {*} options
|
||||
* @returns
|
||||
*/
|
||||
async function getSessionProcessBlob(blobId, session, options) {
|
||||
let actualOptions = {timeout: kPingInterval};
|
||||
if (options) {
|
||||
for (let i in options) {
|
||||
actualOptions[i] = options[i];
|
||||
}
|
||||
}
|
||||
return getProcessBlob(blobId, 'session_' + session, actualOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODOC
|
||||
* @param {*} blobId
|
||||
@ -306,7 +285,7 @@ async function getProcessBlob(blobId, key, options) {
|
||||
}
|
||||
process.lastActive = Date.now();
|
||||
process.lastPing = null;
|
||||
process.timeout = options.timeout;
|
||||
process.timeout = kPingInterval;
|
||||
process.ready = new Promise(function (resolve, reject) {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
@ -345,59 +324,59 @@ async function getProcessBlob(blobId, key, options) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
permissionsGranted: function () {
|
||||
permissionsGranted: async function () {
|
||||
let user = process?.credentials?.session?.name;
|
||||
let settings = await loadSettings();
|
||||
if (
|
||||
user &&
|
||||
options?.packageOwner &&
|
||||
options?.packageName &&
|
||||
gGlobalSettings.userPermissions &&
|
||||
gGlobalSettings.userPermissions[user] &&
|
||||
gGlobalSettings.userPermissions[user][options.packageOwner]
|
||||
settings.userPermissions &&
|
||||
settings.userPermissions[user] &&
|
||||
settings.userPermissions[user][options.packageOwner]
|
||||
) {
|
||||
return gGlobalSettings.userPermissions[user][
|
||||
return settings.userPermissions[user][
|
||||
options.packageOwner
|
||||
][options.packageName];
|
||||
}
|
||||
},
|
||||
allPermissionsGranted: function () {
|
||||
allPermissionsGranted: async function () {
|
||||
let user = process?.credentials?.session?.name;
|
||||
let settings = await loadSettings();
|
||||
if (
|
||||
user &&
|
||||
options?.packageOwner &&
|
||||
options?.packageName &&
|
||||
gGlobalSettings.userPermissions &&
|
||||
gGlobalSettings.userPermissions[user]
|
||||
settings.userPermissions &&
|
||||
settings.userPermissions[user]
|
||||
) {
|
||||
return gGlobalSettings.userPermissions[user];
|
||||
return settings.userPermissions[user];
|
||||
}
|
||||
},
|
||||
permissionsForUser: function (user) {
|
||||
return (
|
||||
(gGlobalSettings?.permissions
|
||||
? gGlobalSettings.permissions[user]
|
||||
: []) ?? []
|
||||
);
|
||||
permissionsForUser: async function (user) {
|
||||
let settings = await loadSettings();
|
||||
return settings?.permissions?.[user] ?? [];
|
||||
},
|
||||
apps: (user) => getApps(user, process),
|
||||
getSockets: getSockets,
|
||||
permissionTest: function (permission) {
|
||||
permissionTest: async function (permission) {
|
||||
let user = process?.credentials?.session?.name;
|
||||
let settings = await loadSettings();
|
||||
if (!user || !options?.packageOwner || !options?.packageName) {
|
||||
return;
|
||||
} else if (
|
||||
gGlobalSettings.userPermissions &&
|
||||
gGlobalSettings.userPermissions[user] &&
|
||||
gGlobalSettings.userPermissions[user][options.packageOwner] &&
|
||||
gGlobalSettings.userPermissions[user][options.packageOwner][
|
||||
settings.userPermissions &&
|
||||
settings.userPermissions[user] &&
|
||||
settings.userPermissions[user][options.packageOwner] &&
|
||||
settings.userPermissions[user][options.packageOwner][
|
||||
options.packageName
|
||||
] &&
|
||||
gGlobalSettings.userPermissions[user][options.packageOwner][
|
||||
settings.userPermissions[user][options.packageOwner][
|
||||
options.packageName
|
||||
][permission] !== undefined
|
||||
) {
|
||||
if (
|
||||
gGlobalSettings.userPermissions[user][options.packageOwner][
|
||||
settings.userPermissions[user][options.packageOwner][
|
||||
options.packageName
|
||||
][permission]
|
||||
) {
|
||||
@ -509,23 +488,24 @@ async function getProcessBlob(blobId, key, options) {
|
||||
}
|
||||
};
|
||||
if (process.credentials?.permissions?.administration) {
|
||||
imports.core.globalSettingsDescriptions = function () {
|
||||
imports.core.globalSettingsDescriptions = async function () {
|
||||
let settings = Object.assign({}, k_global_settings);
|
||||
for (let [key, value] of Object.entries(gGlobalSettings)) {
|
||||
for (let [key, value] of Object.entries(await loadSettings())) {
|
||||
if (settings[key]) {
|
||||
settings[key].value = value;
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
imports.core.globalSettingsGet = function (key) {
|
||||
return gGlobalSettings[key];
|
||||
imports.core.globalSettingsGet = async function (key) {
|
||||
let settings = await loadSettings();
|
||||
return settings?.[key];
|
||||
};
|
||||
imports.core.globalSettingsSet = async function (key, value) {
|
||||
print('Setting', key, value);
|
||||
await loadSettings();
|
||||
gGlobalSettings[key] = value;
|
||||
setGlobalSettings(gGlobalSettings);
|
||||
let settings = await loadSettings();
|
||||
settings[key] = value;
|
||||
await setGlobalSettings(settings);
|
||||
print('Done.');
|
||||
};
|
||||
imports.core.deleteUser = async function (user) {
|
||||
@ -707,8 +687,9 @@ async function getProcessBlob(blobId, key, options) {
|
||||
imports.ssb.addEventListener = undefined;
|
||||
imports.ssb.removeEventListener = undefined;
|
||||
imports.ssb.getIdentityInfo = undefined;
|
||||
imports.fetch = function (url, options) {
|
||||
return http.fetch(url, options, gGlobalSettings.fetch_hosts);
|
||||
imports.fetch = async function (url, options) {
|
||||
let settings = await loadSettings();
|
||||
return http.fetch(url, options, settings?.fetch_hosts);
|
||||
};
|
||||
|
||||
if (
|
||||
@ -759,13 +740,13 @@ async function getProcessBlob(blobId, key, options) {
|
||||
);
|
||||
};
|
||||
}
|
||||
process.sendPermissions = function sendPermissions() {
|
||||
process.sendPermissions = async function sendPermissions() {
|
||||
process.app.send({
|
||||
action: 'permissions',
|
||||
permissions: imports.core.permissionsGranted(),
|
||||
permissions: await imports.core.permissionsGranted(),
|
||||
});
|
||||
};
|
||||
process.resetPermission = function resetPermission(permission) {
|
||||
process.resetPermission = async function resetPermission(permission) {
|
||||
let user = process?.credentials?.session?.name;
|
||||
storePermission(
|
||||
user,
|
||||
@ -774,7 +755,7 @@ async function getProcessBlob(blobId, key, options) {
|
||||
permission,
|
||||
undefined
|
||||
);
|
||||
process.sendPermissions();
|
||||
return process.sendPermissions();
|
||||
};
|
||||
process.task.setImports(imports);
|
||||
process.task.activate();
|
||||
@ -807,7 +788,7 @@ async function getProcessBlob(blobId, key, options) {
|
||||
broadcastEvent('onSessionBegin', [getUser(process, process)]);
|
||||
if (process.app) {
|
||||
process.app.send({action: 'ready', version: version()});
|
||||
process.sendPermissions();
|
||||
await process.sendPermissions();
|
||||
}
|
||||
await process.task.execute({name: appSourceName, source: appSource});
|
||||
resolveReady(process);
|
||||
@ -837,7 +818,6 @@ async function getProcessBlob(blobId, key, options) {
|
||||
* @returns
|
||||
*/
|
||||
async function setGlobalSettings(settings) {
|
||||
gGlobalSettings = settings;
|
||||
try {
|
||||
return await new Database('core').set('settings', JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
@ -965,66 +945,7 @@ async function blobHandler(request, response, blobId, uri) {
|
||||
}
|
||||
|
||||
let process;
|
||||
if (uri == '/view') {
|
||||
let data;
|
||||
let match;
|
||||
let query = form.decodeForm(request.query);
|
||||
let headers = {
|
||||
'Content-Security-Policy': k_content_security_policy,
|
||||
};
|
||||
if (query.filename && query.filename.match(/^[A-Za-z0-9\.-]*$/)) {
|
||||
headers['Content-Disposition'] = `attachment; filename=${query.filename}`;
|
||||
}
|
||||
if ((match = /^\/\~(\w+)\/(\w+)$/.exec(blobId))) {
|
||||
let id = await new Database(match[1]).get('path:' + match[2]);
|
||||
if (id) {
|
||||
if (request.headers['if-none-match'] === '"' + id + '"') {
|
||||
headers['Content-Length'] = '0';
|
||||
response.writeHead(304, headers);
|
||||
response.end();
|
||||
} else {
|
||||
data = await ssb.blobGet(id);
|
||||
if (match[3]) {
|
||||
let appObject = JSON.parse(data);
|
||||
data = appObject.files[match[3]];
|
||||
}
|
||||
sendData(
|
||||
response,
|
||||
data,
|
||||
undefined,
|
||||
Object.assign({etag: '"' + id + '"'}, headers)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (request.headers['if-none-match'] === '"' + blobId + '"') {
|
||||
headers['Content-Length'] = '0';
|
||||
response.writeHead(304, headers);
|
||||
response.end();
|
||||
} else {
|
||||
sendData(
|
||||
response,
|
||||
data,
|
||||
undefined,
|
||||
Object.assign({etag: '"' + blobId + '"'}, headers)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (request.headers['if-none-match'] === '"' + blobId + '"') {
|
||||
headers['Content-Length'] = '0';
|
||||
response.writeHead(304, headers);
|
||||
response.end();
|
||||
} else {
|
||||
data = await ssb.blobGet(blobId);
|
||||
sendData(
|
||||
response,
|
||||
data,
|
||||
undefined,
|
||||
Object.assign({etag: '"' + blobId + '"'}, headers)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (uri == '/save') {
|
||||
if (uri == '/save') {
|
||||
let match;
|
||||
if ((match = /^\/\~(\w+)\/(\w+)$/.exec(blobId))) {
|
||||
let user = match[1];
|
||||
@ -1229,7 +1150,7 @@ async function loadSettings() {
|
||||
data[key] = value.default_value;
|
||||
}
|
||||
}
|
||||
gGlobalSettings = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1254,9 +1175,9 @@ function sendStats() {
|
||||
* TODOC
|
||||
*/
|
||||
loadSettings()
|
||||
.then(function () {
|
||||
if (tildefriends.https_port && gGlobalSettings.http_redirect) {
|
||||
httpd.set_http_redirect(gGlobalSettings.http_redirect);
|
||||
.then(function (settings) {
|
||||
if (tildefriends.https_port && settings.http_redirect) {
|
||||
httpd.set_http_redirect(settings.http_redirect);
|
||||
}
|
||||
httpd.all('/app/socket', app.socket);
|
||||
httpd.all('', function default_http_handler(request, response) {
|
||||
@ -1332,35 +1253,36 @@ loadSettings()
|
||||
* @param {*} permission
|
||||
* @param {*} allow
|
||||
*/
|
||||
function storePermission(user, packageOwner, packageName, permission, allow) {
|
||||
if (!gGlobalSettings.userPermissions) {
|
||||
gGlobalSettings.userPermissions = {};
|
||||
async function storePermission(user, packageOwner, packageName, permission, allow) {
|
||||
let settings = await loadSettings();
|
||||
if (!settings.userPermissions) {
|
||||
settings.userPermissions = {};
|
||||
}
|
||||
if (!gGlobalSettings.userPermissions[user]) {
|
||||
gGlobalSettings.userPermissions[user] = {};
|
||||
if (!settings.userPermissions[user]) {
|
||||
settings.userPermissions[user] = {};
|
||||
}
|
||||
if (!gGlobalSettings.userPermissions[user][packageOwner]) {
|
||||
gGlobalSettings.userPermissions[user][packageOwner] = {};
|
||||
if (!settings.userPermissions[user][packageOwner]) {
|
||||
settings.userPermissions[user][packageOwner] = {};
|
||||
}
|
||||
if (!gGlobalSettings.userPermissions[user][packageOwner][packageName]) {
|
||||
gGlobalSettings.userPermissions[user][packageOwner][packageName] = {};
|
||||
if (!settings.userPermissions[user][packageOwner][packageName]) {
|
||||
settings.userPermissions[user][packageOwner][packageName] = {};
|
||||
}
|
||||
if (
|
||||
gGlobalSettings.userPermissions[user][packageOwner][packageName][
|
||||
settings.userPermissions[user][packageOwner][packageName][
|
||||
permission
|
||||
] !== allow
|
||||
) {
|
||||
if (allow === undefined) {
|
||||
delete gGlobalSettings.userPermissions[user][packageOwner][packageName][
|
||||
delete settings.userPermissions[user][packageOwner][packageName][
|
||||
permission
|
||||
];
|
||||
} else {
|
||||
gGlobalSettings.userPermissions[user][packageOwner][packageName][
|
||||
settings.userPermissions[user][packageOwner][packageName][
|
||||
permission
|
||||
] = allow;
|
||||
}
|
||||
setGlobalSettings(gGlobalSettings);
|
||||
return setGlobalSettings(settings);
|
||||
}
|
||||
}
|
||||
|
||||
export {invoke, getSessionProcessBlob};
|
||||
export {invoke, getProcessBlob};
|
||||
|
2
deps/c-ares
vendored
2
deps/c-ares
vendored
@ -1 +1 @@
|
||||
Subproject commit 0c1c60dc60114812eb5950e6b50fa69d923a20e6
|
||||
Subproject commit a57ff692eeab8d21c853dc1ddaf0164f517074c3
|
139
src/httpd.js.c
139
src/httpd.js.c
@ -41,6 +41,8 @@ static JSValue _authenticate_jwt(tf_ssb_t* ssb, JSContext* context, const char*
|
||||
static JSValue _httpd_websocket_upgrade(JSContext* context, JSValueConst this_val, int argc, JSValueConst* argv);
|
||||
static const char* _make_session_jwt(JSContext* context, tf_ssb_t* ssb, const char* name);
|
||||
static const char* _make_set_session_cookie_header(tf_http_request_t* request, const char* session_cookie);
|
||||
const char** _form_data_decode(const char* data, int length);
|
||||
const char* _form_data_get(const char** form_data, const char* key);
|
||||
|
||||
static JSClassID _httpd_class_id;
|
||||
static JSClassID _httpd_request_class_id;
|
||||
@ -555,14 +557,11 @@ static bool _magic_bytes_match(const magic_bytes_t* magic, const uint8_t* actual
|
||||
return true;
|
||||
}
|
||||
|
||||
static JSValue _httpd_mime_type_from_magic_bytes(JSContext* context, JSValueConst this_val, int argc, JSValueConst* argv)
|
||||
static const char* _httpd_mime_type_from_magic_bytes_internal(const uint8_t* bytes, size_t size)
|
||||
{
|
||||
JSValue result = JS_UNDEFINED;
|
||||
size_t size = 0;
|
||||
uint8_t* bytes = tf_util_try_get_array_buffer(context, &size, argv[0]);
|
||||
const char* type = "application/binary";
|
||||
if (bytes)
|
||||
{
|
||||
|
||||
const magic_bytes_t k_magic_bytes[] = {
|
||||
{
|
||||
.type = "image/jpeg",
|
||||
@ -627,12 +626,19 @@ static JSValue _httpd_mime_type_from_magic_bytes(JSContext* context, JSValueCons
|
||||
{
|
||||
if (_magic_bytes_match(&k_magic_bytes[i], bytes, size))
|
||||
{
|
||||
result = JS_NewString(context, k_magic_bytes[i].type);
|
||||
type = k_magic_bytes[i].type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
return type;
|
||||
}
|
||||
|
||||
static JSValue _httpd_mime_type_from_magic_bytes(JSContext* context, JSValueConst this_val, int argc, JSValueConst* argv)
|
||||
{
|
||||
size_t size = 0;
|
||||
uint8_t* bytes = tf_util_try_get_array_buffer(context, &size, argv[0]);
|
||||
return JS_NewString(context, _httpd_mime_type_from_magic_bytes_internal(bytes, size));
|
||||
}
|
||||
|
||||
static const char* _ext_to_content_type(const char* ext, bool use_fallback)
|
||||
@ -959,6 +965,124 @@ static void _httpd_endpoint_static(tf_http_request_t* request)
|
||||
tf_file_stat(task, path, _httpd_endpoint_static_stat, request);
|
||||
}
|
||||
|
||||
typedef struct _view_t
|
||||
{
|
||||
tf_http_request_t* request;
|
||||
const char** form_data;
|
||||
void* data;
|
||||
size_t size;
|
||||
bool not_modified;
|
||||
} view_t;
|
||||
|
||||
static bool _is_filename_safe(const char* filename)
|
||||
{
|
||||
if (!filename)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
for (const char* p = filename; *p; p++)
|
||||
{
|
||||
if ((*p <= 'a' && *p >= 'z') &&
|
||||
(*p <= 'A' && *p >= 'Z') &&
|
||||
(*p <= '0' && *p >= '9') &&
|
||||
*p != '.' &&
|
||||
*p != '-' &&
|
||||
*p != '_')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return strlen(filename) < 256;
|
||||
}
|
||||
|
||||
static void _httpd_endpoint_view_work(tf_ssb_t* ssb, void* user_data)
|
||||
{
|
||||
view_t* view = user_data;
|
||||
tf_http_request_t* request = view->request;
|
||||
char blob_id[256] = "";
|
||||
if (request->path[0] == '/' && request->path[1] == '~')
|
||||
{
|
||||
char user[256] = "";
|
||||
char path[1024] = "";
|
||||
const char* slash = strchr(request->path + 2, '/');
|
||||
if (slash)
|
||||
{
|
||||
snprintf(user, sizeof(user), "%.*s", (int)(slash - (request->path + 2)), request->path + 2);
|
||||
snprintf(path, sizeof(path), "path:%.*s", (int)(strlen(slash + 1) - strlen("/view")), slash + 1);
|
||||
const char* value = tf_ssb_db_get_property(ssb, user, path);
|
||||
snprintf(blob_id, sizeof(blob_id), "%s", value);
|
||||
tf_free((void*)value);
|
||||
}
|
||||
}
|
||||
else if (request->path[0] == '/' && request->path[1] == '&')
|
||||
{
|
||||
snprintf(blob_id, sizeof(blob_id), "%.*s", (int)(strlen(request->path) - strlen("/view") - 1), request->path + 1);
|
||||
}
|
||||
|
||||
if (*blob_id)
|
||||
{
|
||||
const char* if_none_match = tf_http_request_get_header(request, "if-none-match");
|
||||
char match[258];
|
||||
snprintf(match, sizeof(match), "\"%s\"", blob_id);
|
||||
if (if_none_match && strcmp(if_none_match, match))
|
||||
{
|
||||
view->not_modified = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
tf_ssb_db_blob_get(ssb, blob_id, (uint8_t**)&view->data, &view->size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void _httpd_endpoint_view_after_work(tf_ssb_t* ssb, int status, void* user_data)
|
||||
{
|
||||
view_t* view = user_data;
|
||||
const char* filename = _form_data_get(view->form_data, "filename");
|
||||
if (!_is_filename_safe(filename))
|
||||
{
|
||||
filename = NULL;
|
||||
}
|
||||
char content_disposition[512] = "";
|
||||
if (filename)
|
||||
{
|
||||
snprintf(content_disposition, sizeof(content_disposition), "attachment; filename=%s", filename);
|
||||
}
|
||||
const char* headers[] = {
|
||||
"Content-Security-Policy", "sandbox allow-downloads allow-top-navigation-by-user-activation",
|
||||
"Content-Type", view->data ? _httpd_mime_type_from_magic_bytes_internal(view->data, view->size) : "text/plain",
|
||||
filename ? "Content-Disposition" : NULL, filename ? content_disposition : NULL,
|
||||
};
|
||||
int count = filename ? tf_countof(headers) / 2 : (tf_countof(headers) / 2 - 1);
|
||||
if (view->not_modified)
|
||||
{
|
||||
tf_http_respond(view->request, 304, headers, count, NULL, 0);
|
||||
}
|
||||
else if (view->data)
|
||||
{
|
||||
tf_http_respond(view->request, 200, headers, count, view->data, view->size);
|
||||
tf_free(view->data);
|
||||
}
|
||||
else
|
||||
{
|
||||
const char* k_payload = tf_http_status_text(404);
|
||||
tf_http_respond(view->request, 404, NULL, 0, k_payload, strlen(k_payload));
|
||||
}
|
||||
tf_free(view->form_data);
|
||||
tf_http_request_unref(view->request);
|
||||
tf_free(view);
|
||||
}
|
||||
|
||||
static void _httpd_endpoint_view(tf_http_request_t* request)
|
||||
{
|
||||
tf_http_request_ref(request);
|
||||
tf_task_t* task = request->user_data;
|
||||
tf_ssb_t* ssb = tf_task_get_ssb(task);
|
||||
view_t* view = tf_malloc(sizeof(view_t));
|
||||
*view = (view_t) { .request = request, .form_data = _form_data_decode(request->query, request->query ? strlen(request->query) : 0) };
|
||||
tf_ssb_run_work(ssb, _httpd_endpoint_view_work, _httpd_endpoint_view_after_work, view);
|
||||
}
|
||||
|
||||
static void _httpd_endpoint_root_callback(const char* path, void* user_data)
|
||||
{
|
||||
tf_http_request_t* request = user_data;
|
||||
@ -1690,6 +1814,7 @@ void tf_httpd_register(JSContext* context)
|
||||
tf_http_add_handler(http, "/.well-known/*", _httpd_endpoint_static, NULL, task);
|
||||
tf_http_add_handler(http, "/~*/*/", _httpd_endpoint_static, NULL, task);
|
||||
tf_http_add_handler(http, "/&*.sha256/", _httpd_endpoint_static, NULL, task);
|
||||
tf_http_add_handler(http, "/*/view", _httpd_endpoint_view, NULL, task);
|
||||
|
||||
tf_http_add_handler(http, "/robots.txt", _httpd_endpoint_robots_txt, NULL, NULL);
|
||||
tf_http_add_handler(http, "/debug", _httpd_endpoint_debug, NULL, task);
|
||||
|
Loading…
Reference in New Issue
Block a user