nodebb-plugin-phone-verification 1.2.2

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/library.js ADDED
@@ -0,0 +1,758 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const https = require('https'); // הוספנו עבור SSL
5
+
6
+ // NodeBB modules
7
+ let db;
8
+ let User;
9
+ let meta;
10
+ let SocketPlugins;
11
+
12
+ const plugin = {};
13
+
14
+ // קבועים
15
+ const CODE_EXPIRY_MINUTES = 5;
16
+ const MAX_ATTEMPTS = 3;
17
+ const BLOCK_DURATION_MINUTES = 15;
18
+ const PHONE_FIELD_KEY = 'phoneNumber';
19
+ const DEBUG_SKIP_VERIFICATION = false;
20
+ const REDIS_PREFIX = 'phone-verification:code:';
21
+ const IP_RATE_LIMIT_PREFIX = 'phone-verification:ip:';
22
+ const MAX_REQUESTS_PER_IP = 10;
23
+ const IP_BLOCK_HOURS = 24;
24
+
25
+ // ==================== הגדרות ברירת מחדל ====================
26
+ const defaultSettings = {
27
+ voiceServerUrl: 'https://www.call2all.co.il/ym/api/RunCampaign',
28
+ voiceServerApiKey: '',
29
+ voiceServerEnabled: false,
30
+ blockUnverifiedUsers: false,
31
+ voiceTtsMode: '1',
32
+ voiceMessageTemplate: 'הקוד שלך לאתר {siteTitle} הוא {code} אני חוזר. הקוד הוא {code}'
33
+ };
34
+
35
+ // ==================== פונקציות עזר ====================
36
+
37
+ plugin.validatePhoneNumber = function (phone) {
38
+ if (!phone || typeof phone !== 'string') return false;
39
+ const cleanPhone = phone.replace(/[-\s]/g, '');
40
+ const phoneRegex = /^05\d{8}$/;
41
+ return phoneRegex.test(cleanPhone);
42
+ };
43
+
44
+ plugin.normalizePhone = function (phone) {
45
+ if (!phone || typeof phone !== 'string') return '';
46
+ return phone.replace(/[-\s]/g, '');
47
+ };
48
+
49
+ plugin.generateVerificationCode = function () {
50
+ const randomBytes = crypto.randomBytes(3);
51
+ const number = randomBytes.readUIntBE(0, 3) % 1000000;
52
+ return number.toString().padStart(6, '0');
53
+ };
54
+
55
+ plugin.hashCode = function (code) {
56
+ return crypto.createHash('sha256').update(code).digest('hex');
57
+ };
58
+
59
+ plugin.formatCodeForSpeech = function (code) {
60
+ return code.split('').join(' ');
61
+ };
62
+
63
+ // ==================== בדיקת הרשאות ====================
64
+
65
+ plugin.checkPostingPermissions = async function (data) {
66
+ const uid = data.uid || (data.post && data.post.uid) || (data.topic && data.topic.uid);
67
+ if (!uid || parseInt(uid, 10) === 0) return data;
68
+
69
+ const settings = await plugin.getSettings();
70
+ if (!settings.blockUnverifiedUsers) return data;
71
+
72
+ const isAdmin = await User.isAdministrator(uid);
73
+ if (isAdmin) return data;
74
+
75
+ const phoneData = await plugin.getUserPhone(uid);
76
+ if (!phoneData || !phoneData.phoneVerified) {
77
+ const userSlug = await User.getUserField(uid, 'userslug');
78
+ const editUrl = userSlug ? `/user/${userSlug}/edit` : '/user/me/edit';
79
+ throw new Error('חובה לאמת מספר טלפון כדי להמשיך את הפעילות בפורום.<br/>אנא גש להגדרות הפרופיל שלך.');
80
+ }
81
+ return data;
82
+ };
83
+
84
+ plugin.checkVotingPermissions = async function (data) {
85
+ const uid = data.uid;
86
+ if (!uid || parseInt(uid, 10) === 0) return data;
87
+
88
+ const settings = await plugin.getSettings();
89
+ if (!settings.blockUnverifiedUsers) return data;
90
+
91
+ const isAdmin = await User.isAdministrator(uid);
92
+ if (isAdmin) return data;
93
+
94
+ const phoneData = await plugin.getUserPhone(uid);
95
+ if (!phoneData || !phoneData.phoneVerified) {
96
+ throw new Error('חובה לאמת מספר טלפון כדי להמשיך את הפעילות בפורום.<br/>אנא גש להגדרות הפרופיל שלך.');
97
+ }
98
+ return data;
99
+ };
100
+
101
+ plugin.checkMessagingPermissions = async function (data) {
102
+ const uid = data.fromUid;
103
+ if (!uid || parseInt(uid, 10) === 0) return data;
104
+ const settings = await plugin.getSettings();
105
+ if (!settings.blockUnverifiedUsers) return data;
106
+ const isAdmin = await User.isAdministrator(uid);
107
+ if (isAdmin) return data;
108
+ const phoneData = await plugin.getUserPhone(uid);
109
+ if (phoneData && phoneData.phoneVerified) {
110
+ return data;
111
+ }
112
+ const Messaging = require.main.require('./src/messaging');
113
+ const roomUids = await Messaging.getUidsInRoom(data.roomId, 0, -1);
114
+ const targetUids = roomUids.filter(id => parseInt(id, 10) !== parseInt(uid, 10));
115
+ let isChattingWithAdmin = false;
116
+ for (const targetUid of targetUids) {
117
+ const targetIsAdmin = await User.isAdministrator(targetUid);
118
+ if (targetIsAdmin) {
119
+ isChattingWithAdmin = true;
120
+ break;
121
+ }
122
+ }
123
+ if (isChattingWithAdmin) return data;
124
+ throw new Error('חובה לאמת מספר טלפון כדי להמשיך את הפעילות בפורום.<br/>אנא גש להגדרות הפרופיל שלך.');
125
+ };
126
+
127
+ // ==================== שליחת שיחה קולית ====================
128
+
129
+ plugin.sendVoiceCall = async function (phone, code) {
130
+ const settings = await plugin.getSettings();
131
+ if (!meta) meta = require.main.require('./src/meta');
132
+ const siteTitle = meta.config.title || 'האתר';
133
+
134
+ if (!settings.voiceServerEnabled || !settings.voiceServerApiKey) {
135
+ return { success: false, error: 'VOICE_SERVER_DISABLED', message: 'שרת השיחות לא מוגדר' };
136
+ }
137
+
138
+ try {
139
+ const spokenCode = plugin.formatCodeForSpeech(code);
140
+ let messageText = settings.voiceMessageTemplate || defaultSettings.voiceMessageTemplate;
141
+ messageText = messageText.replace(/{code}/g, spokenCode).replace(/{siteTitle}/g, siteTitle);
142
+
143
+ const phonesData = {};
144
+ phonesData[phone] = { name: 'משתמש', moreinfo: messageText, blocked: false };
145
+
146
+ const baseUrl = settings.voiceServerUrl || defaultSettings.voiceServerUrl;
147
+ const params = new URLSearchParams({
148
+ ttsMode: settings.voiceTtsMode || defaultSettings.voiceTtsMode,
149
+ phones: JSON.stringify(phonesData),
150
+ token: settings.voiceServerApiKey
151
+ });
152
+
153
+ const url = `${baseUrl}?${params.toString()}`;
154
+
155
+ // עקיפת בעיות SSL (אופציונלי)
156
+ const agent = new https.Agent({ rejectUnauthorized: false });
157
+
158
+ const response = await fetch(url, { method: 'GET', agent: agent });
159
+
160
+ if (!response.ok) return { success: false, error: 'VOICE_SERVER_ERROR', message: 'שגיאה בשרת השיחות' };
161
+ const result = await response.json();
162
+
163
+ if (result.responseStatus === 'OK' || result.responseStatus === 'WAITING') {
164
+ return { success: true, result };
165
+ } else {
166
+ return { success: false, error: 'VOICE_SERVER_ERROR', message: result.message || 'שגיאה בשליחת השיחה' };
167
+ }
168
+ } catch (err) {
169
+ console.error(err);
170
+ return { success: false, error: 'VOICE_SERVER_ERROR', message: 'שגיאת תקשורת' };
171
+ }
172
+ };
173
+
174
+ // ==================== ניהול נתונים (Redis) ====================
175
+
176
+ plugin.saveVerificationCode = async function (phone, code) {
177
+ const normalizedPhone = plugin.normalizePhone(phone);
178
+ const now = Date.now();
179
+ const expiresAt = now + (CODE_EXPIRY_MINUTES * 60 * 1000);
180
+ const key = `${REDIS_PREFIX}${normalizedPhone}`;
181
+
182
+ if (!db) return { success: false, error: 'DB_ERROR' };
183
+
184
+ const existing = await db.getObject(key);
185
+ if (existing && existing.blockedUntil && parseInt(existing.blockedUntil, 10) > now) {
186
+ return { success: false, error: 'PHONE_BLOCKED', message: 'המספר חסום זמנית' };
187
+ }
188
+
189
+ const data = { hashedCode: plugin.hashCode(code), attempts: 0, createdAt: now, expiresAt: expiresAt, blockedUntil: 0 };
190
+ await db.setObject(key, data);
191
+ await db.pexpireAt(key, now + (20 * 60 * 1000));
192
+ return { success: true, expiresAt };
193
+ };
194
+
195
+ plugin.verifyCode = async function (phone, code) {
196
+ const normalizedPhone = plugin.normalizePhone(phone);
197
+ const now = Date.now();
198
+ const key = `${REDIS_PREFIX}${normalizedPhone}`;
199
+
200
+ if (!db) return { success: false, error: 'DB_ERROR' };
201
+ const data = await db.getObject(key);
202
+
203
+ if (!data) return { success: false, error: 'CODE_NOT_FOUND', message: 'לא נמצא קוד אימות' };
204
+ if (data.blockedUntil && parseInt(data.blockedUntil, 10) > now) {
205
+ return { success: false, error: 'PHONE_BLOCKED', message: 'המספר חסום זמנית' };
206
+ }
207
+ if (parseInt(data.expiresAt, 10) < now) return { success: false, error: 'CODE_EXPIRED', message: 'הקוד פג תוקף' };
208
+
209
+ if (plugin.hashCode(code) === data.hashedCode) {
210
+ await db.delete(key);
211
+ return { success: true };
212
+ }
213
+
214
+ const attempts = parseInt(data.attempts, 10) + 1;
215
+ if (attempts >= MAX_ATTEMPTS) {
216
+ const blockedUntil = now + (BLOCK_DURATION_MINUTES * 60 * 1000);
217
+ await db.setObjectField(key, 'blockedUntil', blockedUntil);
218
+ await db.setObjectField(key, 'attempts', attempts);
219
+ return { success: false, error: 'PHONE_BLOCKED', message: 'יותר מדי ניסיונות שגויים' };
220
+ }
221
+ await db.setObjectField(key, 'attempts', attempts);
222
+ return { success: false, error: 'CODE_INVALID', message: 'קוד שגוי' };
223
+ };
224
+
225
+ plugin.checkIpRateLimit = async function (ip) {
226
+ if (!db || !ip) return { allowed: true };
227
+ const key = `${IP_RATE_LIMIT_PREFIX}${ip}`;
228
+ const count = await db.get(key);
229
+ if (count && parseInt(count, 10) >= MAX_REQUESTS_PER_IP) {
230
+ return { allowed: false, error: 'IP_BLOCKED', message: 'חסימת IP זמנית' };
231
+ }
232
+ return { allowed: true };
233
+ };
234
+
235
+ plugin.incrementIpCounter = async function (ip) {
236
+ if (!db || !ip) return;
237
+ const key = `${IP_RATE_LIMIT_PREFIX}${ip}`;
238
+ const exists = await db.exists(key);
239
+ await db.increment(key);
240
+ if (!exists) await db.pexpireAt(key, Date.now() + (IP_BLOCK_HOURS * 60 * 60 * 1000));
241
+ };
242
+
243
+ plugin.markPhoneAsVerified = async function (phone) {
244
+ const normalizedPhone = plugin.normalizePhone(phone);
245
+ if (!db) return;
246
+ const key = `phone-verification:verified:${normalizedPhone}`;
247
+ await db.set(key, Date.now());
248
+ await db.pexpireAt(key, Date.now() + (600 * 1000));
249
+ };
250
+
251
+ plugin.isPhoneVerified = async function (phone) {
252
+ const normalizedPhone = plugin.normalizePhone(phone);
253
+ if (!db) return false;
254
+ const key = `phone-verification:verified:${normalizedPhone}`;
255
+ const verifiedAt = await db.get(key);
256
+ return !!verifiedAt;
257
+ };
258
+
259
+ plugin.clearVerifiedPhone = async function (phone) {
260
+ const normalizedPhone = plugin.normalizePhone(phone);
261
+ if (db) await db.delete(`phone-verification:verified:${normalizedPhone}`);
262
+ };
263
+
264
+ // ==================== DB Users Logic ====================
265
+ plugin.savePhoneToUser = async function (uid, phone, verified = true, forceOverride = false) {
266
+ if (!db || !User) return { success: false };
267
+
268
+ // 1. מקרה של אימות ללא טלפון
269
+ if (!phone) {
270
+ await User.setUserFields(uid, {
271
+ phoneVerified: verified ? 1 : 0,
272
+ phoneVerifiedAt: verified ? Date.now() : 0
273
+ });
274
+ const oldPhoneData = await plugin.getUserPhone(uid);
275
+ if (oldPhoneData && oldPhoneData.phone) {
276
+ await db.sortedSetRemove('phone:uid', oldPhoneData.phone);
277
+ }
278
+ await db.sortedSetAdd('users:phone', Date.now(), uid);
279
+ return { success: true };
280
+ }
281
+
282
+ const normalizedPhone = plugin.normalizePhone(phone);
283
+ const existingUid = await db.sortedSetScore('phone:uid', normalizedPhone);
284
+
285
+ // 2. בדיקת כפילות
286
+ if (existingUid) {
287
+ // אם המספר שייך למשתמש אחר
288
+ if (parseInt(existingUid, 10) !== parseInt(uid, 10)) {
289
+ if (forceOverride) {
290
+ // === תיקון: דריסה בכוח (למנהלים) ===
291
+ // מחיקת המספר מהמשתמש הישן
292
+ console.log(`[phone-verification] Force overwriting phone ${normalizedPhone} from user ${existingUid} to ${uid}`);
293
+
294
+ // הסרה מה-Set של המשתמש הישן
295
+ await User.setUserFields(existingUid, {
296
+ [PHONE_FIELD_KEY]: '',
297
+ phoneVerified: 0,
298
+ phoneVerifiedAt: 0
299
+ });
300
+ await db.sortedSetRemove('users:phone', existingUid);
301
+ // (הערה: לא צריך להסיר מ-phone:uid כי אנחנו דורסים אותו מייד למטה)
302
+ } else {
303
+ // אם זה לא מנהל - זרוק שגיאה
304
+ return { success: false, error: 'PHONE_EXISTS', message: 'המספר כבר רשום למשתמש אחר' };
305
+ }
306
+ }
307
+ }
308
+
309
+ const now = Date.now();
310
+ await User.setUserFields(uid, {
311
+ [PHONE_FIELD_KEY]: normalizedPhone,
312
+ phoneVerified: verified ? 1 : 0,
313
+ phoneVerifiedAt: verified ? now : 0
314
+ });
315
+
316
+ // עדכון/דריסה של הרשומה ב-DB
317
+ await db.sortedSetAdd('phone:uid', uid, normalizedPhone);
318
+ await db.sortedSetAdd('users:phone', now, uid);
319
+ return { success: true };
320
+ };
321
+
322
+ plugin.getUserPhone = async function (uid) {
323
+ if (!User) return null;
324
+ const userData = await User.getUserFields(uid, [PHONE_FIELD_KEY, 'phoneVerified', 'phoneVerifiedAt']);
325
+ if (!userData) return null;
326
+ return {
327
+ phone: userData[PHONE_FIELD_KEY] || '',
328
+ phoneVerified: parseInt(userData.phoneVerified, 10) === 1,
329
+ phoneVerifiedAt: parseInt(userData.phoneVerifiedAt, 10) || null
330
+ };
331
+ };
332
+
333
+ plugin.findUserByPhone = async function (phone) {
334
+ if (!db) return null;
335
+ const normalizedPhone = plugin.normalizePhone(phone);
336
+ const uid = await db.sortedSetScore('phone:uid', normalizedPhone);
337
+ return uid ? parseInt(uid, 10) : null;
338
+ };
339
+
340
+ plugin.getAllUsersWithPhones = async function (start = 0, stop = 49) {
341
+ if (!db || !User) return { users: [], total: 0 };
342
+ const total = await db.sortedSetCard('users:phone');
343
+ const uids = await db.getSortedSetRange('users:phone', start, stop);
344
+
345
+ if (!uids || !uids.length) return { users: [], total };
346
+
347
+ const users = await User.getUsersFields(uids, ['uid', 'username', PHONE_FIELD_KEY, 'phoneVerified', 'phoneVerifiedAt']);
348
+
349
+ const usersList = users.map(u => ({
350
+ uid: u.uid,
351
+ username: u.username,
352
+ phone: u[PHONE_FIELD_KEY] || '',
353
+ phoneVerified: parseInt(u.phoneVerified, 10) === 1,
354
+ phoneVerifiedAt: parseInt(u.phoneVerifiedAt, 10) || null
355
+ }));
356
+
357
+ return { users: usersList, total };
358
+ };
359
+
360
+ plugin.checkRegistration = async function (data) {
361
+ try {
362
+ const phoneNumber = data.req.body.phoneNumber;
363
+ const req = data.req;
364
+ const res = data.res;
365
+
366
+ if (!phoneNumber) {
367
+ throw new Error('חובה להזין מספר טלפון');
368
+ }
369
+
370
+ const normalizedPhone = plugin.normalizePhone(phoneNumber);
371
+ const existingUid = await plugin.findUserByPhone(normalizedPhone);
372
+
373
+ if (existingUid) {
374
+ // אם המשתמש מחובר ומנסה לעדכן, או אם המספר תפוס על ידי מישהו אחר
375
+ if (!req.uid || parseInt(existingUid, 10) !== parseInt(req.uid, 10)) {
376
+ throw new Error('מספר הטלפון כבר רשום במערכת למשתמש אחר');
377
+ }
378
+ }
379
+
380
+ // אם הגעת לכאן, הנתונים תקינים.
381
+ // ב-Hook של checkRegistration, פשוט מחזירים את data כדי להמשיך ברישום.
382
+ return data;
383
+
384
+ } catch (err) {
385
+ console.error('[phone-verification] Registration check error:', err);
386
+ throw err; // NodeBB יציג את השגיאה הזו למשתמש בטופס הרישום
387
+ }
388
+ };
389
+
390
+ plugin.userCreated = async function (data) {
391
+ const { user } = data;
392
+ const phoneNumber = data.data.phoneNumber;
393
+ if (phoneNumber && user && user.uid) {
394
+ await plugin.savePhoneToUser(user.uid, phoneNumber, true);
395
+ await plugin.clearVerifiedPhone(phoneNumber);
396
+ }
397
+ };
398
+
399
+ plugin.addAdminNavigation = async function (header) {
400
+ if (header.plugins) {
401
+ header.plugins.push({ route: '/plugins/phone-verification', icon: 'fa-phone', name: 'אימות טלפון' });
402
+ }
403
+ return header;
404
+ };
405
+
406
+ plugin.whitelistFields = async function (data) {
407
+ data.whitelist.push(PHONE_FIELD_KEY, 'phoneVerified', 'phoneVerifiedAt', 'showPhone');
408
+ return data;
409
+ };
410
+
411
+ plugin.addPhoneToAccount = async function (data) {
412
+ if (data.userData && data.userData.uid) {
413
+ const phoneData = await plugin.getUserPhone(data.userData.uid);
414
+ if (phoneData) {
415
+ data.userData.phoneNumber = phoneData.phone;
416
+ data.userData.phoneVerified = phoneData.phoneVerified;
417
+ }
418
+ const showPhone = await db.getObjectField(`user:${data.userData.uid}`, 'showPhone');
419
+ data.userData.showPhone = showPhone === '1' || showPhone === 1;
420
+ }
421
+ return data;
422
+ };
423
+
424
+ plugin.loadScript = async function (data) {
425
+ const pagesToLoad = ['register', 'account/edit', 'account/profile'];
426
+ if (pagesToLoad.includes(data.tpl_url) || pagesToLoad.includes(data.tpl)) {
427
+ if (!data.scripts.includes('forum/phone-verification')) {
428
+ data.scripts.push('forum/phone-verification');
429
+ }
430
+ }
431
+ return data;
432
+ };
433
+
434
+ // ==================== MAIN INIT ====================
435
+
436
+ plugin.init = async function (params) {
437
+ const { router, middleware } = params;
438
+ db = require.main.require('./src/database');
439
+ User = require.main.require('./src/user');
440
+ meta = require.main.require('./src/meta');
441
+ SocketPlugins = require.main.require('./src/socket.io/plugins');
442
+
443
+ // --- SOCKET.IO EVENTS ---
444
+ SocketPlugins.call2all = {};
445
+
446
+ // 1. פונקציה חדשה: מציאת משתמש לפי שם
447
+ SocketPlugins.call2all.getUidByUsername = async function (socket, data) {
448
+ if (!data || !data.username) throw new Error('נא לספק שם משתמש');
449
+ const uid = await User.getUidByUsername(data.username);
450
+ if (!uid) throw new Error('משתמש לא נמצא');
451
+ return uid;
452
+ };
453
+
454
+ // 2. הוספת משתמש מאומת (מתוקן)
455
+ SocketPlugins.call2all.adminAddVerifiedUser = async function (socket, data) {
456
+ if (!data || !data.uid) throw new Error('חסר מזהה משתמש');
457
+ const isAdmin = await User.isAdministrator(socket.uid);
458
+ if (!isAdmin) throw new Error('אין הרשאה');
459
+
460
+ let phone = null;
461
+ if (data.phone && data.phone.trim().length > 0) {
462
+ phone = data.phone;
463
+ if (!plugin.validatePhoneNumber(phone)) throw new Error('מספר לא תקין');
464
+ }
465
+
466
+ const result = await plugin.savePhoneToUser(data.uid, phone, true, true);
467
+
468
+ if (!result.success) throw new Error(result.message);
469
+ };
470
+
471
+ // 3. אימות ידני
472
+ SocketPlugins.call2all.adminVerifyUser = async function (socket, data) {
473
+ if (!data || !data.uid) throw new Error('שגיאה');
474
+ const isAdmin = await User.isAdministrator(socket.uid);
475
+ if (!isAdmin) throw new Error('אין הרשאה');
476
+
477
+ await User.setUserFields(data.uid, { phoneVerified: 1, phoneVerifiedAt: Date.now() });
478
+ await db.sortedSetAdd('users:phone', Date.now(), data.uid);
479
+ };
480
+
481
+ // 4. ביטול אימות
482
+ SocketPlugins.call2all.adminUnverifyUser = async function (socket, data) {
483
+ if (!data || !data.uid) throw new Error('שגיאה');
484
+ const isAdmin = await User.isAdministrator(socket.uid);
485
+ if (!isAdmin) throw new Error('אין הרשאה');
486
+
487
+ await User.setUserFields(data.uid, { phoneVerified: 0, phoneVerifiedAt: 0 });
488
+ };
489
+
490
+ // 5. מחיקת טלפון
491
+ SocketPlugins.call2all.adminDeleteUserPhone = async function (socket, data) {
492
+ if (!data || !data.uid) throw new Error('שגיאה');
493
+ const isAdmin = await User.isAdministrator(socket.uid);
494
+ if (!isAdmin) throw new Error('אין הרשאה');
495
+
496
+ const phoneData = await plugin.getUserPhone(data.uid);
497
+ if (phoneData && phoneData.phone) {
498
+ await db.sortedSetRemove('phone:uid', phoneData.phone);
499
+ }
500
+ await db.sortedSetRemove('users:phone', data.uid);
501
+ await User.setUserFields(data.uid, { [PHONE_FIELD_KEY]: '', phoneVerified: 0, phoneVerifiedAt: 0 });
502
+ };
503
+
504
+ // Client APIs
505
+ router.post('/api/phone-verification/send-code', middleware.applyCSRF, plugin.apiSendCode);
506
+ router.post('/api/phone-verification/verify-code', middleware.applyCSRF, plugin.apiVerifyCode);
507
+ router.post('/api/phone-verification/initiate-call', middleware.applyCSRF, plugin.apiInitiateCall);
508
+ router.post('/api/phone-verification/check-status', middleware.applyCSRF, plugin.apiCheckStatus);
509
+
510
+ // User Profile APIs
511
+ router.get('/api/user/:userslug/phone', middleware.authenticateRequest, plugin.apiGetUserPhoneProfile);
512
+ router.post('/api/user/:userslug/phone', middleware.authenticateRequest, middleware.applyCSRF, plugin.apiUpdateUserPhone);
513
+ router.post('/api/user/:userslug/phone/visibility', middleware.authenticateRequest, middleware.applyCSRF, plugin.apiUpdatePhoneVisibility);
514
+ router.post('/api/user/:userslug/phone/verify', middleware.authenticateRequest, middleware.applyCSRF, plugin.apiVerifyUserPhone);
515
+
516
+ // Admin APIs
517
+ router.get('/admin/plugins/phone-verification', middleware.admin.buildHeader, plugin.renderAdmin);
518
+ router.get('/api/admin/plugins/phone-verification', plugin.renderAdmin);
519
+ router.get('/api/admin/plugins/phone-verification/users', middleware.admin.checkPrivileges, plugin.apiAdminGetUsers);
520
+ router.get('/api/admin/plugins/phone-verification/search', middleware.admin.checkPrivileges, plugin.apiAdminSearchByPhone);
521
+ router.get('/api/admin/plugins/phone-verification/user/:uid', middleware.admin.checkPrivileges, plugin.apiAdminGetUserPhone);
522
+ router.get('/api/admin/plugins/phone-verification/settings', middleware.admin.checkPrivileges, plugin.apiAdminGetSettings);
523
+ router.post('/api/admin/plugins/phone-verification/settings', middleware.admin.checkPrivileges, middleware.applyCSRF, plugin.apiAdminSaveSettings);
524
+ router.post('/api/admin/plugins/phone-verification/test-call', middleware.admin.checkPrivileges, middleware.applyCSRF, plugin.apiAdminTestCall);
525
+ };
526
+ plugin.apiCheckStatus = async function (req, res) {
527
+ try {
528
+ const v = await plugin.isPhoneVerified(plugin.normalizePhone(req.body.phoneNumber));
529
+ res.json({ success: true, verified: v });
530
+ } catch (e) { res.json({ success: false }); }
531
+ };
532
+ plugin.getSettings = async function () {
533
+ if (!meta) meta = require.main.require('./src/meta');
534
+ const settings = await meta.settings.get('phone-verification');
535
+
536
+ const isTrue = (val) => val === true || val === 'true' || val === 'on' || val === '1';
537
+
538
+ return {
539
+ voiceServerUrl: settings.voiceServerUrl || defaultSettings.voiceServerUrl,
540
+ voiceServerApiKey: settings.voiceServerApiKey || '',
541
+ voiceServerEnabled: isTrue(settings.voiceServerEnabled), // בדיקה מורחבת
542
+ blockUnverifiedUsers: isTrue(settings.blockUnverifiedUsers), // בדיקה מורחבת
543
+ voiceTtsMode: settings.voiceTtsMode || '1',
544
+ voiceMessageTemplate: settings.voiceMessageTemplate || defaultSettings.voiceMessageTemplate
545
+ };
546
+ };
547
+
548
+ plugin.saveSettings = async function (settings) {
549
+ if (!meta) return false;
550
+ await meta.settings.set('phone-verification', {
551
+ voiceServerUrl: settings.voiceServerUrl || '',
552
+ voiceServerApiKey: settings.voiceServerApiKey || '',
553
+ voiceServerEnabled: settings.voiceServerEnabled ? 'true' : 'false',
554
+ blockUnverifiedUsers: settings.blockUnverifiedUsers ? 'true' : 'false',
555
+ voiceTtsMode: settings.voiceTtsMode || '1',
556
+ voiceMessageTemplate: settings.voiceMessageTemplate || defaultSettings.voiceMessageTemplate
557
+ });
558
+ return true;
559
+ };
560
+
561
+ plugin.renderAdmin = function (req, res) { res.render('admin/plugins/phone-verification', {}); };
562
+
563
+ plugin.apiAdminGetSettings = async function (req, res) {
564
+ try {
565
+ const settings = await plugin.getSettings();
566
+ res.json({ success: true, settings: { ...settings, voiceServerApiKey: settings.voiceServerApiKey ? '********' : '' } });
567
+ } catch (err) { res.json({ success: false }); }
568
+ };
569
+
570
+ plugin.apiAdminSaveSettings = async function (req, res) {
571
+ try {
572
+ const { voiceServerApiKey, ...rest } = req.body;
573
+ const current = await plugin.getSettings();
574
+ const apiKey = voiceServerApiKey === '********' ? current.voiceServerApiKey : voiceServerApiKey;
575
+ await plugin.saveSettings({ ...rest, voiceServerApiKey: apiKey });
576
+ res.json({ success: true });
577
+ } catch (err) { res.json({ success: false }); }
578
+ };
579
+
580
+ plugin.apiAdminTestCall = async function (req, res) {
581
+ try {
582
+ const { phoneNumber } = req.body;
583
+ if (!phoneNumber) return res.json({ success: false, message: 'חסר טלפון' });
584
+ const result = await plugin.sendVoiceCall(plugin.normalizePhone(phoneNumber), '123456');
585
+ res.json(result);
586
+ } catch (err) { res.json({ success: false }); }
587
+ };
588
+
589
+ plugin.apiSendCode = async function (req, res) {
590
+ try {
591
+ const { phoneNumber } = req.body;
592
+ if (!phoneNumber) return res.json({ success: false, error: 'MISSING' });
593
+
594
+ const clientIp = req.ip || req.headers['x-forwarded-for'];
595
+ const ipCheck = await plugin.checkIpRateLimit(clientIp);
596
+ if (!ipCheck.allowed) return res.json(ipCheck);
597
+ await plugin.incrementIpCounter(clientIp);
598
+
599
+ const clean = plugin.normalizePhone(phoneNumber.replace(/\D/g, ''));
600
+ if (!plugin.validatePhoneNumber(clean)) return res.json({ success: false, error: 'INVALID' });
601
+
602
+ const existingUid = await plugin.findUserByPhone(clean);
603
+ // בדיקת כפילות: אם המספר שייך למשתמש אחר
604
+ if (existingUid && (!req.uid || parseInt(existingUid) !== parseInt(req.uid))) {
605
+ return res.json({ success: false, error: 'EXISTS', message: 'המספר תפוס' });
606
+ }
607
+
608
+ const code = plugin.generateVerificationCode();
609
+ await plugin.saveVerificationCode(clean, code);
610
+ const result = await plugin.sendVoiceCall(clean, code);
611
+
612
+ res.json({ success: true, message: result.success ? 'שיחה נשלחה' : 'קוד נוצר', voiceCallSent: result.success });
613
+ } catch (err) { res.json({ success: false }); }
614
+ };
615
+
616
+ plugin.apiVerifyCode = async function (req, res) {
617
+ try {
618
+ const { phoneNumber, code } = req.body;
619
+ const result = await plugin.verifyCode(plugin.normalizePhone(phoneNumber), code);
620
+ if (result.success) await plugin.markPhoneAsVerified(plugin.normalizePhone(phoneNumber));
621
+ res.json(result);
622
+ } catch (err) { res.json({ success: false }); }
623
+ };
624
+
625
+ plugin.apiInitiateCall = async function (req, res) {
626
+ try {
627
+ const { phoneNumber } = req.body;
628
+
629
+ if (!phoneNumber) {
630
+ return res.json({ success: false, error: 'PHONE_REQUIRED', message: 'חובה להזין מספר טלפון' });
631
+ }
632
+
633
+ if (!plugin.validatePhoneNumber(phoneNumber)) {
634
+ return res.json({ success: false, error: 'PHONE_INVALID', message: 'מספר הטלפון אינו תקין' });
635
+ }
636
+
637
+ const normalizedPhone = plugin.normalizePhone(phoneNumber);
638
+
639
+ const existingUid = await plugin.findUserByPhone(normalizedPhone);
640
+
641
+ if (existingUid) {
642
+ if (!req.uid || parseInt(existingUid, 10) !== parseInt(req.uid, 10)) {
643
+ return res.json({ success: false, error: 'PHONE_EXISTS', message: 'מספר הטלפון כבר רשום במערכת' });
644
+ }
645
+ }
646
+
647
+ const code = plugin.generateVerificationCode();
648
+ const saveResult = await plugin.saveVerificationCode(normalizedPhone, code);
649
+
650
+ if (!saveResult.success) {
651
+ return res.json(saveResult);
652
+ }
653
+
654
+ res.json({ success: true, phone: normalizedPhone, code: code, expiresAt: saveResult.expiresAt });
655
+
656
+ } catch (err) {
657
+ res.json({ success: false, error: 'SERVER_ERROR', message: 'אירעה שגיאה' });
658
+ }
659
+ };
660
+ plugin.apiGetUserPhoneProfile = async function (req, res) {
661
+ try {
662
+ const uid = await User.getUidByUserslug(req.params.userslug);
663
+ const isOwner = parseInt(uid) === parseInt(req.uid);
664
+ const isAdmin = await User.isAdministrator(req.uid);
665
+ if (!isOwner && !isAdmin) return res.json({ success: true, phone: null, hidden: true });
666
+ const data = await plugin.getUserPhone(uid);
667
+ res.json({ success: true, phone: data ? data.phone : null, phoneVerified: data ? data.phoneVerified : false, isOwner });
668
+ } catch (e) { res.json({ success: false }); }
669
+ };
670
+
671
+ plugin.apiUpdateUserPhone = async function (req, res) {
672
+ try {
673
+ const uid = await User.getUidByUserslug(req.params.userslug);
674
+ const isOwner = parseInt(uid) === parseInt(req.uid);
675
+ const isAdmin = await User.isAdministrator(req.uid);
676
+ if (!isOwner && !isAdmin) return res.json({ success: false, error: '403' });
677
+
678
+ const { phoneNumber } = req.body;
679
+ if (!phoneNumber) {
680
+ const old = await plugin.getUserPhone(uid);
681
+ if (old && old.phone) {
682
+ await db.sortedSetRemove('phone:uid', old.phone);
683
+ await db.sortedSetRemove('users:phone', uid);
684
+ }
685
+ await User.setUserFields(uid, { [PHONE_FIELD_KEY]: '', phoneVerified: 0 });
686
+ return res.json({ success: true });
687
+ }
688
+
689
+ const clean = plugin.normalizePhone(phoneNumber);
690
+ if (!plugin.validatePhoneNumber(clean)) return res.json({ success: false, error: 'INVALID' });
691
+
692
+ const existing = await plugin.findUserByPhone(clean);
693
+ if (existing && parseInt(existing) !== parseInt(uid)) return res.json({ success: false, error: 'EXISTS' });
694
+
695
+ await plugin.savePhoneToUser(uid, clean, false);
696
+ res.json({ success: true, needsVerification: true });
697
+ } catch (e) { res.json({ success: false }); }
698
+ };
699
+
700
+ plugin.apiUpdatePhoneVisibility = async function(req,res) {
701
+ try {
702
+ const uid = await User.getUidByUserslug(req.params.userslug);
703
+ if (parseInt(uid) !== parseInt(req.uid)) return res.json({success:false});
704
+ await db.setObjectField(`user:${uid}`, 'showPhone', req.body.showPhone ? '1' : '0');
705
+ res.json({success:true});
706
+ } catch(e){ res.json({success:false}); }
707
+ };
708
+
709
+ plugin.apiVerifyUserPhone = async function(req,res) {
710
+ try {
711
+ const uid = await User.getUidByUserslug(req.params.userslug);
712
+ if (parseInt(uid) !== parseInt(req.uid)) return res.json({success:false});
713
+ const data = await plugin.getUserPhone(uid);
714
+ if (!data || !data.phone) return res.json({success:false});
715
+ const result = await plugin.verifyCode(data.phone, req.body.code);
716
+ if (result.success) {
717
+ await User.setUserFields(uid, { phoneVerified: 1, phoneVerifiedAt: Date.now() });
718
+ await db.sortedSetAdd('users:phone', Date.now(), uid);
719
+ }
720
+ res.json(result);
721
+ } catch(e){ res.json({success:false}); }
722
+ };
723
+
724
+ plugin.apiAdminGetUsers = async function (req, res) {
725
+ try {
726
+ const page = parseInt(req.query.page) || 1;
727
+ const result = await plugin.getAllUsersWithPhones((page - 1) * 50, (page * 50) - 1);
728
+ res.json({ success: true, users: result.users, total: result.total, page, totalPages: Math.ceil(result.total / 50) });
729
+ } catch (e) { res.json({ success: false }); }
730
+ };
731
+
732
+ plugin.apiAdminSearchByPhone = async function (req, res) {
733
+ try {
734
+ const uid = await plugin.findUserByPhone(req.query.phone);
735
+ if (uid) {
736
+ const data = await plugin.getUserPhone(uid);
737
+ const u = await User.getUserFields(uid, ['username']);
738
+ res.json({ success: true, found: true, user: { uid, username: u.username, ...data } });
739
+ } else res.json({ success: true, found: false });
740
+ } catch (e) { res.json({ success: false }); }
741
+ };
742
+
743
+ plugin.apiAdminGetUserPhone = async function (req, res) {
744
+ const data = await plugin.getUserPhone(req.params.uid);
745
+ res.json({ success: true, ...data });
746
+ };
747
+
748
+ plugin.userDelete = async function (data) {
749
+ try {
750
+ const phones = await db.getSortedSetRangeByScore('phone:uid', data.uid, 1, data.uid);
751
+ if (phones[0]) {
752
+ await db.sortedSetRemove('phone:uid', phones[0]);
753
+ await db.sortedSetRemove('users:phone', data.uid);
754
+ }
755
+ } catch (e) {}
756
+ };
757
+
758
+ module.exports = plugin;