|
|
-This message means that
-the publisher has restricted access to a page,
-and will not allow you to see it.
-
-Publishers sometimes restrict access to
-limit the use of their information.
-
-In general, it won't help to try again.
+{{ content | safe }}
Technical details
-This is result 403 Forbidden.
+This is {{ error_description }} {{ error_code }}.
|
|
@@ -79,4 +71,4 @@ selected>
-`;
\ No newline at end of file
+
diff --git a/zefie_wtvp_minisrv/includes/classes/WTVGuide.js b/zefie_wtvp_minisrv/includes/classes/WTVGuide.js
index 82c3f9bd..cf0e685f 100644
--- a/zefie_wtvp_minisrv/includes/classes/WTVGuide.js
+++ b/zefie_wtvp_minisrv/includes/classes/WTVGuide.js
@@ -23,6 +23,30 @@ class WTVGuide {
var data = false;
switch (topic.toLowerCase()) {
+ case "alerts":
+ // Handle error alert pages using Nunjucks templates
+ var template = this.wtvshared.getTemplate("wtv-guide", "templates/NunjucksTemplate.js", true);
+ if (this.fs.existsSync(template)) {
+ // Map error names to template files
+ var errorTemplateMap = {
+ "forbidden": topic + "/Forbidden.njk",
+ "hostmissing": topic + "/HostMissing.njk",
+ "internalservererror": topic + "/InternalServerError.njk",
+ "notfound": topic + "/NotFound.njk",
+ "serviceunavailable": topic + "/ServiceUnavailable.njk"
+ };
+
+ var templateName = errorTemplateMap[subtopic.toLowerCase()];
+ if (templateName) {
+ template_args = {
+ template_name: templateName,
+ minisrv_config: this.minisrv_config,
+ session_data: this.session_data
+ };
+ }
+ }
+ if (template) break;
+
case "glossary":
var template =this.wtvshared.getTemplate("wtv-guide", "templates/glossary.js", true);
var glossary_datafile =this.wtvshared.getTemplate("wtv-guide", "glossary.json", true);
diff --git a/zefie_wtvp_minisrv/includes/classes/WTVIRC.js b/zefie_wtvp_minisrv/includes/classes/WTVIRC.js
index 8dad3638..d68fbcc9 100644
--- a/zefie_wtvp_minisrv/includes/classes/WTVIRC.js
+++ b/zefie_wtvp_minisrv/includes/classes/WTVIRC.js
@@ -69,8 +69,8 @@ class WTVIRC {
this.servername = this.irc_config.server_hostname || 'irc.local';
this.network = this.irc_config.network || 'minisrv';
this.oper_username = this.irc_config.oper_username || 'minisrv';
- this.oper_password = this.irc_config.oper_password || 'changeme573';
- this.oper_enabled = this.irc_config.oper_enabled || this.debug || false; // Default to off to prevent accidental use with default credentials
+ this.oper_password = this.irc_config.oper_password || 'changeme573_PLEASE_CHANGE_THIS_PASSWORD';
+ this.oper_enabled = this.irc_config.oper_enabled || false; // Default to off for security
this.irc_motd = this.irc_config.motd || [
'Welcome to the zefIRCd IRC server, powered by minisrv.',
'This server is powered by Node.js, and the minisrv project.',
@@ -94,7 +94,7 @@ class WTVIRC {
this.socket_timeout = 75; // Default socket timeout to 75 seconds, most clients will send PINGs every 60 seconds, so this should be enough to catch lost connections
this.server_hello = this.irc_config.server_hello || `zefIRCd v${this.version} IRC server powered by minisrv`;
this.serverId = this.irc_config.server_id || '00A'; // Default server ID, can be overridden in config
- this.allow_public_vhosts = this.irc_config.allow_public_vhosts || true; // If true, users can set their host to a virtual host that is not a real hostname or IP address, if false, only opers can.
+ this.allow_public_vhosts = this.irc_config.allow_public_vhosts || false; // Default to false for security
this.kick_insecure_users_on_secure = this.irc_config.kick_insecure_users_on_secure || true; // If true, users without SSL connections will be kicked from a channel when +Z is applied
this.hide_version = this.irc_config.hide_version || false; // If true, the server will not send its version in the MOTD
this.clientpeak = 0;
@@ -111,6 +111,24 @@ class WTVIRC {
this.supported_server_caps = ['TBURST', 'EOB', 'IE', 'EX'];
this.enable_webtv_command_hacks = this.irc_config.enable_webtv_command_hacks || true;
this.supported_webtv_command_hacks = ["MODE", "KICK"]; // You could technically add any command currently supported by the IRCd here, such as OPER, KILL, KLINE, etc.
+
+ // Rate limiting configuration
+ this.rate_limit_enabled = this.irc_config.rate_limit_enabled || true;
+ this.max_messages_per_second = this.irc_config.max_messages_per_second || 5;
+ this.message_counts = new Map(); // socket -> {count, resetTime}
+
+ // Brute force protection
+ this.failed_auth_attempts = new Map(); // IP -> {count, lockoutUntil}
+ this.max_auth_attempts = this.irc_config.max_auth_attempts || 3;
+ this.auth_lockout_duration = this.irc_config.auth_lockout_duration || 300; // 5 minutes
+
+ // Connection limits
+ this.connections_per_ip = new Map(); // IP -> count
+ this.max_connections_per_ip = this.irc_config.max_connections_per_ip || 3;
+
+ // Security logging
+ this.security_log_enabled = this.irc_config.security_log_enabled || true;
+ this.security_events = [];
this.session_store_path = this.wtvshared.getAbsolutePath(this.minisrv_config.config.SessionStore + path.sep + 'minisrv_internal_irc');
this.klines_path = this.session_store_path + path.sep + 'klines.json';
this.caps = [
@@ -129,10 +147,16 @@ class WTVIRC {
for (const channel of this.irc_config.channels) {
this.createChannel(channel.name);
if (channel.modes && Array.isArray(channel.modes)) {
- this.channelData.get(channel.name).modes = [...channel.modes];
+ const channelData = this.channelData.get(channel.name);
+ if (channelData) {
+ channelData.modes = [...channel.modes];
+ }
}
if (channel.topic) {
- this.channelData.get(channel.name).topic = channel.topic;
+ const channelData = this.channelData.get(channel.name);
+ if (channelData) {
+ channelData.topic = channel.topic;
+ }
}
}
}
@@ -142,6 +166,18 @@ class WTVIRC {
// Detect SSL handshake and wrap socket if needed
socket.once('data', async firstChunk => {
this.totalConnections++;
+
+ // Check connection limits per IP
+ const clientIP = socket.remoteAddress;
+ const currentConnections = this.connections_per_ip.get(clientIP) || 0;
+ if (currentConnections >= this.max_connections_per_ip) {
+ this.debugLog('warn', `Connection limit exceeded for IP ${clientIP}`);
+ socket.write(`:${this.servername} ERROR :Too many connections from your IP\r\n`);
+ socket.end();
+ return;
+ }
+ this.connections_per_ip.set(clientIP, currentConnections + 1);
+
socket.removeAllListeners('data');
socket.pause();
socket.on('error', (err) => {
@@ -219,17 +255,182 @@ class WTVIRC {
data = data.substring(0, this.max_message_len - 2) + '\r\n';
this.debugLog('warn', `Data length exceeds max_message_len (${this.max_message_len}), truncating: ${data.length} > ${this.max_message_len}`);
}
- while (socket.writable === false) {
+
+ // Add timeout to prevent infinite loop
+ let waitCount = 0;
+ const maxWaitIterations = 1000; // 10 seconds max wait
+ while (socket.writable === false && waitCount < maxWaitIterations) {
await new Promise(resolve => setTimeout(resolve, 10));
+ waitCount++;
+ }
+
+ if (socket.writable === false) {
+ this.debugLog('error', 'Socket not writable after timeout, aborting write');
+ return;
}
socket.write(data);
}
+
+ checkRateLimit(socket) {
+ if (!this.rate_limit_enabled || socket.isserver || this.isIRCOp(socket.nickname)) {
+ return true; // No rate limiting for servers or IRCOps
+ }
+
+ const now = Date.now();
+ const socketId = socket.remoteAddress + ':' + socket.remotePort;
+
+ if (!this.message_counts.has(socketId)) {
+ this.message_counts.set(socketId, { count: 1, resetTime: now + 1000 });
+ return true;
+ }
+
+ const rateLimitData = this.message_counts.get(socketId);
+
+ if (now > rateLimitData.resetTime) {
+ // Reset the counter
+ rateLimitData.count = 1;
+ rateLimitData.resetTime = now + 1000;
+ return true;
+ }
+
+ if (rateLimitData.count >= this.max_messages_per_second) {
+ return false; // Rate limit exceeded
+ }
+
+ rateLimitData.count++;
+ return true;
+ }
+
+ checkAuthAttempts(socket) {
+ const ip = socket.realhost || socket.remoteAddress;
+ const now = Date.now();
+
+ if (!this.failed_auth_attempts.has(ip)) {
+ return true;
+ }
+
+ const authData = this.failed_auth_attempts.get(ip);
+ if (authData.lockoutUntil && now < authData.lockoutUntil) {
+ return false; // Still locked out
+ }
+
+ return true;
+ }
+
+ recordAuthFailure(socket) {
+ const ip = socket.realhost || socket.remoteAddress;
+ const now = Date.now();
+
+ if (!this.failed_auth_attempts.has(ip)) {
+ this.failed_auth_attempts.set(ip, { count: 1, lockoutUntil: null });
+ return;
+ }
+
+ const authData = this.failed_auth_attempts.get(ip);
+ authData.count++;
+
+ if (authData.count >= this.max_auth_attempts) {
+ authData.lockoutUntil = now + (this.auth_lockout_duration * 1000);
+ this.debugLog('warn', `IP ${ip} locked out for ${this.auth_lockout_duration} seconds due to failed auth attempts`);
+ }
+ }
+
+ clearAuthFailures(socket) {
+ const ip = socket.realhost || socket.remoteAddress;
+ this.failed_auth_attempts.delete(ip);
+ }
+
+ sanitizeInput(input, type = 'general') {
+ if (typeof input !== 'string') {
+ return '';
+ }
+
+ // Remove control characters except \r\n
+ input = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
+
+ switch (type) {
+ case 'nickname':
+ // IRC nicknames: A-Z a-z 0-9 [ ] \ ` _ ^ { | }
+ return input.replace(/[^A-Za-z0-9\[\]\\`_^{|}]/g, '').substring(0, this.nicklen);
+
+ case 'channel':
+ // Channel names: start with #, no spaces, commas, or control chars
+ if (!input.startsWith('#')) return '';
+ return input.replace(/[^A-Za-z0-9#\-_.]/g, '').substring(0, this.channellen);
+
+ case 'message':
+ // Messages: no control chars, reasonable length
+ return input.substring(0, 512);
+
+ case 'username':
+ // Usernames: alphanumeric and some special chars
+ return input.replace(/[^A-Za-z0-9\-_.]/g, '').substring(0, 32);
+
+ default:
+ return input.substring(0, 512);
+ }
+ }
+
+ validateCommand(command, params) {
+ if (!command || typeof command !== 'string') {
+ return false;
+ }
+
+ // Command must be uppercase letters only
+ if (!/^[A-Z]+$/.test(command)) {
+ return false;
+ }
+
+ // Reasonable command length
+ if (command.length > 20) {
+ return false;
+ }
+
+ // Parameters validation
+ if (params && Array.isArray(params)) {
+ for (const param of params) {
+ if (typeof param !== 'string' || param.length > 512) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ logSecurityEvent(event, socket, details = {}) {
+ if (!this.security_log_enabled) return;
+
+ const securityEvent = {
+ timestamp: new Date().toISOString(),
+ event: event,
+ ip: socket ? (socket.realhost || socket.remoteAddress) : 'unknown',
+ nickname: socket ? socket.nickname : 'unknown',
+ details: details
+ };
+
+ this.security_events.push(securityEvent);
+
+ // Keep only last 1000 security events in memory
+ if (this.security_events.length > 1000) {
+ this.security_events.shift();
+ }
+
+ // Log to console/file if needed
+ this.debugLog('security', `Security Event: ${event} from ${securityEvent.ip} (${securityEvent.nickname})`);
+
+ // Write to security log file if configured
+ if (this.irc_config.security_log_file) {
+ const fs = require('fs');
+ fs.appendFileSync(this.irc_config.security_log_file, JSON.stringify(securityEvent) + '\n');
+ }
+ }
async initializeSocket(socket, secure = false, oldSocket = null) {
if (this.debug) {
// debug output for socket data
- const originalWrite = socket.write;
+ const originalWrite = socket.write.bind(socket);
socket.write = function (...args) {
var log_args = args.map(arg => {
if (typeof arg === 'string') {
@@ -238,7 +439,7 @@ class WTVIRC {
return arg;
});
console.log('<', ...log_args);
- return originalWrite.apply(socket, args);
+ return originalWrite(...args);
};
}
if (oldSocket) {
@@ -984,7 +1185,7 @@ class WTVIRC {
whoisNick = whoisSocket.nickname;
const whois_username = this.usernames.get(whoisNick);
var userinfo = this.userinfo.get(whoisNick) || whoisSocket.userinfo || '';
- var output_lines = [];
+ output_lines = [];
output_lines.push(`:${this.serverId} 311 ${targetUniqueId} ${whoisNick} ${whois_username} ${whoisSocket.host} * :${userinfo}\r\n`);
if (this.awaymsgs.has(whoisNick)) {
output_lines.push(`:${this.serverId} 301 ${targetUniqueId} ${whoisNick} :${this.awaymsgs.get(whoisNick)}\r\n`);
@@ -1108,6 +1309,8 @@ class WTVIRC {
}
async processSocketData(socket, data) {
+ var output_lines = []; // Declare once at function scope
+
if (socket.signedoff) {
return;
}
@@ -1184,6 +1387,16 @@ class WTVIRC {
if (this.debug) {
console.log(`> ${line}`);
}
+
+ // Rate limiting check for non-server connections
+ if (!socket.isserver && !this.checkRateLimit(socket)) {
+ this.debugLog('warn', `Rate limit exceeded for ${socket.remoteAddress}, disconnecting`);
+ this.logSecurityEvent('RATE_LIMIT_EXCEEDED', socket, { limit: this.max_messages_per_second });
+ await this.safeWriteToSocket(socket, `:${this.servername} ERROR :Rate limit exceeded\r\n`);
+ this.terminateSession(socket, true);
+ return;
+ }
+
if (socket.isserver) {
await this.processServerData(socket, line);
continue;
@@ -1221,11 +1434,27 @@ class WTVIRC {
const [command, ...params] = line.trim().split(' ');
+
+ // Validate command and parameters
+ if (!this.validateCommand(command.toUpperCase(), params)) {
+ this.logSecurityEvent('INVALID_COMMAND', socket, { command, params });
+ await this.safeWriteToSocket(socket, `:${this.servername} 421 ${socket.nickname || '*'} ${command} :Unknown command\r\n`);
+ continue;
+ }
+
switch (command.toUpperCase()) {
case 'OPER':
+ if (!socket.secure) {
+ await this.safeWriteToSocket(socket, `:${this.servername} 464 ${socket.nickname} :SSL required for OPER\r\n`);
+ }
if (!this.checkRegistered(socket)) {
break;
}
+ if (!this.checkAuthAttempts(socket)) {
+ await this.safeWriteToSocket(socket, `:${this.servername} 491 ${socket.nickname} :Too many failed attempts. Try again later.\r\n`);
+ this.logSecurityEvent('OPER_LOCKOUT', socket, { attempts: this.failed_auth_attempts.get(socket.realhost || socket.remoteAddress) });
+ break;
+ }
if (!this.oper_enabled) {
await this.safeWriteToSocket(socket, `:${this.servername} 491 ${socket.nickname} :This server does not support IRC operators\r\n`);
break;
@@ -1238,13 +1467,25 @@ class WTVIRC {
if (operName !== this.oper_username) {
await this.safeWriteToSocket(socket, `:${this.servername} 491 ${socket.nickname} :No permission\r\n`);
this.debugLog('warn', `Invalid oper name attempt: ${operName} from ${socket.nickname} (${socket.username}@${socket.realhost})`);
+ this.logSecurityEvent('OPER_FAILED_USERNAME', socket, { provided_username: operName });
+ this.recordAuthFailure(socket);
break;
}
- if (operPassword !== this.oper_password) {
+ // Use timing-safe comparison to prevent timing attacks
+ const providedPassword = Buffer.from(operPassword, 'utf8');
+ const actualPassword = Buffer.from(this.oper_password, 'utf8');
+ const passwordMatch = providedPassword.length === actualPassword.length &&
+ require('crypto').timingSafeEqual(providedPassword, actualPassword);
+
+ if (!passwordMatch) {
await this.safeWriteToSocket(socket, `:${this.servername} 464 ${socket.nickname} :Password incorrect\r\n`);
this.debugLog('warn', `Invalid oper password attempt from ${socket.nickname} (${socket.username}@${socket.realhost}) (using oper name ${operName})`);
+ this.logSecurityEvent('OPER_FAILED_PASSWORD', socket, { username: operName });
+ this.recordAuthFailure(socket);
break;
}
+ this.clearAuthFailures(socket);
+ this.logSecurityEvent('OPER_SUCCESS', socket, { username: operName });
this.setUserMode(socket.nickname, 'o', true);
await this.safeWriteToSocket(socket, `:${this.servername} 381 ${socket.nickname} :You are now an IRC operator\r\n`);
await this.safeWriteToSocket(socket, `:${socket.nickname}!${socket.username}@${socket.host} MODE ${socket.nickname} +o\r\n`);
@@ -1262,6 +1503,26 @@ class WTVIRC {
const seconds = uptime % 60;
await this.safeWriteToSocket(socket, `:${this.servername} 242 ${socket.nickname} :Server uptime is ${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds\r\n`);
break;
+ case 'SECURITY':
+ if (!this.checkRegistered(socket)) {
+ break;
+ }
+ if (!this.isIRCOp(socket.nickname)) {
+ await this.safeWriteToSocket(socket, `:${this.servername} 481 ${socket.nickname} :Permission denied - you are not an IRC operator\r\n`);
+ break;
+ }
+
+ // Show security statistics
+ let output_lines = [];
+ output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :=== Security Report ===\r\n`);
+ output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :Active connections per IP: ${this.connections_per_ip.size}\r\n`);
+ output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :Failed auth attempts tracked: ${this.failed_auth_attempts.size}\r\n`);
+ output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :Security events logged: ${this.security_events.length}\r\n`);
+ output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :Rate limit violations: ${this.security_events.filter(e => e.event === 'RATE_LIMIT_EXCEEDED').length}\r\n`);
+ output_lines.push(`:${this.servername} NOTICE ${socket.nickname} :=== End Report ===\r\n`);
+
+ await this.sendThrottled(socket, output_lines);
+ break;
case 'KICK':
if (!this.checkRegistered(socket)) {
break;
@@ -1674,13 +1935,23 @@ class WTVIRC {
case 'NICK':
var old_nickname = socket.nickname;
var new_nickname = params[0];
- if (new_nickname.startsWith(':')) {
+ if (new_nickname && new_nickname.startsWith(':')) {
new_nickname = new_nickname.slice(1);
}
if (!new_nickname || new_nickname.length < 1) {
await this.safeWriteToSocket(socket, `:${this.servername} 431 * :No nickname\r\n`);
break;
}
+
+ // Sanitize nickname input
+ const sanitized_nickname = this.sanitizeInput(new_nickname, 'nickname');
+ if (sanitized_nickname !== new_nickname || sanitized_nickname.length === 0) {
+ await this.safeWriteToSocket(socket, `:${this.servername} 432 * ${new_nickname} :Erroneus nickname (invalid characters)\r\n`);
+ this.logSecurityEvent('INVALID_NICKNAME', socket, { provided: new_nickname, sanitized: sanitized_nickname });
+ break;
+ }
+ new_nickname = sanitized_nickname;
+
if (new_nickname.length > this.nicklen) {
await this.safeWriteToSocket(socket, `:${this.servername} 432 * ${new_nickname} :Erroneus nickname (too long)\r\n`);
break;
@@ -1863,7 +2134,7 @@ class WTVIRC {
const invited = this.channelData.get(ch).inviteexceptions;
let isInvited = false;
for (const inviteMask of invited) {
- isInvited = checkMask(inviteMask, socket);
+ isInvited = this.checkMask(inviteMask, socket);
if (isInvited) {
break; // Stop checking if we found a match
}
@@ -1948,7 +2219,7 @@ class WTVIRC {
}
}
var users = this.getUsersInChannel(ch);
- var output_lines = [];
+ output_lines = [];
var prefixRegex = new RegExp(`^[${this.supported_prefixes[1]}]`);
if (users.length > 0) {
users.sort((a, b) => {
@@ -2006,7 +2277,7 @@ class WTVIRC {
}
}
if (this.clientIsWebTV(socket) && this.enable_webtv_command_hacks) {
- var output_lines = [];
+ output_lines = [];
var channelObj = this.channelData.get(ch);
output_lines.push("You have joined " + ch);
output_lines.push("Current channel modes: +" + channelObj.modes.join(''));
@@ -2038,7 +2309,7 @@ class WTVIRC {
break;
}
var users = this.getUsersInChannel(channel);
- var output_lines = [];
+ output_lines = [];
if (users.length > 0) {
if (socket.client_caps.includes('userhost-in-names')) {
const userHosts = users.map(user => {
@@ -2240,7 +2511,7 @@ class WTVIRC {
await this.safeWriteToSocket(socket, `:${this.servername} 315 ${socket.nickname} ${target} :End of /WHO list\r\n`);
} else {
// WHO for nickname
- var output_lines = [];
+ output_lines = [];
if (target.includes('*') || target.includes('?')) {
// Wildcard mask search for nicknames
const maskRegex = new RegExp('^' + target.replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
@@ -2574,7 +2845,7 @@ class WTVIRC {
break;
}
var type = params[0] ? params[0].toUpperCase() : '';
- var output_lines = [];
+ output_lines = [];
switch (type) {
case "HELP":
output_lines.push(`:${this.servername} 200 ${socket.nickname} :Available commands:\r\n`);
@@ -2711,7 +2982,7 @@ class WTVIRC {
}
const userChannels = [];
- var output_lines = [];
+ output_lines = [];
for (const [ch, channelObj] of this.channelData.entries()) {
if (channelObj.users.has(whoisNick)) {
let prefix = '';
@@ -3010,11 +3281,28 @@ class WTVIRC {
this.servers.delete(socket);
this.serverusers.delete(socket);
} else {
- this.clients.filter(c => c !== socket);
+ this.clients = this.clients.filter(c => c !== socket);
}
if (socket._idleInterval) {
clearInterval(socket._idleInterval);
+ socket._idleInterval = null;
}
+
+ // Clean up rate limiting data
+ const socketId = socket.remoteAddress + ':' + socket.remotePort;
+ this.message_counts.delete(socketId);
+
+ // Clean up connection count per IP
+ const clientIP = socket.remoteAddress;
+ if (this.connections_per_ip.has(clientIP)) {
+ const currentCount = this.connections_per_ip.get(clientIP);
+ if (currentCount <= 1) {
+ this.connections_per_ip.delete(clientIP);
+ } else {
+ this.connections_per_ip.set(clientIP, currentCount - 1);
+ }
+ }
+
if (close) {
socket.end();
}
@@ -4057,7 +4345,7 @@ class WTVIRC {
}
} else if (modes === 'b') {
// Get the list of channel bans
- var output_lines = [];
+ output_lines = [];
if (this.channelData.has(channel).bans) {
const bans = this.channelData.has(channel).bans;
for (const ban of bans) {
@@ -4069,7 +4357,7 @@ class WTVIRC {
return;
} else if (modes === 'e') {
// Get the list of channel exemptions
- var output_lines = [];
+ output_lines = [];
if (this.channelData.has(channel).exemptions) {
const exemptions = this.channelData.has(channel).exemptions;
for (const exemption of exemptions) {
@@ -4081,7 +4369,7 @@ class WTVIRC {
return;
} else if (modes === 'I') {
// Get the list of channel invites masks
- var output_lines = [];
+ output_lines = [];
if (this.channelData.has(channel).invites) {
const invites = this.channelData.has(channel).invites;
for (const invite of invites) {
| |