End to end encryption completed through HTTP!
This commit is contained in:
parent
2a604edb9e
commit
4126fd503a
6 changed files with 389 additions and 184 deletions
|
|
@ -2,5 +2,5 @@
|
|||
"requirePragma": true,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"endOfLine": "lf"
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
|
|
|
|||
221
src/routes.js
221
src/routes.js
|
|
@ -10,14 +10,17 @@ const Crypto = require("crypto");
|
|||
const logger = require("winston");
|
||||
const httpsAgent = require("https");
|
||||
const responseTime = require("response-time");
|
||||
const uuid = require("uuid/v4");
|
||||
const getListPage = require("../utils/paginate");
|
||||
const auth = require("../services/auth/auth");
|
||||
const FS = require("../utils/fs");
|
||||
const Encryption = require("../utils/encryptionStore");
|
||||
const LightningServices = require("../utils/lightningServices");
|
||||
const GunDB = require("../services/gunDB/Mediator");
|
||||
const { unprotectedRoutes } = require("../utils/protectedRoutes");
|
||||
const { unprotectedRoutes, nonEncryptedRoutes } = require("../utils/protectedRoutes");
|
||||
|
||||
const DEFAULT_MAX_NUM_ROUTES_TO_QUERY = 10;
|
||||
const SESSION_ID = uuid();
|
||||
|
||||
// module.exports = (app) => {
|
||||
module.exports = async (
|
||||
|
|
@ -116,7 +119,7 @@ module.exports = async (
|
|||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
if (err) {
|
||||
res.send({
|
||||
res.json({
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
|
|
@ -124,7 +127,7 @@ module.exports = async (
|
|||
}
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -192,6 +195,53 @@ module.exports = async (
|
|||
}
|
||||
};
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("x-session-id", SESSION_ID)
|
||||
next()
|
||||
})
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const deviceId = req.headers["x-shockwallet-device-id"];
|
||||
try {
|
||||
if (nonEncryptedRoutes.includes(req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!deviceId) {
|
||||
const error = {
|
||||
field: "deviceId",
|
||||
message: "Please specify a device ID"
|
||||
};
|
||||
console.error(error)
|
||||
return res.status(401).json(error);
|
||||
}
|
||||
|
||||
if (!Encryption.isAuthorizedDevice({ deviceId })) {
|
||||
const error = {
|
||||
field: "deviceId",
|
||||
message: "Please specify a device ID"
|
||||
};
|
||||
console.error("Unknown Device", error)
|
||||
return res.status(401).json(error);
|
||||
}
|
||||
|
||||
console.log("Body:", req.body)
|
||||
console.log("Decrypt params:", { deviceId, message: req.body.encryptionKey })
|
||||
const decryptedKey = Encryption.decryptKey({ deviceId, message: req.body.encryptionKey });
|
||||
console.log("decryptedKey", decryptedKey)
|
||||
const decryptedMessage = Encryption.decryptMessage({ message: req.body.data, key: decryptedKey, iv: req.body.iv })
|
||||
req.body = JSON.parse(decryptedMessage);
|
||||
return next();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res
|
||||
.status(401)
|
||||
.json(
|
||||
err
|
||||
);
|
||||
}
|
||||
})
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
try {
|
||||
console.log("Route:", req.path)
|
||||
|
|
@ -247,7 +297,7 @@ module.exports = async (
|
|||
*/
|
||||
app.get("/health", async (req, res) => {
|
||||
const health = await checkHealth();
|
||||
res.send(health);
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -255,11 +305,11 @@ module.exports = async (
|
|||
*/
|
||||
app.get("/healthz", async (req, res) => {
|
||||
const health = await checkHealth();
|
||||
res.send(health);
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
app.get("/ping", (req, res) => {
|
||||
res.send("OK");
|
||||
res.json({ message: "OK" });
|
||||
});
|
||||
|
||||
app.post("/api/mobile/error", (req, res) => {
|
||||
|
|
@ -267,8 +317,36 @@ module.exports = async (
|
|||
res.json({ msg: "OK" });
|
||||
});
|
||||
|
||||
app.post("/api/security/exchangeKeys", (req, res) => {
|
||||
app.post("/api/security/exchangeKeys", async (req, res) => {
|
||||
try {
|
||||
const { publicKey, deviceId } = req.body;
|
||||
|
||||
if (!publicKey || publicKey.length < 600) {
|
||||
return res.status(400).json({
|
||||
field: 'publicKey',
|
||||
message: "Please provide a valid public key"
|
||||
})
|
||||
}
|
||||
|
||||
if (!deviceId ||
|
||||
!/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/iu
|
||||
.test(deviceId)) {
|
||||
return res.status(400).json({
|
||||
field: 'deviceId',
|
||||
message: "Please provide a valid device ID"
|
||||
})
|
||||
}
|
||||
|
||||
const authorizedDevice = await Encryption.authorizeDevice({ deviceId, publicKey })
|
||||
console.log(authorizedDevice)
|
||||
return res.status(200).json(authorizedDevice)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return res.status(401).json({
|
||||
field: 'unknown',
|
||||
message: err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/api/lnd/wallet/status", async (req, res) => {
|
||||
|
|
@ -292,6 +370,7 @@ module.exports = async (
|
|||
|
||||
app.post("/api/lnd/auth", async (req, res) => {
|
||||
try {
|
||||
console.log("/api/lnd/auth Body:", req.body)
|
||||
const health = await checkHealth();
|
||||
const walletInitialized = await walletExists();
|
||||
// If we're connected to lnd, unlock the wallet using the password supplied
|
||||
|
|
@ -337,7 +416,7 @@ module.exports = async (
|
|||
}
|
||||
|
||||
res.status(500);
|
||||
res.send({
|
||||
res.json({
|
||||
field: "health",
|
||||
errorMessage: sanitizeLNDError(health.LNDStatus.message),
|
||||
success: false
|
||||
|
|
@ -346,7 +425,7 @@ module.exports = async (
|
|||
} catch (err) {
|
||||
logger.debug("Unlock Error:", err);
|
||||
res.status(400);
|
||||
res.send({ field: "user", errorMessage: sanitizeLNDError(err.message), success: false });
|
||||
res.json({ field: "user", errorMessage: sanitizeLNDError(err.message), success: false });
|
||||
return err;
|
||||
}
|
||||
});
|
||||
|
|
@ -368,10 +447,10 @@ module.exports = async (
|
|||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400);
|
||||
res.send({ field: "WalletUnlocker", errorMessage: unlockErr.message });
|
||||
res.json({ field: "WalletUnlocker", errorMessage: unlockErr.message });
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
} else {
|
||||
await recreateLnServices();
|
||||
|
|
@ -435,12 +514,12 @@ module.exports = async (
|
|||
const message = genSeedErr.details;
|
||||
return res
|
||||
.status(400)
|
||||
.send({ field: "GenSeed", errorMessage: message, success: false });
|
||||
.json({ field: "GenSeed", errorMessage: message, success: false });
|
||||
}
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.send({ field: "health", errorMessage: "LND is down", success: false });
|
||||
.json({ field: "health", errorMessage: "LND is down", success: false });
|
||||
}
|
||||
|
||||
logger.debug("GenSeed:", genSeedResponse);
|
||||
|
|
@ -613,13 +692,13 @@ module.exports = async (
|
|||
logger.error("GetInfo Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "getInfo",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.info("GetInfo:", response);
|
||||
|
|
@ -644,13 +723,13 @@ module.exports = async (
|
|||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400);
|
||||
res.send({
|
||||
res.json({
|
||||
field: "getNodeInfo",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("GetNodeInfo:", response);
|
||||
|
|
@ -666,13 +745,13 @@ module.exports = async (
|
|||
logger.debug("GetNetworkInfo Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "getNodeInfo",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("GetNetworkInfo:", response);
|
||||
|
|
@ -688,13 +767,13 @@ module.exports = async (
|
|||
logger.debug("ListPeers Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "listPeers",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("ListPeers:", response);
|
||||
|
|
@ -710,13 +789,13 @@ module.exports = async (
|
|||
logger.debug("NewAddress Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "newAddress",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("NewAddress:", response);
|
||||
|
|
@ -731,13 +810,13 @@ module.exports = async (
|
|||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(403);
|
||||
return res.send({
|
||||
return res.json({
|
||||
field: "limituser",
|
||||
errorMessage: "User limited"
|
||||
});
|
||||
}
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
const connectRequest = {
|
||||
addr: { pubkey: req.body.pubkey, host: req.body.host },
|
||||
|
|
@ -747,7 +826,7 @@ module.exports = async (
|
|||
lightning.connectPeer(connectRequest, (err, response) => {
|
||||
if (err) {
|
||||
logger.debug("ConnectPeer Error:", err);
|
||||
res.status(500).send({ field: "connectPeer", errorMessage: sanitizeLNDError(err.message) });
|
||||
res.status(500).json({ field: "connectPeer", errorMessage: sanitizeLNDError(err.message) });
|
||||
} else {
|
||||
logger.debug("ConnectPeer:", response);
|
||||
res.json(response);
|
||||
|
|
@ -762,20 +841,20 @@ module.exports = async (
|
|||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(403);
|
||||
return res.send({
|
||||
return res.json({
|
||||
field: "limituser",
|
||||
errorMessage: "User limited"
|
||||
});
|
||||
}
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
const disconnectRequest = { pub_key: req.body.pubkey };
|
||||
logger.debug("DisconnectPeer Request:", disconnectRequest);
|
||||
lightning.disconnectPeer(disconnectRequest, (err, response) => {
|
||||
if (err) {
|
||||
logger.debug("DisconnectPeer Error:", err);
|
||||
res.status(400).send({ field: "disconnectPeer", errorMessage: sanitizeLNDError(err.message) });
|
||||
res.status(400).json({ field: "disconnectPeer", errorMessage: sanitizeLNDError(err.message) });
|
||||
} else {
|
||||
logger.debug("DisconnectPeer:", response);
|
||||
res.json(response);
|
||||
|
|
@ -791,13 +870,13 @@ module.exports = async (
|
|||
logger.debug("ListChannels Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "listChannels",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("ListChannels:", response);
|
||||
|
|
@ -813,13 +892,13 @@ module.exports = async (
|
|||
logger.debug("PendingChannels Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "pendingChannels",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("PendingChannels:", response);
|
||||
|
|
@ -906,10 +985,10 @@ module.exports = async (
|
|||
logger.debug("ListInvoices Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({ errorMessage: sanitizeLNDError(err.message), success: false });
|
||||
res.status(400).json({ errorMessage: sanitizeLNDError(err.message), success: false });
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: health.LNDStatus.message, success: false });
|
||||
res.json({ errorMessage: health.LNDStatus.message, success: false });
|
||||
}
|
||||
} else {
|
||||
// logger.debug("ListInvoices:", response);
|
||||
|
|
@ -932,13 +1011,13 @@ module.exports = async (
|
|||
logger.debug("ForwardingHistory Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "forwardingHistory",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("ForwardingHistory:", response);
|
||||
|
|
@ -954,13 +1033,13 @@ module.exports = async (
|
|||
logger.debug("WalletBalance Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "walletBalance",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("WalletBalance:", response);
|
||||
|
|
@ -976,13 +1055,13 @@ module.exports = async (
|
|||
if (err) {
|
||||
logger.debug("WalletBalance Error:", err);
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "walletBalance",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send(health.LNDStatus);
|
||||
res.json(health.LNDStatus);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
|
@ -991,13 +1070,13 @@ module.exports = async (
|
|||
if (err) {
|
||||
logger.debug("ChannelBalance Error:", err);
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "channelBalance",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send(health.LNDStatus);
|
||||
res.json(health.LNDStatus);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
|
@ -1020,11 +1099,11 @@ module.exports = async (
|
|||
logger.debug("DecodePayReq Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(500).send({
|
||||
res.status(500).json({
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500).send({ errorMessage: "LND is down" });
|
||||
res.status(500).json({ errorMessage: "LND is down" });
|
||||
}
|
||||
} else {
|
||||
logger.info("DecodePayReq:", paymentRequest);
|
||||
|
|
@ -1042,13 +1121,13 @@ module.exports = async (
|
|||
logger.debug("ChannelBalance Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "channelBalance",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("ChannelBalance:", response);
|
||||
|
|
@ -1065,7 +1144,7 @@ module.exports = async (
|
|||
res.sendStatus(403);
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -1089,10 +1168,10 @@ module.exports = async (
|
|||
logger.info("OpenChannelRequest Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success && !res.headersSent) {
|
||||
res.status(500).send({ field: "openChannelRequest", errorMessage: sanitizeLNDError(err.message) });
|
||||
res.status(500).json({ field: "openChannelRequest", errorMessage: sanitizeLNDError(err.message) });
|
||||
} else if (!res.headersSent) {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
});
|
||||
openedChannel.write(openChannelRequest)
|
||||
|
|
@ -1108,7 +1187,7 @@ module.exports = async (
|
|||
res.sendStatus(403);
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
const { channelPoint, outputIndex } = req.body;
|
||||
|
|
@ -1136,13 +1215,13 @@ module.exports = async (
|
|||
if (!res.headersSent) {
|
||||
if (health.LNDStatus.success) {
|
||||
logger.debug("CloseChannelRequest Error:", err);
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "closeChannel",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1157,7 +1236,7 @@ module.exports = async (
|
|||
res.sendStatus(403);
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
const paymentRequest = { payment_request: req.body.payreq };
|
||||
|
|
@ -1189,12 +1268,12 @@ module.exports = async (
|
|||
logger.error("SendPayment Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(500).send({
|
||||
res.status(500).json({
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1210,7 +1289,7 @@ module.exports = async (
|
|||
res.sendStatus(403);
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1226,13 +1305,13 @@ module.exports = async (
|
|||
logger.debug("AddInvoice Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "addInvoice",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
|
@ -1250,7 +1329,7 @@ module.exports = async (
|
|||
res.sendStatus(403);
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
lightning.signMessage(
|
||||
|
|
@ -1260,10 +1339,10 @@ module.exports = async (
|
|||
logger.debug("SignMessage Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({ field: "signMessage", errorMessage: sanitizeLNDError(err.message) });
|
||||
res.status(400).json({ field: "signMessage", errorMessage: sanitizeLNDError(err.message) });
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("SignMessage:", response);
|
||||
|
|
@ -1282,10 +1361,10 @@ module.exports = async (
|
|||
logger.debug("VerifyMessage Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({ field: "verifyMessage", errorMessage: sanitizeLNDError(err.message) });
|
||||
res.status(400).json({ field: "verifyMessage", errorMessage: sanitizeLNDError(err.message) });
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("VerifyMessage:", response);
|
||||
|
|
@ -1303,7 +1382,7 @@ module.exports = async (
|
|||
res.sendStatus(403);
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
const sendCoinsRequest = { addr: req.body.addr, amount: req.body.amount };
|
||||
|
|
@ -1313,13 +1392,13 @@ module.exports = async (
|
|||
logger.debug("SendCoins Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
field: "sendCoins",
|
||||
errorMessage: sanitizeLNDError(err.message)
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("SendCoins:", response);
|
||||
|
|
@ -1339,10 +1418,10 @@ module.exports = async (
|
|||
logger.debug("QueryRoute Error:", err);
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({ field: "queryRoute", errorMessage: sanitizeLNDError(err.message) });
|
||||
res.status(400).json({ field: "queryRoute", errorMessage: sanitizeLNDError(err.message) });
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
}
|
||||
logger.debug("QueryRoute:", response);
|
||||
|
|
@ -1365,12 +1444,12 @@ module.exports = async (
|
|||
if (err) {
|
||||
const health = await checkHealth();
|
||||
if (health.LNDStatus.success) {
|
||||
res.status(400).send({
|
||||
res.status(400).json({
|
||||
error: err.message
|
||||
});
|
||||
} else {
|
||||
res.status(500);
|
||||
res.send({ errorMessage: "LND is down" });
|
||||
res.json({ errorMessage: "LND is down" });
|
||||
}
|
||||
} else {
|
||||
logger.debug("EstimateFee:", fee);
|
||||
|
|
|
|||
223
src/server.js
223
src/server.js
|
|
@ -1,78 +1,119 @@
|
|||
"use strict";
|
||||
/**
|
||||
* @prettier
|
||||
*/
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
const server = program => {
|
||||
const Https = require("https");
|
||||
const Http = require("http");
|
||||
const Express = require("express");
|
||||
const LightningServices = require("../utils/lightningServices");
|
||||
const app = Express();
|
||||
const Https = require('https')
|
||||
const Http = require('http')
|
||||
const Express = require('express')
|
||||
const LightningServices = require('../utils/lightningServices')
|
||||
const Encryption = require('../utils/encryptionStore')
|
||||
const app = Express()
|
||||
|
||||
const FS = require("../utils/fs");
|
||||
const bodyParser = require("body-parser");
|
||||
const session = require("express-session");
|
||||
const methodOverride = require("method-override");
|
||||
const { unprotectedRoutes, sensitiveRoutes } = require("../utils/protectedRoutes");
|
||||
const FS = require('../utils/fs')
|
||||
const bodyParser = require('body-parser')
|
||||
const session = require('express-session')
|
||||
const methodOverride = require('method-override')
|
||||
const {
|
||||
unprotectedRoutes,
|
||||
sensitiveRoutes,
|
||||
nonEncryptedRoutes
|
||||
} = require('../utils/protectedRoutes')
|
||||
// load app default configuration data
|
||||
const defaults = require("../config/defaults")(program.mainnet);
|
||||
const defaults = require('../config/defaults')(program.mainnet)
|
||||
// define useful global variables ======================================
|
||||
module.useTLS = program.usetls;
|
||||
module.serverPort = program.serverport || defaults.serverPort;
|
||||
module.httpsPort = module.serverPort;
|
||||
module.serverHost = program.serverhost || defaults.serverHost;
|
||||
module.useTLS = program.usetls
|
||||
module.serverPort = program.serverport || defaults.serverPort
|
||||
module.httpsPort = module.serverPort
|
||||
module.serverHost = program.serverhost || defaults.serverHost
|
||||
|
||||
// setup winston logging ==========
|
||||
const logger = require("../config/log")(
|
||||
const logger = require('../config/log')(
|
||||
program.logfile || defaults.logfile,
|
||||
program.loglevel || defaults.loglevel
|
||||
);
|
||||
)
|
||||
|
||||
// utilities functions =================
|
||||
require("../utils/server-utils")(module);
|
||||
require('../utils/server-utils')(module)
|
||||
|
||||
logger.info("Mainnet Mode:", !!program.mainnet);
|
||||
logger.info('Mainnet Mode:', !!program.mainnet)
|
||||
|
||||
const modifyResponseBody = (req, res, next) => {
|
||||
const deviceId = req.headers['x-shockwallet-device-id']
|
||||
const oldSend = res.send
|
||||
|
||||
if (!nonEncryptedRoutes.includes(req.path)) {
|
||||
res.send = (...args) => {
|
||||
if (args[0] && args[0].encryptedData && args[0].encryptionKey) {
|
||||
console.log('Response loop detected', req.path, args[0])
|
||||
oldSend.apply(res, args)
|
||||
} else {
|
||||
// arguments[0] (or `data`) contains the response body
|
||||
const authorized = Encryption.isAuthorizedDevice({ deviceId })
|
||||
const encryptedMessage = authorized
|
||||
? Encryption.encryptMessage({
|
||||
message: args[0],
|
||||
deviceId
|
||||
})
|
||||
: args[0]
|
||||
args[0] = JSON.stringify(encryptedMessage)
|
||||
oldSend.apply(res, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
const wait = seconds =>
|
||||
new Promise(resolve => {
|
||||
const timer = setTimeout(() => resolve(timer), seconds * 1000);
|
||||
});
|
||||
const timer = setTimeout(() => resolve(timer), seconds * 1000)
|
||||
})
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
const startServer = async () => {
|
||||
try {
|
||||
LightningServices.setDefaults(program);
|
||||
await LightningServices.init();
|
||||
LightningServices.setDefaults(program)
|
||||
await LightningServices.init()
|
||||
|
||||
// init lnd module =================
|
||||
const lnd = require("../services/lnd/lnd")(LightningServices.services.lightning);
|
||||
const auth = require("../services/auth/auth");
|
||||
const lnd = require('../services/lnd/lnd')(
|
||||
LightningServices.services.lightning
|
||||
)
|
||||
const auth = require('../services/auth/auth')
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
console.log("Route:", req.path)
|
||||
console.log('Route:', req.path)
|
||||
if (unprotectedRoutes[req.method][req.path]) {
|
||||
next();
|
||||
next()
|
||||
} else {
|
||||
try {
|
||||
const response = await auth.validateToken(
|
||||
req.headers.authorization.replace("Bearer ", "")
|
||||
);
|
||||
req.headers.authorization.replace('Bearer ', '')
|
||||
)
|
||||
if (response.valid) {
|
||||
next();
|
||||
next()
|
||||
} else {
|
||||
res.status(401).json({ field: "authorization", errorMessage: "The authorization token you've supplied is invalid" });
|
||||
res.status(401).json({
|
||||
field: 'authorization',
|
||||
errorMessage:
|
||||
"The authorization token you've supplied is invalid"
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
!req.headers.authorization
|
||||
? "Please add an Authorization header"
|
||||
? 'Please add an Authorization header'
|
||||
: err
|
||||
);
|
||||
res.status(401).json({ field: "authorization", errorMessage: "Please log in" });
|
||||
)
|
||||
res
|
||||
.status(401)
|
||||
.json({ field: 'authorization', errorMessage: 'Please log in' })
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (sensitiveRoutes[req.method][req.path]) {
|
||||
|
|
@ -84,7 +125,7 @@ const server = program => {
|
|||
path: req.path,
|
||||
sessionId: req.sessionId
|
||||
})
|
||||
);
|
||||
)
|
||||
} else {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
|
|
@ -96,10 +137,10 @@ const server = program => {
|
|||
query: req.query,
|
||||
sessionId: req.sessionId
|
||||
})
|
||||
);
|
||||
)
|
||||
}
|
||||
next();
|
||||
});
|
||||
next()
|
||||
})
|
||||
app.use(
|
||||
session({
|
||||
secret: defaults.sessionSecret,
|
||||
|
|
@ -108,25 +149,23 @@ const server = program => {
|
|||
rolling: true,
|
||||
saveUninitialized: true
|
||||
})
|
||||
);
|
||||
app.use(bodyParser.urlencoded({ extended: "true" }));
|
||||
app.use(bodyParser.json());
|
||||
app.use(bodyParser.json({ type: "application/vnd.api+json" }));
|
||||
app.use(methodOverride());
|
||||
)
|
||||
app.use(bodyParser.urlencoded({ extended: 'true' }))
|
||||
app.use(bodyParser.json())
|
||||
app.use(bodyParser.json({ type: 'application/vnd.api+json' }))
|
||||
app.use(methodOverride())
|
||||
// WARNING
|
||||
// error handler middleware, KEEP 4 parameters as express detects the
|
||||
// arity of the function to treat it as a err handling middleware
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, _, res, __) => {
|
||||
// Do logging and user-friendly error message display
|
||||
logger.error(err);
|
||||
res
|
||||
.status(500)
|
||||
.send({ status: 500, errorMessage: "internal error" });
|
||||
});
|
||||
logger.error(err)
|
||||
res.status(500).send({ status: 500, errorMessage: 'internal error' })
|
||||
})
|
||||
|
||||
const CA = LightningServices.servicesConfig.lndCertPath
|
||||
const CA_KEY = CA.replace("cert", "key")
|
||||
const CA_KEY = CA.replace('cert', 'key')
|
||||
|
||||
const createServer = async () => {
|
||||
try {
|
||||
|
|
@ -134,62 +173,60 @@ const server = program => {
|
|||
const [key, cert] = await Promise.all([
|
||||
FS.readFile(CA_KEY),
|
||||
FS.readFile(CA)
|
||||
]);
|
||||
const httpsServer = Https.createServer({ key, cert }, app);
|
||||
])
|
||||
const httpsServer = Https.createServer({ key, cert }, app)
|
||||
|
||||
return httpsServer;
|
||||
return httpsServer
|
||||
}
|
||||
|
||||
const httpServer = Http.Server(app);
|
||||
return httpServer;
|
||||
const httpServer = Http.Server(app)
|
||||
return httpServer
|
||||
} catch (err) {
|
||||
logger.error(err.message);
|
||||
logger.error("An error has occurred while finding an LND cert to use to open an HTTPS server")
|
||||
logger.warn("Falling back to opening an HTTP server...")
|
||||
const httpServer = Http.Server(app);
|
||||
logger.error(err.message)
|
||||
logger.error(
|
||||
'An error has occurred while finding an LND cert to use to open an HTTPS server'
|
||||
)
|
||||
logger.warn('Falling back to opening an HTTP server...')
|
||||
const httpServer = Http.Server(app)
|
||||
return httpServer
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const serverInstance = await createServer();
|
||||
const serverInstance = await createServer()
|
||||
|
||||
const io = require("socket.io")(serverInstance);
|
||||
const io = require('socket.io')(serverInstance)
|
||||
|
||||
const Sockets = require("./sockets")(
|
||||
const Sockets = require('./sockets')(
|
||||
io,
|
||||
lnd,
|
||||
program.user,
|
||||
program.pwd,
|
||||
program.limituser,
|
||||
program.limitpwd
|
||||
);
|
||||
)
|
||||
|
||||
require("./routes")(
|
||||
app,
|
||||
defaults,
|
||||
Sockets,
|
||||
{
|
||||
serverHost: module.serverHost,
|
||||
serverPort: module.serverPort,
|
||||
usetls: program.usetls,
|
||||
CA,
|
||||
CA_KEY
|
||||
}
|
||||
);
|
||||
require('./routes')(app, defaults, Sockets, {
|
||||
serverHost: module.serverHost,
|
||||
serverPort: module.serverPort,
|
||||
usetls: program.usetls,
|
||||
CA,
|
||||
CA_KEY
|
||||
})
|
||||
|
||||
// enable CORS headers
|
||||
app.use(require("./cors"));
|
||||
app.use(require('./cors'))
|
||||
// app.use(bodyParser.json({limit: '100000mb'}));
|
||||
app.use(bodyParser.json({ limit: "50mb" }));
|
||||
app.use(bodyParser.urlencoded({ limit: "50mb", extended: true }));
|
||||
app.use(bodyParser.json({ limit: '50mb' }))
|
||||
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }))
|
||||
app.use(modifyResponseBody)
|
||||
|
||||
serverInstance.listen(module.serverPort, module.serverhost);
|
||||
serverInstance.listen(module.serverPort, module.serverhost)
|
||||
|
||||
logger.info(
|
||||
"App listening on " + module.serverHost + " port " + module.serverPort
|
||||
);
|
||||
'App listening on ' + module.serverHost + ' port ' + module.serverPort
|
||||
)
|
||||
|
||||
module.server = serverInstance;
|
||||
module.server = serverInstance
|
||||
|
||||
// const localtunnel = require('localtunnel');
|
||||
//
|
||||
|
|
@ -198,15 +235,15 @@ const server = program => {
|
|||
// console.log('t', t.url);
|
||||
// });
|
||||
} catch (err) {
|
||||
logger.info(err);
|
||||
logger.info("Restarting server in 30 seconds...");
|
||||
await wait(30);
|
||||
startServer();
|
||||
return false;
|
||||
logger.info(err)
|
||||
logger.info('Restarting server in 30 seconds...')
|
||||
await wait(30)
|
||||
startServer()
|
||||
return false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
startServer();
|
||||
};
|
||||
startServer()
|
||||
}
|
||||
|
||||
module.exports = server;
|
||||
module.exports = server
|
||||
|
|
|
|||
|
|
@ -6,52 +6,130 @@ const { Buffer } = require('buffer')
|
|||
const APIKeyPair = new Map()
|
||||
const authorizedDevices = new Map()
|
||||
|
||||
module.exports = {
|
||||
encrypt: ({ deviceId, message }) => {
|
||||
const Encryption = {
|
||||
encryptKey: ({ deviceId, message }) => {
|
||||
if (!authorizedDevices.has(deviceId)) {
|
||||
throw { field: 'deviceId', message: 'Unknown Device ID' }
|
||||
}
|
||||
|
||||
const devicePublicKey = authorizedDevices.get(deviceId)
|
||||
const data = Buffer.from(message)
|
||||
const encryptedData = Crypto.publicEncrypt(devicePublicKey, data)
|
||||
const encryptedData = Crypto.publicEncrypt(
|
||||
{
|
||||
key: devicePublicKey,
|
||||
padding: Crypto.constants.RSA_PKCS1_PADDING
|
||||
},
|
||||
data
|
||||
)
|
||||
return encryptedData.toString('base64')
|
||||
},
|
||||
decrypt: ({ deviceId, message }) => {
|
||||
decryptKey: ({ deviceId, message }) => {
|
||||
if (!authorizedDevices.has(deviceId)) {
|
||||
throw { field: 'deviceId', message: 'Unknown Device ID' }
|
||||
}
|
||||
|
||||
const data = Buffer.from(message, 'base64')
|
||||
const encryptedData = Crypto.privateDecrypt(APIKeyPair.private, data)
|
||||
return encryptedData.toString('base64')
|
||||
const encryptedData = Crypto.privateDecrypt(
|
||||
{
|
||||
key: APIKeyPair.get(deviceId).privateKey,
|
||||
padding: Crypto.constants.RSA_PKCS1_PADDING
|
||||
},
|
||||
data
|
||||
)
|
||||
console.log('Decrypted Data:', encryptedData)
|
||||
return encryptedData.toString()
|
||||
},
|
||||
encryptMessage: ({ deviceId, message }) => {
|
||||
const parsedMessage =
|
||||
typeof message === 'object' ? JSON.stringify(message) : message
|
||||
const data = Buffer.from(parsedMessage)
|
||||
const key = Crypto.randomBytes(32)
|
||||
const iv = Crypto.randomBytes(16)
|
||||
const encryptedKey = Encryption.encryptKey({
|
||||
deviceId,
|
||||
message: key.toString('hex')
|
||||
})
|
||||
const cipher = Crypto.createCipheriv('aes-256-cbc', key, iv)
|
||||
const encryptedCipher = cipher.update(data)
|
||||
const encryptedBuffer = Buffer.concat([
|
||||
Buffer.from(encryptedCipher),
|
||||
Buffer.from(cipher.final())
|
||||
])
|
||||
const encryptedData = encryptedBuffer.toString('base64')
|
||||
return { encryptedData, encryptedKey, iv: iv.toString('hex') }
|
||||
},
|
||||
decryptMessage: ({ message, key, iv }) => {
|
||||
const data = Buffer.from(message, 'base64')
|
||||
const cipher = Crypto.createDecipheriv(
|
||||
'aes-256-cbc',
|
||||
Buffer.from(key, 'hex'),
|
||||
Buffer.from(iv, 'hex')
|
||||
)
|
||||
const decryptedCipher = cipher.update(data)
|
||||
const decryptedBuffer = Buffer.concat([
|
||||
Buffer.from(decryptedCipher),
|
||||
Buffer.from(cipher.final())
|
||||
])
|
||||
const decryptedData = decryptedBuffer.toString()
|
||||
console.log('Decrypted Data:', decryptedData)
|
||||
return decryptedData.toString()
|
||||
},
|
||||
isAuthorizedDevice: ({ deviceId }) => {
|
||||
console.log(
|
||||
'deviceId',
|
||||
deviceId,
|
||||
Object.fromEntries(authorizedDevices.entries()),
|
||||
authorizedDevices.has(deviceId)
|
||||
)
|
||||
if (authorizedDevices.has(deviceId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
authorizeDevice: ({ deviceId, publicKey }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (authorizedDevices.has(deviceId)) {
|
||||
const error = { success: false, message: 'Device already exists' }
|
||||
reject(error)
|
||||
return error
|
||||
const devicePublicKey = APIKeyPair.get(deviceId).publicKey
|
||||
const deviceExists = {
|
||||
success: true,
|
||||
APIPublicKey: devicePublicKey
|
||||
}
|
||||
resolve(deviceExists)
|
||||
return deviceExists
|
||||
}
|
||||
|
||||
authorizedDevices.set(deviceId, publicKey)
|
||||
|
||||
Crypto.generateKeyPair(
|
||||
'rsa',
|
||||
{
|
||||
modulusLength: 4096
|
||||
modulusLength: 4096,
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs1',
|
||||
format: 'pem'
|
||||
},
|
||||
publicKeyEncoding: {
|
||||
type: 'pkcs1',
|
||||
format: 'pem'
|
||||
}
|
||||
},
|
||||
(err, publicKey, privateKey) => {
|
||||
if (err) {
|
||||
reject({ field: 'APIKeyPair', errorMessage: err })
|
||||
console.error(err)
|
||||
reject(err)
|
||||
return err
|
||||
}
|
||||
|
||||
APIKeyPair.set(deviceId, {
|
||||
const exportedKey = {
|
||||
publicKey,
|
||||
privateKey
|
||||
}
|
||||
|
||||
APIKeyPair.set(deviceId, exportedKey)
|
||||
resolve({
|
||||
success: true,
|
||||
APIPublicKey: exportedKey.publicKey
|
||||
})
|
||||
resolve({ success: true })
|
||||
}
|
||||
)
|
||||
}),
|
||||
|
|
@ -59,3 +137,5 @@ module.exports = {
|
|||
authorizedDevices.delete(deviceId)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Encryption
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ module.exports = {
|
|||
"/api/lnd/connect": true,
|
||||
"/api/lnd/wallet": true,
|
||||
"/api/lnd/wallet/existing": true,
|
||||
"/api/lnd/auth": true
|
||||
"/api/lnd/auth": true,
|
||||
"/api/security/exchangeKeys": true
|
||||
},
|
||||
PUT: {},
|
||||
DELETE: {}
|
||||
|
|
@ -26,5 +27,6 @@ module.exports = {
|
|||
},
|
||||
PUT: {},
|
||||
DELETE: {}
|
||||
}
|
||||
},
|
||||
nonEncryptedRoutes: ['/api/security/exchangeKeys', '/healthz', '/ping', '/api/lnd/wallet/status']
|
||||
}
|
||||
|
|
@ -873,7 +873,7 @@ ascli@~1:
|
|||
colour "~0.7.1"
|
||||
optjs "~3.2.2"
|
||||
|
||||
asn1@~0.2.3:
|
||||
asn1@^0.2.4, asn1@~0.2.3:
|
||||
version "0.2.4"
|
||||
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
|
||||
integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
|
||||
|
|
@ -4341,6 +4341,13 @@ node-pre-gyp@^0.13.0:
|
|||
semver "^5.3.0"
|
||||
tar "^4"
|
||||
|
||||
node-rsa@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/node-rsa/-/node-rsa-1.0.7.tgz#85b7a6d6fa8ee624be6402a6b41be49272d58055"
|
||||
integrity sha512-idwRXma6scFufZmbaKkHpJoLL93yynRefP6yur13wZ5i9FR35ex451KCoF2OORDeJanyRVahmjjiwmUlCnTqJA==
|
||||
dependencies:
|
||||
asn1 "^0.2.4"
|
||||
|
||||
nodemon@^1.19.3:
|
||||
version "1.19.3"
|
||||
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.19.3.tgz#db71b3e62aef2a8e1283a9fa00164237356102c0"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue