thelounge-plugin-ntfy 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/docker-compose.yml +3 -0
- package/index.js +11 -7
- package/package.json +2 -2
- package/src/config.js +75 -4
- package/src/crypto.js +111 -0
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# The Lounge ntfy Plugin
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
3
6
|
A plugin for [The Lounge](https://thelounge.chat/) that sends a message to an [ntfy](https://ntfy.sh/) server whenever you are mentioned in a chat.
|
|
4
7
|
|
|
5
8
|
## Installation
|
package/docker-compose.yml
CHANGED
|
@@ -12,3 +12,6 @@ services:
|
|
|
12
12
|
- $PWD/index.js:/var/opt/thelounge/packages/thelounge-plugin-ntfy/index.js
|
|
13
13
|
- $PWD/package.json:/var/opt/thelounge/packages/thelounge-plugin-ntfy/package.json
|
|
14
14
|
- $PWD/package-lock.json:/var/opt/thelounge/packages/thelounge-plugin-ntfy/package-lock.json
|
|
15
|
+
|
|
16
|
+
# Installation: su node -c "thelounge install file:/var/opt/thelounge/packages/thelounge-plugin-ntfy"
|
|
17
|
+
# Removal: su node -c "thelounge uninstall thelounge-plugin-ntfy"
|
package/index.js
CHANGED
|
@@ -22,17 +22,17 @@ const ntfyCommand = {
|
|
|
22
22
|
say(`/${command} start - Start the ntfy listener for this network`);
|
|
23
23
|
say(`/${command} stop - Stop the ntfy listener for this network`);
|
|
24
24
|
say(
|
|
25
|
-
`/${command} status - Show the ntfy listener status for this network
|
|
25
|
+
`/${command} status - Show the ntfy listener status for this network`,
|
|
26
26
|
);
|
|
27
27
|
say(`/${command} test - Send a test notification`);
|
|
28
28
|
say(
|
|
29
|
-
`/${command} config set <setting_key> <setting_value> - Set a configuration setting
|
|
29
|
+
`/${command} config set <setting_key> <setting_value> - Set a configuration setting`,
|
|
30
30
|
);
|
|
31
31
|
say(
|
|
32
|
-
`/${command} config remove <setting_key> - Set configuration setting to null
|
|
32
|
+
`/${command} config remove <setting_key> - Set configuration setting to null`,
|
|
33
33
|
);
|
|
34
34
|
say(
|
|
35
|
-
`/${command} config print - Print the current configuration with warnings if any
|
|
35
|
+
`/${command} config print - Print the current configuration with warnings if any`,
|
|
36
36
|
);
|
|
37
37
|
};
|
|
38
38
|
|
|
@@ -197,7 +197,7 @@ const ntfyCommand = {
|
|
|
197
197
|
const response = saveUserSetting(
|
|
198
198
|
client.client.name,
|
|
199
199
|
settingKey,
|
|
200
|
-
settingValue
|
|
200
|
+
settingValue,
|
|
201
201
|
);
|
|
202
202
|
|
|
203
203
|
say(response);
|
|
@@ -217,7 +217,7 @@ const ntfyCommand = {
|
|
|
217
217
|
const response = saveUserSetting(
|
|
218
218
|
client.client.name,
|
|
219
219
|
settingKey,
|
|
220
|
-
null
|
|
220
|
+
null,
|
|
221
221
|
);
|
|
222
222
|
|
|
223
223
|
say(response);
|
|
@@ -228,6 +228,8 @@ const ntfyCommand = {
|
|
|
228
228
|
case "print": {
|
|
229
229
|
const [userConfig, errors] = loadUserConfig(client.client.name);
|
|
230
230
|
|
|
231
|
+
const sensitiveKeys = new Set(["ntfy.password", "ntfy.token"]);
|
|
232
|
+
|
|
231
233
|
const printConfig = (obj, parentKey = "") => {
|
|
232
234
|
for (const key in obj) {
|
|
233
235
|
const value = obj[key];
|
|
@@ -235,6 +237,8 @@ const ntfyCommand = {
|
|
|
235
237
|
|
|
236
238
|
if (typeof value === "object" && value !== null) {
|
|
237
239
|
printConfig(value, fullKey);
|
|
240
|
+
} else if (sensitiveKeys.has(fullKey) && value) {
|
|
241
|
+
say(`${fullKey}=********`);
|
|
238
242
|
} else {
|
|
239
243
|
say(`${fullKey}=${value}`);
|
|
240
244
|
}
|
|
@@ -257,7 +261,7 @@ const ntfyCommand = {
|
|
|
257
261
|
userConfig.ntfy.password
|
|
258
262
|
) {
|
|
259
263
|
say(
|
|
260
|
-
"Warning: Both ntfy.token and ntfy.username/password are set, ntfy.token will be used for authentication"
|
|
264
|
+
"Warning: Both ntfy.token and ntfy.username/password are set, ntfy.token will be used for authentication",
|
|
261
265
|
);
|
|
262
266
|
}
|
|
263
267
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thelounge-plugin-ntfy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "A plugin for The Lounge that sends push notifications via ntfy when highlighted",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"thelounge",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"ajv": "^8.17.1",
|
|
30
30
|
"ajv-errors": "^3.0.0",
|
|
31
31
|
"ajv-formats": "^3.0.1",
|
|
32
|
-
"ntfy": "
|
|
32
|
+
"ntfy": "~1.13.3",
|
|
33
33
|
"thelounge": "^4.4.3"
|
|
34
34
|
}
|
|
35
35
|
}
|
package/src/config.js
CHANGED
|
@@ -5,6 +5,12 @@ const path = require("path");
|
|
|
5
5
|
const Ajv2019 = require("ajv/dist/2019").default;
|
|
6
6
|
const addFormats = require("ajv-formats");
|
|
7
7
|
const addErrors = require("ajv-errors");
|
|
8
|
+
const {
|
|
9
|
+
initEncryptionKey,
|
|
10
|
+
encrypt,
|
|
11
|
+
decrypt,
|
|
12
|
+
isEncrypted,
|
|
13
|
+
} = require("./crypto.js");
|
|
8
14
|
|
|
9
15
|
const DEFAULT_CONFIG = {
|
|
10
16
|
ntfy: {
|
|
@@ -30,6 +36,8 @@ const ALLOWED_KEYS = new Set([
|
|
|
30
36
|
|
|
31
37
|
const BOOLEAN_KEYS = new Set(["config.notify_on_private_messages"]);
|
|
32
38
|
|
|
39
|
+
const SENSITIVE_KEYS = new Set(["ntfy.password", "ntfy.token"]);
|
|
40
|
+
|
|
33
41
|
const userConfigSchema = {
|
|
34
42
|
type: "object",
|
|
35
43
|
additionalProperties: false,
|
|
@@ -121,6 +129,38 @@ let rootDir = null;
|
|
|
121
129
|
|
|
122
130
|
function setRootDir(dir) {
|
|
123
131
|
rootDir = dir;
|
|
132
|
+
initEncryptionKey(dir);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function decryptSensitiveFields(config) {
|
|
136
|
+
const decrypted = JSON.parse(JSON.stringify(config)); // Deep clone
|
|
137
|
+
|
|
138
|
+
for (const key of SENSITIVE_KEYS) {
|
|
139
|
+
const keys = key.split(".");
|
|
140
|
+
let curr = decrypted;
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
143
|
+
if (curr[keys[i]]) {
|
|
144
|
+
curr = curr[keys[i]];
|
|
145
|
+
} else {
|
|
146
|
+
curr = null;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (curr) {
|
|
152
|
+
const finalKey = keys[keys.length - 1];
|
|
153
|
+
if (curr[finalKey] && isEncrypted(curr[finalKey])) {
|
|
154
|
+
try {
|
|
155
|
+
curr[finalKey] = decrypt(curr[finalKey]);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// Leave as-is
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return decrypted;
|
|
124
164
|
}
|
|
125
165
|
|
|
126
166
|
function loadUserConfig(username) {
|
|
@@ -142,10 +182,12 @@ function loadUserConfig(username) {
|
|
|
142
182
|
userConfig = JSON.parse(fs.readFileSync(userConfigPath, "utf-8"));
|
|
143
183
|
} catch (e) {
|
|
144
184
|
throw new Error(
|
|
145
|
-
`Invalid JSON in user config for ${username}: ${e.message}
|
|
185
|
+
`Invalid JSON in user config for ${username}: ${e.message}`,
|
|
146
186
|
);
|
|
147
187
|
}
|
|
148
188
|
|
|
189
|
+
userConfig = decryptSensitiveFields(userConfig);
|
|
190
|
+
|
|
149
191
|
const validate = ajv.compile(userConfigSchema);
|
|
150
192
|
const valid = validate(userConfig);
|
|
151
193
|
|
|
@@ -153,6 +195,33 @@ function loadUserConfig(username) {
|
|
|
153
195
|
}
|
|
154
196
|
}
|
|
155
197
|
|
|
198
|
+
function encryptSensitiveFields(config) {
|
|
199
|
+
const encrypted = JSON.parse(JSON.stringify(config)); // Deep clone
|
|
200
|
+
|
|
201
|
+
for (const key of SENSITIVE_KEYS) {
|
|
202
|
+
const keys = key.split(".");
|
|
203
|
+
let curr = encrypted;
|
|
204
|
+
|
|
205
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
206
|
+
if (curr[keys[i]]) {
|
|
207
|
+
curr = curr[keys[i]];
|
|
208
|
+
} else {
|
|
209
|
+
curr = null;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (curr) {
|
|
215
|
+
const finalKey = keys[keys.length - 1];
|
|
216
|
+
if (curr[finalKey] && !isEncrypted(curr[finalKey])) {
|
|
217
|
+
curr[finalKey] = encrypt(curr[finalKey]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return encrypted;
|
|
223
|
+
}
|
|
224
|
+
|
|
156
225
|
function saveUserSetting(username, settingKey, settingValue) {
|
|
157
226
|
if (!rootDir) {
|
|
158
227
|
throw new Error("Root directory is not set");
|
|
@@ -192,20 +261,22 @@ function saveUserSetting(username, settingKey, settingValue) {
|
|
|
192
261
|
|
|
193
262
|
curr[keys[keys.length - 1]] = settingValue;
|
|
194
263
|
|
|
264
|
+
const configToSave = encryptSensitiveFields(userConfig);
|
|
265
|
+
|
|
195
266
|
const userConfigPath = path.join(rootDir, "config", `${username}.json`);
|
|
196
267
|
|
|
197
268
|
fs.mkdirSync(path.dirname(userConfigPath), { recursive: true });
|
|
198
269
|
fs.writeFileSync(
|
|
199
270
|
userConfigPath,
|
|
200
|
-
JSON.stringify(
|
|
201
|
-
"utf-8"
|
|
271
|
+
JSON.stringify(configToSave, null, 2),
|
|
272
|
+
"utf-8",
|
|
202
273
|
);
|
|
203
274
|
|
|
204
275
|
return "Success";
|
|
205
276
|
}
|
|
206
277
|
|
|
207
278
|
return `Invalid setting ${settingKey}, allowed settings are: ${Array.from(
|
|
208
|
-
ALLOWED_KEYS
|
|
279
|
+
ALLOWED_KEYS,
|
|
209
280
|
).join(", ")}`;
|
|
210
281
|
}
|
|
211
282
|
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const ALGORITHM = "aes-256-gcm";
|
|
8
|
+
const KEY_LENGTH = 32; // 256 bits
|
|
9
|
+
const IV_LENGTH = 16; // 128 bits
|
|
10
|
+
const AUTH_TAG_LENGTH = 16; // 128 bits
|
|
11
|
+
const ENCRYPTED_PREFIX = "enc:";
|
|
12
|
+
|
|
13
|
+
let encryptionKey = null;
|
|
14
|
+
|
|
15
|
+
function initEncryptionKey(configDir) {
|
|
16
|
+
const keyPath = path.join(configDir, ".ntfy_key");
|
|
17
|
+
|
|
18
|
+
if (fs.existsSync(keyPath)) {
|
|
19
|
+
const keyData = fs.readFileSync(keyPath, "utf-8").trim();
|
|
20
|
+
encryptionKey = Buffer.from(keyData, "hex");
|
|
21
|
+
|
|
22
|
+
if (encryptionKey.length !== KEY_LENGTH) {
|
|
23
|
+
throw new Error("Invalid encryption key length");
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
encryptionKey = crypto.randomBytes(KEY_LENGTH);
|
|
27
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
28
|
+
fs.writeFileSync(keyPath, encryptionKey.toString("hex"), {
|
|
29
|
+
encoding: "utf-8",
|
|
30
|
+
mode: 0o600, // R/W for owner only
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isInitialized() {
|
|
36
|
+
return encryptionKey !== null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function encrypt(plaintext) {
|
|
40
|
+
if (!encryptionKey) {
|
|
41
|
+
throw new Error("Encryption key not initialized");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!plaintext || typeof plaintext !== "string") {
|
|
45
|
+
return plaintext;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (plaintext.startsWith(ENCRYPTED_PREFIX)) {
|
|
49
|
+
return plaintext;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
53
|
+
const cipher = crypto.createCipheriv(ALGORITHM, encryptionKey, iv, {
|
|
54
|
+
authTagLength: AUTH_TAG_LENGTH,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
|
58
|
+
encrypted += cipher.final("hex");
|
|
59
|
+
|
|
60
|
+
const authTag = cipher.getAuthTag();
|
|
61
|
+
|
|
62
|
+
return `${ENCRYPTED_PREFIX}${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function decrypt(encryptedText) {
|
|
66
|
+
if (!encryptionKey) {
|
|
67
|
+
throw new Error("Encryption key not initialized");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!encryptedText || typeof encryptedText !== "string") {
|
|
71
|
+
return encryptedText;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!encryptedText.startsWith(ENCRYPTED_PREFIX)) {
|
|
75
|
+
return encryptedText;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const data = encryptedText.slice(ENCRYPTED_PREFIX.length);
|
|
79
|
+
const parts = data.split(":");
|
|
80
|
+
|
|
81
|
+
if (parts.length !== 3) {
|
|
82
|
+
throw new Error("Invalid encrypted data format");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const [ivHex, authTagHex, encrypted] = parts;
|
|
86
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
87
|
+
const authTag = Buffer.from(authTagHex, "hex");
|
|
88
|
+
|
|
89
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKey, iv, {
|
|
90
|
+
authTagLength: AUTH_TAG_LENGTH,
|
|
91
|
+
});
|
|
92
|
+
decipher.setAuthTag(authTag);
|
|
93
|
+
|
|
94
|
+
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
|
95
|
+
decrypted += decipher.final("utf8");
|
|
96
|
+
|
|
97
|
+
return decrypted;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isEncrypted(value) {
|
|
101
|
+
return typeof value === "string" && value.startsWith(ENCRYPTED_PREFIX);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
initEncryptionKey,
|
|
106
|
+
isInitialized,
|
|
107
|
+
encrypt,
|
|
108
|
+
decrypt,
|
|
109
|
+
isEncrypted,
|
|
110
|
+
ENCRYPTED_PREFIX,
|
|
111
|
+
};
|