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 CHANGED
@@ -1,5 +1,8 @@
1
1
  # The Lounge ntfy Plugin
2
2
 
3
+ ![NPM Version](https://img.shields.io/npm/v/thelounge-plugin-ntfy?style=for-the-badge)
4
+ ![NPM Downloads](https://img.shields.io/npm/dy/thelounge-plugin-ntfy?style=for-the-badge)
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
@@ -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.2.0",
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": "^1.11.8",
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(userConfig, null, 2),
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
+ };