security: Token 改为 HttpOnly Cookie 下发,消除 XSS 窃取面
登录时网关签发 HttpOnly+Secure+SameSite=Lax 的 q4_token Cookie,前端不再经手 Token;网关代理时支持从 Cookie 验签并向下游注入 Authorization(dnfs 零改动),新增 /logout 清除 Cookie,static 代理路由兼容 Cookie 转 Bearer。
This commit is contained in:
1 parent
65cfaf97a1
commit
e89578fa82
4 files changed
+102
-28
No files matched your search
+79
-3
@@ -26,7 +26,7 @@ using namespace qing;
|
|||||||
namespace json = boost::json;
|
namespace json = boost::json;
|
||||||
namespace po = boost::program_options;
|
namespace po = boost::program_options;
|
||||||
|
|
||||||
constexpr const char *_VER = "0.4.0";
|
constexpr const char *_VER = "0.4.1";
|
||||||
|
|
||||||
//////////////////////////////////
|
//////////////////////////////////
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
@@ -291,6 +291,13 @@ http::response<http::string_body> handleCaptcha(
|
|||||||
const http::request<http::string_body>& req,
|
const http::request<http::string_body>& req,
|
||||||
const std::unordered_map<std::string, std::string>& params);
|
const std::unordered_map<std::string, std::string>& params);
|
||||||
|
|
||||||
|
http::response<http::string_body> handleLogout(
|
||||||
|
const http::request<http::string_body>& req,
|
||||||
|
const std::unordered_map<std::string, std::string>& params);
|
||||||
|
|
||||||
|
/* 从 Cookie 头中解析指定名称的值(找不到返回空串) */
|
||||||
|
std::string get_cookie_value(const std::string& cookie_header, const std::string& name);
|
||||||
|
|
||||||
|
|
||||||
/* headers can't processed */
|
/* headers can't processed */
|
||||||
bool is_special_header(const std::string &header);
|
bool is_special_header(const std::string &header);
|
||||||
@@ -532,6 +539,11 @@ void registerRoutes(HttpServer &server) {
|
|||||||
[](const auto& req, const auto& params) { return handleRegister(req, params); });
|
[](const auto& req, const auto& params) { return handleRegister(req, params); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.post("/logout", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||||
|
return wrapperHttp(req, params,
|
||||||
|
[](const auto& req, const auto& params) { return handleLogout(req, params); });
|
||||||
|
});
|
||||||
|
|
||||||
server.get("*", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
server.get("*", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||||
return wrapperHttp(req, params,
|
return wrapperHttp(req, params,
|
||||||
[](const auto& req, const auto& params) { return handleProxy(req, params, "get"); });
|
[](const auto& req, const auto& params) { return handleProxy(req, params, "get"); });
|
||||||
@@ -776,12 +788,38 @@ http::response<http::string_body> handleLogin(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
res.set("Connection", "close");
|
res.set("Connection", "close");
|
||||||
|
|
||||||
|
/* 签发 HttpOnly Cookie(防 XSS 窃取 Token;JS 不再经手 Token) */
|
||||||
|
{
|
||||||
|
std::string ck = "q4_token=" + token +
|
||||||
|
"; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" +
|
||||||
|
std::to_string(_TOKEN_KEEP_TIME);
|
||||||
|
res.set("Set-Cookie", ck);
|
||||||
|
}
|
||||||
|
|
||||||
res.result(http::status::ok);
|
res.result(http::status::ok);
|
||||||
res.body() = json::serialize(jv_rr);
|
res.body() = json::serialize(jv_rr);
|
||||||
res.prepare_payload();
|
res.prepare_payload();
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 退出登录 /logout:清除 q4_token Cookie */
|
||||||
|
http::response<http::string_body> handleLogout(
|
||||||
|
const http::request<http::string_body>& req,
|
||||||
|
const std::unordered_map<std::string, std::string>& params)
|
||||||
|
{
|
||||||
|
http::response<http::string_body> res;
|
||||||
|
res.version(req.version());
|
||||||
|
|
||||||
|
res.set("Set-Cookie",
|
||||||
|
"q4_token=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0");
|
||||||
|
res.set("Connection", "close");
|
||||||
|
res.result(http::status::ok);
|
||||||
|
res.body() = R"({"success": true, "message": "logged out"})";
|
||||||
|
res.prepare_payload();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
/* 处理注册 /register */
|
/* 处理注册 /register */
|
||||||
http::response<http::string_body> handleRegister(
|
http::response<http::string_body> handleRegister(
|
||||||
const http::request<http::string_body>& req,
|
const http::request<http::string_body>& req,
|
||||||
@@ -878,6 +916,7 @@ http::response<http::string_body> handleProxy(
|
|||||||
std::string t_url = "";
|
std::string t_url = "";
|
||||||
std::string url = "";
|
std::string url = "";
|
||||||
std::string auth = "";
|
std::string auth = "";
|
||||||
|
std::string cookie_token = ""; /* 从 Cookie 提取的 Token(用于向下游注入 Authorization) */
|
||||||
json::value jv_t;
|
json::value jv_t;
|
||||||
{
|
{
|
||||||
auto flag = false;
|
auto flag = false;
|
||||||
@@ -912,10 +951,21 @@ http::response<http::string_body> handleProxy(
|
|||||||
url += "/";
|
url += "/";
|
||||||
url += target_path;
|
url += target_path;
|
||||||
|
|
||||||
|
std::string raw = "";
|
||||||
it = req.base().find("Authorization");
|
it = req.base().find("Authorization");
|
||||||
if (it == req.base().end() && !flag)
|
if (it != req.base().end()) {
|
||||||
|
raw = std::string(it->value());
|
||||||
|
} else {
|
||||||
|
/* 回退:从 HttpOnly Cookie 读取 q4_token(浏览器同源请求自动携带) */
|
||||||
|
auto cit = req.base().find("Cookie");
|
||||||
|
if (cit != req.base().end()) {
|
||||||
|
cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
|
||||||
|
if (!cookie_token.empty())
|
||||||
|
raw = "Bearer " + cookie_token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw.empty() && !flag)
|
||||||
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*");
|
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*");
|
||||||
std::string raw = req["Authorization"];
|
|
||||||
if (raw.substr(0, 7) == "Bearer ") {
|
if (raw.substr(0, 7) == "Bearer ") {
|
||||||
raw = raw.substr(7, raw.size() - 7);
|
raw = raw.substr(7, raw.size() - 7);
|
||||||
size_t idx = raw.find("_");
|
size_t idx = raw.find("_");
|
||||||
@@ -996,6 +1046,11 @@ http::response<http::string_body> handleProxy(
|
|||||||
client.set_header("X-Forwarded-For: unknown");
|
client.set_header("X-Forwarded-For: unknown");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Token 来自 Cookie 时,向下游补齐 Authorization 头(dnfs 等下游仍按 Bearer 解析,无需改动) */
|
||||||
|
if (!cookie_token.empty()) {
|
||||||
|
client.set_header("Authorization: Bearer " + cookie_token);
|
||||||
|
}
|
||||||
|
|
||||||
/* 转发请求 */
|
/* 转发请求 */
|
||||||
if (method == "get")
|
if (method == "get")
|
||||||
client.get_stream_sync(url, callback);
|
client.get_stream_sync(url, callback);
|
||||||
@@ -1031,6 +1086,27 @@ bool is_special_header(const std::string &header) {
|
|||||||
|| header == "Forwarded";
|
|| header == "Forwarded";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string get_cookie_value(const std::string& cookie_header, const std::string& name) {
|
||||||
|
// Cookie 头格式:"k1=v1; k2=v2; q4_token=xxx"
|
||||||
|
size_t pos = 0;
|
||||||
|
while (pos <= cookie_header.size()) {
|
||||||
|
size_t end = cookie_header.find(';', pos);
|
||||||
|
if (end == std::string::npos) end = cookie_header.size();
|
||||||
|
std::string pair = cookie_header.substr(pos, end - pos);
|
||||||
|
size_t eq = pair.find('=');
|
||||||
|
if (eq != std::string::npos) {
|
||||||
|
std::string k = pair.substr(0, eq);
|
||||||
|
size_t b = k.find_first_not_of(" \t");
|
||||||
|
size_t e = k.find_last_not_of(" \t");
|
||||||
|
if (b != std::string::npos) k = k.substr(b, e - b + 1);
|
||||||
|
if (k == name) return pair.substr(eq + 1);
|
||||||
|
}
|
||||||
|
if (end == cookie_header.size()) break;
|
||||||
|
pos = end + 1;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
bool is_safe_target(const std::string& target) {
|
bool is_safe_target(const std::string& target) {
|
||||||
// 请求路径安全性校验(P1-1)
|
// 请求路径安全性校验(P1-1)
|
||||||
// 防御目标:路径遍历、协议注入、空字节、非绝对路径形式
|
// 防御目标:路径遍历、协议注入、空字节、非绝对路径形式
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ def proxy_get_notes():
|
|||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
h = {"Content-Type": "application/json"}
|
h = {"Content-Type": "application/json"}
|
||||||
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
||||||
|
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
|
||||||
req = _urlreq.Request(f"{DNFS_URL}/get_notes", data=_json.dumps(data).encode(), headers=h, method="POST")
|
req = _urlreq.Request(f"{DNFS_URL}/get_notes", data=_json.dumps(data).encode(), headers=h, method="POST")
|
||||||
with _urlreq.urlopen(req, timeout=15) as r:
|
with _urlreq.urlopen(req, timeout=15) as r:
|
||||||
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
||||||
@@ -426,6 +427,7 @@ def proxy_add_note():
|
|||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
h = {"Content-Type": "application/json"}
|
h = {"Content-Type": "application/json"}
|
||||||
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
||||||
|
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
|
||||||
req = _urlreq.Request(f"{DNFS_URL}/add_note", data=_json.dumps(data).encode(), headers=h, method="POST")
|
req = _urlreq.Request(f"{DNFS_URL}/add_note", data=_json.dumps(data).encode(), headers=h, method="POST")
|
||||||
with _urlreq.urlopen(req, timeout=15) as r:
|
with _urlreq.urlopen(req, timeout=15) as r:
|
||||||
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
||||||
@@ -440,6 +442,7 @@ def proxy_delete_note():
|
|||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
h = {"Content-Type": "application/json"}
|
h = {"Content-Type": "application/json"}
|
||||||
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
||||||
|
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
|
||||||
req = _urlreq.Request(f"{DNFS_URL}/delete_note", data=_json.dumps(data).encode(), headers=h, method="POST")
|
req = _urlreq.Request(f"{DNFS_URL}/delete_note", data=_json.dumps(data).encode(), headers=h, method="POST")
|
||||||
with _urlreq.urlopen(req, timeout=15) as r:
|
with _urlreq.urlopen(req, timeout=15) as r:
|
||||||
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
||||||
@@ -454,6 +457,7 @@ def proxy_update_note():
|
|||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
h = {"Content-Type": "application/json"}
|
h = {"Content-Type": "application/json"}
|
||||||
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
||||||
|
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
|
||||||
req = _urlreq.Request(f"{DNFS_URL}/update_note", data=_json.dumps(data).encode(), headers=h, method="POST")
|
req = _urlreq.Request(f"{DNFS_URL}/update_note", data=_json.dumps(data).encode(), headers=h, method="POST")
|
||||||
with _urlreq.urlopen(req, timeout=15) as r:
|
with _urlreq.urlopen(req, timeout=15) as r:
|
||||||
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
||||||
|
|||||||
+18
-23
@@ -1,11 +1,10 @@
|
|||||||
// Session restore from localStorage
|
// 认证改为 HttpOnly Cookie(gateway 下发 q4_token),浏览器同源请求自动携带,JS 不再经手 Token
|
||||||
if(!sessionStorage.getItem('q4_token')){var t=localStorage.getItem('q4_token');if(t)sessionStorage.setItem('q4_token',t);}
|
|
||||||
|
|
||||||
/* by qing.
|
/* by qing.
|
||||||
*
|
*
|
||||||
* 用于获取一个节点下的所有子节点信息,0为根节点。
|
* 用于获取一个节点下的所有子节点信息,0为根节点。
|
||||||
*/
|
*/
|
||||||
async function getNode(token, id) {
|
async function getNode(id) {
|
||||||
try {
|
try {
|
||||||
obj = { "pid": id }
|
obj = { "pid": id }
|
||||||
jsonBody = JSON.stringify(obj)
|
jsonBody = JSON.stringify(obj)
|
||||||
@@ -14,7 +13,6 @@ async function getNode(token, id) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json', // 告诉服务器内容为json
|
'Content-Type': 'application/json', // 告诉服务器内容为json
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Target-Url': 'http://127.0.0.1:8006'
|
'Target-Url': 'http://127.0.0.1:8006'
|
||||||
},
|
},
|
||||||
body: jsonBody
|
body: jsonBody
|
||||||
@@ -54,7 +52,7 @@ async function getNode(token, id) {
|
|||||||
*
|
*
|
||||||
* 用于向dnfs添加节点
|
* 用于向dnfs添加节点
|
||||||
*/
|
*/
|
||||||
async function addNode(token, pid, title, content) {
|
async function addNode(pid, title, content) {
|
||||||
try {
|
try {
|
||||||
obj = {
|
obj = {
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
@@ -67,7 +65,6 @@ async function addNode(token, pid, title, content) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json', // 告诉服务器内容为json
|
'Content-Type': 'application/json', // 告诉服务器内容为json
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Target-Url': 'http://127.0.0.1:8006'
|
'Target-Url': 'http://127.0.0.1:8006'
|
||||||
},
|
},
|
||||||
body: jsonBody
|
body: jsonBody
|
||||||
@@ -89,8 +86,8 @@ async function addNode(token, pid, title, content) {
|
|||||||
|
|
||||||
input_note_title.value = "";
|
input_note_title.value = "";
|
||||||
input_note_content.value = "";
|
input_note_content.value = "";
|
||||||
getNode(token, stack[stack.length - 1]).then(load_notes);
|
getNode(stack[stack.length - 1]).then(load_notes);
|
||||||
getNode(token, stack[stack.length - 1]).then(loadMap)
|
getNode(stack[stack.length - 1]).then(loadMap)
|
||||||
|
|
||||||
//if (result.success) {
|
//if (result.success) {
|
||||||
alert(`添加节点成功`, result);
|
alert(`添加节点成功`, result);
|
||||||
@@ -108,7 +105,7 @@ async function addNode(token, pid, title, content) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteNode(token, id) {
|
async function deleteNode(id) {
|
||||||
if (confirm("确认删除此节点?") == false) {
|
if (confirm("确认删除此节点?") == false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -121,7 +118,6 @@ async function deleteNode(token, id) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Target-Url': 'http://127.0.0.1:8006'
|
'Target-Url': 'http://127.0.0.1:8006'
|
||||||
},
|
},
|
||||||
body: jsonBody
|
body: jsonBody
|
||||||
@@ -142,8 +138,8 @@ async function deleteNode(token, id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stack.pop();
|
stack.pop();
|
||||||
getNode(token, stack[stack.length - 1]).then(load_notes);
|
getNode(stack[stack.length - 1]).then(load_notes);
|
||||||
getNode(token, stack[stack.length - 1]).then(loadMap)
|
getNode(stack[stack.length - 1]).then(loadMap)
|
||||||
|
|
||||||
console.log(`删除节点成功`);
|
console.log(`删除节点成功`);
|
||||||
return result
|
return result
|
||||||
@@ -182,7 +178,7 @@ input_note_submit.addEventListener('click', function(event) {
|
|||||||
var pid = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
var pid = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
||||||
//alert(stack.length)
|
//alert(stack.length)
|
||||||
//alert(pid)
|
//alert(pid)
|
||||||
addNode(token, pid, title, content).then(result => {
|
addNode(pid, title, content).then(result => {
|
||||||
//alert(result.message);
|
//alert(result.message);
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -199,7 +195,7 @@ delete_note.addEventListener('click', (event) => {
|
|||||||
console.log("节点未删除");
|
console.log("节点未删除");
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
deleteNode(token, id);
|
deleteNode(id);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -232,8 +228,8 @@ function load_notes(main_node) {
|
|||||||
card.addEventListener('click', function(event) {
|
card.addEventListener('click', function(event) {
|
||||||
var id = Number(item.id)
|
var id = Number(item.id)
|
||||||
stack.push(id)
|
stack.push(id)
|
||||||
getNode(token, id).then(load_notes)
|
getNode(id).then(load_notes)
|
||||||
getNode(token, id).then(loadMap)
|
getNode(id).then(loadMap)
|
||||||
});
|
});
|
||||||
let ttl = document.createElement('center');
|
let ttl = document.createElement('center');
|
||||||
let ttl1 =document.createElement('h3');
|
let ttl1 =document.createElement('h3');
|
||||||
@@ -274,10 +270,10 @@ function loadMap(children_json) {
|
|||||||
fold_div.addEventListener("click", () => {
|
fold_div.addEventListener("click", () => {
|
||||||
if (is_expand == true) {
|
if (is_expand == true) {
|
||||||
is_expand = false;
|
is_expand = false;
|
||||||
getNode(token, stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
|
getNode(stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
|
||||||
} else {
|
} else {
|
||||||
is_expand = true;
|
is_expand = true;
|
||||||
getNode(token, stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
|
getNode(stack.length == 0 ? 0 : stack[stack.length - 1]).then(loadMap);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (need_return == true) {
|
if (need_return == true) {
|
||||||
@@ -290,8 +286,8 @@ function loadMap(children_json) {
|
|||||||
back.addEventListener("click", () => {
|
back.addEventListener("click", () => {
|
||||||
stack.pop();
|
stack.pop();
|
||||||
var id = (stack.length >= 0) ? stack[stack.length - 1] : 0;
|
var id = (stack.length >= 0) ? stack[stack.length - 1] : 0;
|
||||||
getNode(token, id).then(load_notes);
|
getNode(id).then(load_notes);
|
||||||
getNode(token, id).then(loadMap);
|
getNode(id).then(loadMap);
|
||||||
});
|
});
|
||||||
minimap.appendChild(back);
|
minimap.appendChild(back);
|
||||||
}
|
}
|
||||||
@@ -309,6 +305,5 @@ function loadMap(children_json) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var token = sessionStorage.getItem('q4_token')
|
getNode(0).then(load_notes);
|
||||||
getNode(token, 0).then(load_notes);
|
getNode(0).then(loadMap)
|
||||||
getNode(token, 0).then(loadMap)
|
|
||||||
@@ -76,8 +76,7 @@ form.addEventListener('submit', async function(e) {
|
|||||||
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
|
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
|
||||||
if (!res.ok) throw new Error(result.message || '登录失败');
|
if (!res.ok) throw new Error(result.message || '登录失败');
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
localStorage.setItem('q4_token', result.data.token);
|
// Token 已由网关通过 HttpOnly Cookie 下发,JS 不再经手
|
||||||
sessionStorage.setItem('q4_token', result.data.token);
|
|
||||||
alert('登录成功');
|
alert('登录成功');
|
||||||
location.href = '/dnfs.html';
|
location.href = '/dnfs.html';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in new issue
Block a user