feat(api): Settings-Typ standalone und wifi.standalone in system/info
This commit is contained in:
@@ -259,6 +259,10 @@ static void handle_info(const nl_api_ctx_t *ctx, nl_api_response_t *resp)
|
||||
cJSON_AddStringToObject(w, "error", in.wifi_error);
|
||||
else
|
||||
cJSON_AddNullToObject(w, "error");
|
||||
/* "mode" allein reicht nicht: im Standalone-Betrieb ist es "ap", genau wie beim offenen
|
||||
* Onboarding-AP. Ohne dieses Feld boete die App einer fertig eingerichteten Lampe ein
|
||||
* Onboarding an. */
|
||||
cJSON_AddBoolToObject(w, "standalone", in.wifi_standalone);
|
||||
cJSON_AddNumberToObject(o, "uptime", in.uptime_s);
|
||||
cJSON_AddNumberToObject(o, "free_heap", in.free_heap);
|
||||
if (in.last_ota_error)
|
||||
@@ -314,6 +318,16 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp
|
||||
cJSON_AddStringToObject(sa, "layout", s.sacn_layout ? "rgb" : "dimmer");
|
||||
cJSON_AddNumberToObject(sa, "slots", nl_board_dmx_slots(ctx->board, s.sacn_layout != 0));
|
||||
}
|
||||
{
|
||||
cJSON *sa = cJSON_AddObjectToObject(o, "standalone");
|
||||
cJSON_AddBoolToObject(sa, "enabled", s.standalone != 0);
|
||||
/* Der WIRKSAME Name, nicht der gespeicherte: ohne eigenen Namen heisst der AP
|
||||
* wie der Hostname. Wer hier einen leeren String saehe, muesste die Vorgabe
|
||||
* selbst nachbilden. Das AP-Passwort steht bewusst nicht hier. */
|
||||
cJSON_AddStringToObject(sa, "ssid", s.ap_ssid[0] ? s.ap_ssid : (ctx->hostname ? ctx->hostname : ""));
|
||||
cJSON_AddNumberToObject(sa, "channel", s.ap_channel ? s.ap_channel : 6);
|
||||
cJSON_AddBoolToObject(sa, "pulse", s.standalone_pulse != 0);
|
||||
}
|
||||
reply_json(resp, 200, o);
|
||||
}
|
||||
|
||||
@@ -345,6 +359,58 @@ static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t
|
||||
cJSON_Delete(j);
|
||||
return;
|
||||
}
|
||||
} else if (strcmp(type, "standalone") == 0) {
|
||||
const cJSON *en = cJSON_GetObjectItemCaseSensitive(j, "enabled");
|
||||
const cJSON *pu = cJSON_GetObjectItemCaseSensitive(j, "pulse");
|
||||
const char *as = NULL, *ap = NULL;
|
||||
long ch = 0;
|
||||
int asp, app, chp;
|
||||
bool will_enable;
|
||||
|
||||
will_enable = cJSON_IsBool(en) && cJSON_IsTrue(en);
|
||||
asp = get_string(j, "ssid", 1, NL_SSID_LEN - 1, &as);
|
||||
app = get_string(j, "password", 8, NL_PASS_LEN - 1, &ap);
|
||||
chp = get_int(j, "channel", 1, 13, &ch);
|
||||
|
||||
if (!cJSON_IsBool(en)) {
|
||||
err = "invalid enabled";
|
||||
} else if (asp < 0) {
|
||||
err = "invalid ssid";
|
||||
} else if (app < 0) {
|
||||
/* Die Meldung nennt das Feld, nie den Wert: ein zu kurzes Passwort darf
|
||||
* nicht im Protokoll des Aufrufers landen. */
|
||||
err = "invalid password";
|
||||
} else if (chp < 0) {
|
||||
err = "invalid channel";
|
||||
} else if (will_enable && app != 1 && s.ap_pass[0] == '\0') {
|
||||
err = "password required";
|
||||
} else if (pu && !cJSON_IsBool(pu)) {
|
||||
err = "invalid pulse";
|
||||
} else {
|
||||
if (asp == 1)
|
||||
strcpy(s.ap_ssid, as);
|
||||
if (app == 1)
|
||||
strcpy(s.ap_pass, ap);
|
||||
if (chp == 1)
|
||||
s.ap_channel = (uint8_t)ch;
|
||||
if (pu)
|
||||
s.standalone_pulse = cJSON_IsTrue(pu) ? 1 : 0;
|
||||
s.standalone = will_enable ? 1 : 0;
|
||||
/* Wie bei "wifi" kehrt dieser Zweig hier zurueck statt ans gemeinsame Ende zu
|
||||
* laufen (das antwortet 200): der Wechsel zieht der Verbindung das Netz weg, die
|
||||
* Antwort ist 202 und muss vorher ausgefuellt sein. */
|
||||
if (ctx->save_settings(&s) != 0) {
|
||||
reply_error(resp, 500, "save failed");
|
||||
} else if (!ctx->standalone_configure ||
|
||||
ctx->standalone_configure(will_enable, s.ap_ssid, s.ap_pass,
|
||||
s.ap_channel, s.standalone_pulse != 0) != 0) {
|
||||
reply_error(resp, 503, "wifi busy");
|
||||
} else {
|
||||
reply(resp, 202, "{\"status\":\"standalone\"}");
|
||||
}
|
||||
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";
|
||||
|
||||
@@ -27,18 +27,24 @@ typedef struct {
|
||||
bool sacn_active; /* true, solange die Lampe ferngesteuert wird */
|
||||
bool sacn_joined; /* Multicast-Gruppe des Universe beigetreten */
|
||||
bool sacn_running; /* Empfangs-Task lebt und haelt einen Socket */
|
||||
bool wifi_standalone; /* eigener Dauer-AP, nicht der offene Onboarding-AP */
|
||||
} nl_sys_info_t;
|
||||
|
||||
/* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */
|
||||
typedef struct {
|
||||
const nl_board_t *board;
|
||||
const char *fw_version;
|
||||
const char *hostname; /* "ninlight-<mac4>", Vorgabe fuer den AP-Namen */
|
||||
const char *build_date;
|
||||
void (*get_state)(nl_light_state_t *out);
|
||||
nl_ctrl_err_t (*apply)(const nl_light_change_t *c);
|
||||
void (*get_settings)(nl_settings_t *out);
|
||||
int (*save_settings)(const nl_settings_t *s); /* 0 = ok; wifi fields are ignored */
|
||||
int (*wifi_configure)(const char *ssid, const char *pass); /* 0 = onboarding test started */
|
||||
/* 0 = uebernommen (Antwort 202), sonst 503. Wie wifi_configure wirkt der Wechsel
|
||||
erst nach der Antwort - die Verbindung bricht sonst vor dem Senden weg. */
|
||||
int (*standalone_configure)(bool enabled, const char *ssid, const char *pass,
|
||||
uint8_t channel, bool pulse);
|
||||
bool (*open_access)(void); /* unconfigured onboarding AP */
|
||||
void (*get_info)(nl_sys_info_t *out);
|
||||
void (*factory_reset)(void); /* request only */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "fake_api_ctx.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "light/nl_boot_state.h"
|
||||
@@ -12,6 +13,10 @@ nl_ctrl_err_t fake_apply_result;
|
||||
int fake_apply_calls, fake_save_calls, fake_save_result;
|
||||
int fake_wifi_calls, fake_wifi_result;
|
||||
char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
|
||||
int fake_standalone_calls, fake_standalone_result;
|
||||
bool fake_standalone_enabled, fake_standalone_pulse;
|
||||
char fake_standalone_ssid[NL_SSID_LEN], fake_standalone_pass[NL_PASS_LEN];
|
||||
uint8_t fake_standalone_channel;
|
||||
int fake_factory_calls, fake_ping_calls, fake_fw_result;
|
||||
char fake_fw_url[256];
|
||||
bool fake_open;
|
||||
@@ -49,6 +54,18 @@ static int wifi_configure(const char *ssid, const char *pass)
|
||||
return fake_wifi_result;
|
||||
}
|
||||
|
||||
static int fake_standalone_configure(bool enabled, const char *ssid, const char *pass,
|
||||
uint8_t channel, bool pulse)
|
||||
{
|
||||
fake_standalone_calls++;
|
||||
fake_standalone_enabled = enabled;
|
||||
fake_standalone_pulse = pulse;
|
||||
fake_standalone_channel = channel;
|
||||
snprintf(fake_standalone_ssid, sizeof fake_standalone_ssid, "%s", ssid ? ssid : "");
|
||||
snprintf(fake_standalone_pass, sizeof fake_standalone_pass, "%s", pass ? pass : "");
|
||||
return fake_standalone_result;
|
||||
}
|
||||
|
||||
static bool open_access(void) { return fake_open; }
|
||||
static void get_info(nl_sys_info_t *out) { *out = fake_info; }
|
||||
static void factory_reset(void) { fake_factory_calls++; }
|
||||
@@ -83,6 +100,10 @@ void fake_api_reset(const char *board_id)
|
||||
fake_apply_calls = fake_save_calls = fake_save_result = 0;
|
||||
fake_wifi_calls = fake_wifi_result = 0;
|
||||
fake_wifi_ssid[0] = fake_wifi_pass[0] = '\0';
|
||||
fake_standalone_calls = fake_standalone_result = 0;
|
||||
fake_standalone_enabled = fake_standalone_pulse = false;
|
||||
fake_standalone_ssid[0] = fake_standalone_pass[0] = '\0';
|
||||
fake_standalone_channel = 0;
|
||||
fake_factory_calls = fake_ping_calls = 0;
|
||||
fake_fw_result = -1;
|
||||
fake_fw_url[0] = '\0';
|
||||
@@ -96,6 +117,8 @@ void fake_api_reset(const char *board_id)
|
||||
fake_ctx.get_settings = get_settings;
|
||||
fake_ctx.save_settings = save_settings;
|
||||
fake_ctx.wifi_configure = wifi_configure;
|
||||
fake_ctx.standalone_configure = fake_standalone_configure;
|
||||
fake_ctx.hostname = "ninlight-681f";
|
||||
fake_ctx.open_access = open_access;
|
||||
fake_ctx.get_info = get_info;
|
||||
fake_ctx.factory_reset = factory_reset;
|
||||
|
||||
@@ -12,6 +12,10 @@ extern nl_ctrl_err_t fake_apply_result;
|
||||
extern int fake_apply_calls, fake_save_calls, fake_save_result;
|
||||
extern int fake_wifi_calls, fake_wifi_result;
|
||||
extern char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN];
|
||||
extern int fake_standalone_calls, fake_standalone_result;
|
||||
extern bool fake_standalone_enabled, fake_standalone_pulse;
|
||||
extern char fake_standalone_ssid[NL_SSID_LEN], fake_standalone_pass[NL_PASS_LEN];
|
||||
extern uint8_t fake_standalone_channel;
|
||||
extern int fake_factory_calls, fake_ping_calls, fake_fw_result;
|
||||
extern char fake_fw_url[256];
|
||||
extern bool fake_open;
|
||||
|
||||
+174
@@ -589,6 +589,169 @@ static void test_put_sacn_layout_and_start_slot_together(void)
|
||||
TEST_ASSERT_EQUAL_UINT16(510, fake_settings.sacn_start_slot);
|
||||
}
|
||||
|
||||
/* --- standalone --- */
|
||||
|
||||
static void test_standalone_requires_a_password(void)
|
||||
{
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||
TEST_ASSERT_EQUAL_STRING("password required", error_text());
|
||||
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||
TEST_ASSERT_EQUAL_INT(0, fake_save_calls);
|
||||
}
|
||||
|
||||
/* Ein gespeichertes Passwort genuegt - man soll den Kanal aendern koennen, ohne das
|
||||
* Passwort erneut zu schicken. */
|
||||
static void test_standalone_keeps_a_stored_password(void)
|
||||
{
|
||||
strcpy(fake_settings.ap_pass, "geheim123");
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||
"{\"type\":\"standalone\",\"enabled\":true,\"channel\":11}");
|
||||
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||
TEST_ASSERT_EQUAL_INT(11, fake_standalone_channel);
|
||||
TEST_ASSERT_EQUAL_STRING("geheim123", fake_standalone_pass);
|
||||
}
|
||||
|
||||
static void test_standalone_rejects_a_short_password(void)
|
||||
{
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"1234567\"}");
|
||||
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||
TEST_ASSERT_EQUAL_STRING("invalid password", error_text());
|
||||
/* Die Fehlermeldung nennt das Feld, nie den Wert. */
|
||||
TEST_ASSERT_NULL(strstr(resp.body, "1234567"));
|
||||
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||
}
|
||||
|
||||
static void test_standalone_rejects_a_channel_outside_the_band(void)
|
||||
{
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"channel\":14}");
|
||||
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"channel\":0}");
|
||||
TEST_ASSERT_EQUAL_INT(400, resp.status);
|
||||
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||
}
|
||||
|
||||
static void test_standalone_accepts_a_full_request(void)
|
||||
{
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN,
|
||||
"{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\","
|
||||
"\"ssid\":\"Wohnzimmerlampe\",\"channel\":6,\"pulse\":false}");
|
||||
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||
TEST_ASSERT_EQUAL_STRING("standalone", cJSON_GetStringValue(field("status")));
|
||||
TEST_ASSERT_EQUAL_INT(1, fake_standalone_calls);
|
||||
TEST_ASSERT_TRUE(fake_standalone_enabled);
|
||||
TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe", fake_standalone_ssid);
|
||||
TEST_ASSERT_EQUAL_STRING("geheim123", fake_standalone_pass);
|
||||
TEST_ASSERT_EQUAL_INT(6, fake_standalone_channel);
|
||||
TEST_ASSERT_FALSE(fake_standalone_pulse);
|
||||
/* Gespeichert wird auch: sonst ist der Modus nach dem Neustart weg. */
|
||||
TEST_ASSERT_EQUAL_INT(1, fake_save_calls);
|
||||
TEST_ASSERT_EQUAL_UINT8(1, fake_settings.standalone);
|
||||
TEST_ASSERT_EQUAL_STRING("geheim123", fake_settings.ap_pass);
|
||||
}
|
||||
|
||||
/* Abschalten braucht kein Passwort. */
|
||||
static void test_standalone_can_be_switched_off(void)
|
||||
{
|
||||
fake_settings.standalone = 1;
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":false}");
|
||||
TEST_ASSERT_EQUAL_INT(202, resp.status);
|
||||
TEST_ASSERT_EQUAL_INT(1, fake_standalone_calls);
|
||||
TEST_ASSERT_FALSE(fake_standalone_enabled);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, fake_settings.standalone);
|
||||
}
|
||||
|
||||
static void test_standalone_reports_a_busy_radio_and_a_failed_save(void)
|
||||
{
|
||||
strcpy(fake_settings.ap_pass, "geheim123");
|
||||
fake_standalone_result = -1;
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||
TEST_ASSERT_EQUAL_INT(503, resp.status);
|
||||
TEST_ASSERT_EQUAL_STRING("wifi busy", error_text());
|
||||
|
||||
fake_standalone_result = 0;
|
||||
fake_standalone_calls = 0;
|
||||
fake_save_result = -1;
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}");
|
||||
TEST_ASSERT_EQUAL_INT(500, resp.status);
|
||||
/* Ohne gespeicherten Datensatz darf der Wechsel nicht angestossen werden. */
|
||||
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||
}
|
||||
|
||||
static void test_standalone_rejects_bad_types_of_the_other_fields(void)
|
||||
{
|
||||
strcpy(fake_settings.ap_pass, "geheim123");
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":\"yes\"}");
|
||||
TEST_ASSERT_EQUAL_STRING("invalid enabled", error_text());
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true,\"pulse\":1}");
|
||||
TEST_ASSERT_EQUAL_STRING("invalid pulse", error_text());
|
||||
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true,\"ssid\":\"\"}");
|
||||
TEST_ASSERT_EQUAL_STRING("invalid ssid", error_text());
|
||||
TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls);
|
||||
}
|
||||
|
||||
/* Das AP-Passwort darf nirgends in der Antwort auftauchen. */
|
||||
static void test_settings_show_standalone_without_the_password(void)
|
||||
{
|
||||
cJSON *o, *sa;
|
||||
fake_settings.standalone = 1;
|
||||
fake_settings.ap_channel = 11;
|
||||
fake_settings.standalone_pulse = 0;
|
||||
strcpy(fake_settings.ap_ssid, "Wohnzimmerlampe");
|
||||
strcpy(fake_settings.ap_pass, "geheim123");
|
||||
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||
TEST_ASSERT_EQUAL_INT(200, resp.status);
|
||||
TEST_ASSERT_NULL(strstr(resp.body, "geheim123"));
|
||||
o = cJSON_Parse(resp.body);
|
||||
sa = cJSON_GetObjectItemCaseSensitive(o, "standalone");
|
||||
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(sa, "enabled")));
|
||||
TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe",
|
||||
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(sa, "ssid")));
|
||||
TEST_ASSERT_EQUAL_INT(11, (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(sa, "channel")));
|
||||
TEST_ASSERT_FALSE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(sa, "pulse")));
|
||||
cJSON_Delete(o);
|
||||
}
|
||||
|
||||
/* Ohne eigenen Namen zeigt die Antwort den WIRKSAMEN, nicht einen leeren String -
|
||||
* sonst muesste jeder Aufrufer die Vorgabe selbst nachbilden. */
|
||||
static void test_settings_show_the_effective_ap_name(void)
|
||||
{
|
||||
cJSON *o, *sa;
|
||||
fake_settings.ap_ssid[0] = '\0';
|
||||
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
|
||||
o = cJSON_Parse(resp.body);
|
||||
sa = cJSON_GetObjectItemCaseSensitive(o, "standalone");
|
||||
TEST_ASSERT_EQUAL_STRING("ninlight-681f",
|
||||
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(sa, "ssid")));
|
||||
/* Kanal 0 im Datensatz heisst 6. */
|
||||
TEST_ASSERT_EQUAL_INT(6, (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(sa, "channel")));
|
||||
cJSON_Delete(o);
|
||||
}
|
||||
|
||||
/* "ap" allein reicht nicht: denselben Wert meldet der Onboarding-AP. Ohne dieses Feld
|
||||
* boete die App einer fertig eingerichteten Lampe ein Onboarding an. */
|
||||
static void test_info_tells_standalone_from_onboarding(void)
|
||||
{
|
||||
cJSON *o, *w;
|
||||
fake_info.wifi_mode = "ap";
|
||||
fake_info.wifi_standalone = true;
|
||||
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
|
||||
o = cJSON_Parse(resp.body);
|
||||
w = cJSON_GetObjectItemCaseSensitive(o, "wifi");
|
||||
TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(w, "standalone")));
|
||||
cJSON_Delete(o);
|
||||
|
||||
fake_info.wifi_standalone = false;
|
||||
call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL);
|
||||
o = cJSON_Parse(resp.body);
|
||||
w = cJSON_GetObjectItemCaseSensitive(o, "wifi");
|
||||
TEST_ASSERT_TRUE(cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(w, "standalone")));
|
||||
cJSON_Delete(o);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
UNITY_BEGIN();
|
||||
@@ -627,5 +790,16 @@ int main(void)
|
||||
RUN_TEST(test_info_exposes_sacn_state);
|
||||
RUN_TEST(test_info_reports_a_dead_sacn_task);
|
||||
RUN_TEST(test_commands);
|
||||
RUN_TEST(test_standalone_requires_a_password);
|
||||
RUN_TEST(test_standalone_keeps_a_stored_password);
|
||||
RUN_TEST(test_standalone_rejects_a_short_password);
|
||||
RUN_TEST(test_standalone_rejects_a_channel_outside_the_band);
|
||||
RUN_TEST(test_standalone_accepts_a_full_request);
|
||||
RUN_TEST(test_standalone_can_be_switched_off);
|
||||
RUN_TEST(test_standalone_reports_a_busy_radio_and_a_failed_save);
|
||||
RUN_TEST(test_standalone_rejects_bad_types_of_the_other_fields);
|
||||
RUN_TEST(test_settings_show_standalone_without_the_password);
|
||||
RUN_TEST(test_settings_show_the_effective_ap_name);
|
||||
RUN_TEST(test_info_tells_standalone_from_onboarding);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user