[mirotalksfu] - update config.template.js & improve Public IPv4 detection
هذا الالتزام موجود في:
@@ -64,7 +64,7 @@ dev dependencies: {
|
||||
* @license For commercial or closed source, contact us at license.mirotalk@gmail.com or purchase directly via CodeCanyon
|
||||
* @license CodeCanyon: https://codecanyon.net/item/mirotalk-sfu-webrtc-realtime-video-conferences/40769970
|
||||
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
|
||||
* @version 1.7.96
|
||||
* @version 1.7.97
|
||||
*
|
||||
*/
|
||||
|
||||
@@ -303,34 +303,62 @@ let announcedAddress = webRtcServerActive
|
||||
const workers = [];
|
||||
let nextMediasoupWorkerIdx = 0;
|
||||
|
||||
// Autodetect announcedAddress (https://www.ipify.org)
|
||||
// Autodetect announcedAddress with multiple fallback services
|
||||
if (!announcedAddress && IP === '0.0.0.0') {
|
||||
http.get(
|
||||
{
|
||||
host: 'api.ipify.org',
|
||||
port: 80,
|
||||
path: '/',
|
||||
},
|
||||
(resp) => {
|
||||
resp.on('data', (ip) => {
|
||||
announcedAddress = ip.toString();
|
||||
if (webRtcServerActive) {
|
||||
config.mediasoup.webRtcServerOptions.listenInfos.forEach((info) => {
|
||||
info.announcedAddress = announcedAddress;
|
||||
});
|
||||
} else {
|
||||
config.mediasoup.webRtcTransport.listenInfos.forEach((info) => {
|
||||
info.announcedAddress = announcedAddress;
|
||||
});
|
||||
const detectPublicIp = async () => {
|
||||
const services = config.services?.ip || [
|
||||
'http://api.ipify.org',
|
||||
'http://ipinfo.io/ip',
|
||||
'http://ifconfig.me/ip',
|
||||
];
|
||||
|
||||
for (const service of services) {
|
||||
try {
|
||||
const ip = await fetchPublicIp(service);
|
||||
if (ip) {
|
||||
announcedAddress = ip;
|
||||
updateAnnouncedAddress(ip);
|
||||
startServer();
|
||||
return;
|
||||
}
|
||||
startServer();
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(`Failed to detect IP from ${service}`, err.message);
|
||||
}
|
||||
}
|
||||
throw new Error('All public IP detection services failed! Please check your network connection');
|
||||
};
|
||||
|
||||
detectPublicIp().catch((err) => {
|
||||
log.error('Public IP detection failed', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
startServer();
|
||||
}
|
||||
|
||||
function fetchPublicIp(serviceUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(serviceUrl, (resp) => {
|
||||
if (resp.statusCode !== 200) {
|
||||
return reject(new Error(`HTTP ${resp.statusCode}`));
|
||||
}
|
||||
let data = '';
|
||||
resp.on('data', (chunk) => (data += chunk));
|
||||
resp.on('end', () => resolve(data.toString().trim()));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function updateAnnouncedAddress(ip) {
|
||||
const target = webRtcServerActive
|
||||
? config.mediasoup.webRtcServerOptions.listenInfos
|
||||
: config.mediasoup.webRtcTransport.listenInfos;
|
||||
|
||||
target.forEach((info) => {
|
||||
info.announcedAddress = ip;
|
||||
});
|
||||
}
|
||||
|
||||
// Custom middleware function for OIDC authentication
|
||||
function OIDCAuth(req, res, next) {
|
||||
if (OIDC.enabled) {
|
||||
@@ -1305,8 +1333,8 @@ function startServer() {
|
||||
|
||||
// SFU settings
|
||||
sfu: {
|
||||
ip: IP, // Local IPv4 listen on
|
||||
announcedAddress, // Public IPv4 listen on
|
||||
listenIP: IP,
|
||||
publicIP: announcedAddress,
|
||||
numWorker: config.mediasoup?.numWorkers,
|
||||
rtcMinPort: config.mediasoup?.worker?.rtcMinPort,
|
||||
rtcMaxPort: config.mediasoup?.worker?.rtcMaxPort,
|
||||
|
||||
@@ -1,87 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
const dotenv = require('dotenv').config();
|
||||
|
||||
const packageJson = require('../../package.json');
|
||||
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
|
||||
const https = require('https');
|
||||
const PLATFORM = os.platform();
|
||||
const IS_DOCKER = require('fs').existsSync('/.dockerenv');
|
||||
|
||||
// #############################
|
||||
// HELPERS
|
||||
// #############################
|
||||
|
||||
const platform = os.platform();
|
||||
|
||||
function getFFmpegPath(platform) {
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
return '/usr/local/bin/ffmpeg'; // macOS
|
||||
case 'linux':
|
||||
return '/usr/bin/ffmpeg'; // Linux
|
||||
case 'win32':
|
||||
return 'C:\\ffmpeg\\bin\\ffmpeg.exe'; // Windows
|
||||
default:
|
||||
return '/usr/bin/ffmpeg'; // Centos or others...
|
||||
}
|
||||
}
|
||||
|
||||
function getPublicIPv4(timeout = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get('https://api.ipify.org', (res) => {
|
||||
res.on('data', (ip) => {
|
||||
resolve(String(ip)); // Resolve with the public IP address
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
req.setTimeout(timeout, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timed out'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getLocalIPv4() {
|
||||
const ifaces = os.networkInterfaces();
|
||||
for (const interfaceName in ifaces) {
|
||||
const iface = ifaces[interfaceName];
|
||||
for (const { address, family, internal } of iface) {
|
||||
if (family === 'IPv4' && !internal) {
|
||||
return address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '0.0.0.0'; // Default to 0.0.0.0 if no external IPv4 address found
|
||||
}
|
||||
|
||||
/*
|
||||
IPv4 Configuration Guide:
|
||||
1. Localhost Setup:
|
||||
- For local development with Docker, replace `getLocalIPv4()` with '127.0.0.1' if needed.
|
||||
2. Production Setup:
|
||||
- If you leave it empty (''), it will be automatically detected using a service like https://api.ipify.org.
|
||||
- Replace `getLocalIPv4()` with the 'Public Static IPv4 Address' of the server hosting this application.
|
||||
- For AWS EC2 instances, replace `getLocalIPv4()` with the 'Elastic IP' associated with the instance.
|
||||
This ensures the public IP remains consistent across instance reboots.
|
||||
Note: Always enclose the IP address in single quotes ''.
|
||||
*/
|
||||
let IPv4 = process.env.SFU_ANNOUNCED_IP || getLocalIPv4(); // Replace with the appropriate IPv4 address for your environment or leave it empty ('') to be automatically detected on instance startup.
|
||||
|
||||
(async () => {
|
||||
if (IPv4 === '0.0.0.0' || !IPv4) {
|
||||
try {
|
||||
IPv4 = await getPublicIPv4();
|
||||
} catch (err) {
|
||||
console.error('Failed to determine public IPv4, using local fallback', err);
|
||||
IPv4 = getLocalIPv4();
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const listenIP = process.env.SFU_LISTEN_IP || '0.0.0.0';
|
||||
// ###################################################################################################
|
||||
const ENVIRONMENT = process.env.NODE_ENV || 'development'; // production
|
||||
const PUBLIC_IP = process.env.SFU_PUBLIC_IP || ''; // SFU Public IP
|
||||
const LISTEN_IP = process.env.SFU_LISTEN_IP || '0.0.0.0'; // SFU listen IP
|
||||
const IPv4 = getIPv4(); // Determines the appropriate IPv4 address based on ENVIRONMENT
|
||||
// ###################################################################################################
|
||||
|
||||
/*
|
||||
Set the port range for WebRTC communication. This range is used for the dynamic allocation of UDP ports for media streams.
|
||||
@@ -93,20 +24,23 @@ const listenIP = process.env.SFU_LISTEN_IP || '0.0.0.0';
|
||||
- Alternatively, enable 'webRtcServerActive: true' mode for better scalability.
|
||||
- Make sure these port ranges are not blocked by the firewall, if they are, add the necessary rules
|
||||
*/
|
||||
const rtcMinPort = process.env.SFU_MIN_PORT || 40000;
|
||||
const rtcMaxPort = process.env.SFU_MAX_PORT || 40100;
|
||||
const RTC_MIN_PORT = parseInt(process.env.SFU_MIN_PORT) || 40000;
|
||||
const RTC_MAX_PORT = parseInt(process.env.SFU_MAX_PORT) || 40100;
|
||||
|
||||
/*
|
||||
One worker can handle approximately 100 concurrent participants.
|
||||
The number of workers cannot exceed the number of available CPU cores.
|
||||
*/
|
||||
const numCPUs = os.cpus().length;
|
||||
const numWorkers = Math.min(process.env.SFU_NUM_WORKERS || numCPUs, numCPUs);
|
||||
const NUM_CPUS = os.cpus().length;
|
||||
const NUM_WORKERS = Math.min(process.env.SFU_NUM_WORKERS || NUM_CPUS, NUM_CPUS);
|
||||
|
||||
// Used 4 RTMP streams
|
||||
const ffmpegPath = process.env.FFMPEG_PATH || getFFmpegPath(platform);
|
||||
// RTMP using FMMPEG for streaming...
|
||||
const FFMPEG_PATH = process.env.FFMPEG_PATH || getFFmpegPath(PLATFORM);
|
||||
|
||||
module.exports = {
|
||||
services: {
|
||||
ip: ['http://api.ipify.org', 'http://ipinfo.io/ip', 'http://ifconfig.me/ip'],
|
||||
},
|
||||
systemInfo: {
|
||||
os: {
|
||||
type: os.type(),
|
||||
@@ -120,6 +54,7 @@ module.exports = {
|
||||
memory: {
|
||||
total: (os.totalmem() / 1024 / 1024 / 1024).toFixed(2) + ' GB',
|
||||
},
|
||||
isDocker: IS_DOCKER,
|
||||
},
|
||||
console: {
|
||||
/*
|
||||
@@ -200,8 +135,8 @@ module.exports = {
|
||||
apiSecret: 'mirotalkRtmpApiSecret',
|
||||
expirationHours: 4,
|
||||
dir: 'rtmp',
|
||||
ffmpegPath: ffmpegPath,
|
||||
platform: platform,
|
||||
ffmpegPath: FFMPEG_PATH,
|
||||
platform: PLATFORM,
|
||||
},
|
||||
},
|
||||
middleware: {
|
||||
@@ -685,10 +620,10 @@ module.exports = {
|
||||
},
|
||||
mediasoup: {
|
||||
// Worker settings
|
||||
numWorkers: numWorkers,
|
||||
numWorkers: NUM_WORKERS,
|
||||
worker: {
|
||||
rtcMinPort: rtcMinPort,
|
||||
rtcMaxPort: rtcMaxPort,
|
||||
rtcMinPort: RTC_MIN_PORT,
|
||||
rtcMaxPort: RTC_MAX_PORT,
|
||||
disableLiburing: false, // https://github.com/axboe/liburing
|
||||
logLevel: 'error',
|
||||
logTags: ['info', 'ice', 'dtls', 'rtp', 'srtp', 'rtcp', 'rtx', 'bwe', 'score', 'simulcast', 'svc', 'sctp'],
|
||||
@@ -758,38 +693,38 @@ module.exports = {
|
||||
webRtcServerActive: false,
|
||||
webRtcServerOptions: {
|
||||
listenInfos: [
|
||||
// { protocol: 'udp', ip: listenIP, announcedAddress: IPv4, port: rtcMinPort },
|
||||
// { protocol: 'tcp', ip: listenIP, announcedAddress: IPv4, port: rtcMinPort },
|
||||
// { protocol: 'udp', ip: LISTEN_IP, announcedAddress: IPv4, port: RTC_MIN_PORT },
|
||||
// { protocol: 'tcp', ip: LISTEN_IP, announcedAddress: IPv4, port: RTC_MIN_PORT },
|
||||
{
|
||||
protocol: 'udp',
|
||||
ip: listenIP,
|
||||
ip: LISTEN_IP,
|
||||
announcedAddress: IPv4,
|
||||
portRange: { min: rtcMinPort, max: rtcMinPort + numWorkers },
|
||||
portRange: { min: RTC_MIN_PORT, max: RTC_MIN_PORT + NUM_WORKERS },
|
||||
},
|
||||
{
|
||||
protocol: 'tcp',
|
||||
ip: listenIP,
|
||||
ip: LISTEN_IP,
|
||||
announcedAddress: IPv4,
|
||||
portRange: { min: rtcMinPort, max: rtcMinPort + numWorkers },
|
||||
portRange: { min: RTC_MIN_PORT, max: RTC_MIN_PORT + NUM_WORKERS },
|
||||
},
|
||||
],
|
||||
},
|
||||
// WebRtcTransportOptions
|
||||
webRtcTransport: {
|
||||
listenInfos: [
|
||||
// { protocol: 'udp', ip: IPv4, portRange: { min: rtcMinPort, max: rtcMaxPort } },
|
||||
// { protocol: 'tcp', ip: IPv4, portRange: { min: rtcMinPort, max: rtcMaxPort } },
|
||||
// { protocol: 'udp', ip: IPv4, portRange: { min: RTC_MIN_PORT, max: RTC_MAX_PORT } },
|
||||
// { protocol: 'tcp', ip: IPv4, portRange: { min: RTC_MIN_PORT, max: RTC_MAX_PORT } },
|
||||
{
|
||||
protocol: 'udp',
|
||||
ip: listenIP,
|
||||
ip: LISTEN_IP,
|
||||
announcedAddress: IPv4,
|
||||
portRange: { min: rtcMinPort, max: rtcMaxPort },
|
||||
portRange: { min: RTC_MIN_PORT, max: RTC_MAX_PORT },
|
||||
},
|
||||
{
|
||||
protocol: 'tcp',
|
||||
ip: listenIP,
|
||||
ip: LISTEN_IP,
|
||||
announcedAddress: IPv4,
|
||||
portRange: { min: rtcMinPort, max: rtcMaxPort },
|
||||
portRange: { min: RTC_MIN_PORT, max: RTC_MAX_PORT },
|
||||
},
|
||||
],
|
||||
initialAvailableOutgoingBitrate: 1000000,
|
||||
@@ -799,3 +734,208 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the appropriate IPv4 address based on environment and configuration
|
||||
* Priority order:
|
||||
* 1. Explicitly configured PUBLIC_IP (if set)
|
||||
* 2. Environment-specific detection
|
||||
*
|
||||
* @returns {string} The selected IPv4 address
|
||||
*/
|
||||
function getIPv4() {
|
||||
// Highest priority: use explicitly configured IP if available
|
||||
if (PUBLIC_IP) return PUBLIC_IP;
|
||||
|
||||
switch (ENVIRONMENT) {
|
||||
case 'development':
|
||||
return IS_DOCKER ? '127.0.0.1' : getLocalIPv4();
|
||||
|
||||
case 'production':
|
||||
/*
|
||||
* Production Environment Notes:
|
||||
* ----------------------------------
|
||||
* 1. Recommended: Explicitly set your public IPv4 address
|
||||
* - For cloud providers (AWS/Azure/GCP):
|
||||
* - AWS: Use Elastic IP associated with your EC2 instance
|
||||
* - GCP: Use static external IP assigned to your VM
|
||||
* - Azure: Use public IP address resource
|
||||
*
|
||||
* 2. Auto-detection Fallback:
|
||||
* - Will attempt to detect public IP if not manually configured
|
||||
* - Not recommended for production as it may cause:
|
||||
* - DNS resolution delays during startup
|
||||
* - Inconsistent behavior if detection services are unavailable
|
||||
*
|
||||
* 3. For containerized production:
|
||||
* - Set via environment variable
|
||||
* - Use cloud provider metadata service when available
|
||||
* (e.g., AWS EC2 metadata service)
|
||||
*/
|
||||
return PUBLIC_IP;
|
||||
|
||||
default:
|
||||
// Fallback for unknown environments - use local IP detection
|
||||
return getLocalIPv4();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the most suitable local IPv4 address by:
|
||||
* 1. Checking platform-specific priority interfaces first (Ethernet/Wi-Fi)
|
||||
* 2. Falling back to scanning all non-virtual interfaces
|
||||
* 3. Excluding APIPA (169.254.x.x) and internal/virtual addresses
|
||||
*
|
||||
* @returns {string} Valid IPv4 address or '0.0.0.0' if none found
|
||||
*/
|
||||
function getLocalIPv4() {
|
||||
const ifaces = os.networkInterfaces();
|
||||
const platform = os.platform();
|
||||
|
||||
// ===== 1. Platform-Specific Configuration =====
|
||||
/**
|
||||
* Interface priority list (ordered by most preferred first).
|
||||
* Windows: Physical Ethernet/Wi-Fi before virtual adapters
|
||||
* macOS: Built-in en0 (Ethernet/Wi-Fi) before secondary interfaces
|
||||
* Linux: Standard eth0/wlan0 before containers/virtual NICs
|
||||
*/
|
||||
const PRIORITY_CONFIG = {
|
||||
win32: [
|
||||
{ name: 'Ethernet', type: 'wired' }, // Primary wired
|
||||
{ name: 'Wi-Fi', type: 'wireless' }, // Primary wireless
|
||||
{ name: 'Local Area Connection', type: 'wired' }, // Legacy wired
|
||||
],
|
||||
darwin: [
|
||||
{ name: 'en0', type: 'wired/wireless' }, // macOS primary
|
||||
{ name: 'en1', type: 'secondary' }, // macOS secondary
|
||||
],
|
||||
linux: [
|
||||
{ name: 'eth0', type: 'wired' }, // Linux primary Ethernet
|
||||
{ name: 'wlan0', type: 'wireless' }, // Linux primary wireless
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Virtual interfaces to exclude (case-insensitive partial matches):
|
||||
* - Common: Docker, VPNs, loopback
|
||||
* - Windows: Hyper-V, VMware, Bluetooth
|
||||
* - macOS: AWDL (Apple Wireless Direct Link), virtualization
|
||||
* - Linux: Kubernetes, libvirt bridges
|
||||
*/
|
||||
const VIRTUAL_INTERFACES = {
|
||||
all: ['docker', 'veth', 'tun', 'lo'], // Cross-platform virtual NICs
|
||||
win32: ['Virtual', 'vEthernet', 'Teredo', 'Bluetooth'],
|
||||
darwin: ['awdl', 'bridge', 'utun'],
|
||||
linux: ['virbr', 'kube', 'cni'],
|
||||
};
|
||||
|
||||
// ===== 2. Priority Interface Check =====
|
||||
const platformPriorities = PRIORITY_CONFIG[platform] || [];
|
||||
const virtualExcludes = [...VIRTUAL_INTERFACES.all, ...(VIRTUAL_INTERFACES[platform] || [])];
|
||||
|
||||
for (const { name: ifName } of platformPriorities) {
|
||||
// Windows: Match interface names containing priority string (e.g., "Ethernet 2")
|
||||
// Unix: Match exact interface names (eth0, wlan0)
|
||||
const matchingIfaces = platform === 'win32' ? Object.keys(ifaces).filter((k) => k.includes(ifName)) : [ifName];
|
||||
|
||||
for (const interfaceName of matchingIfaces) {
|
||||
const addr = findValidAddress(ifaces[interfaceName]);
|
||||
if (addr) {
|
||||
return addr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 3. Fallback: Full Interface Scan =====
|
||||
const fallbackAddress = scanAllInterfaces(ifaces, virtualExcludes);
|
||||
if (fallbackAddress) return fallbackAddress;
|
||||
|
||||
// ===== 4. Final Fallback =====
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans all non-virtual interfaces for valid IPv4 addresses
|
||||
* @param {Object} ifaces - Network interfaces from os.networkInterfaces()
|
||||
* @param {string[]} excludes - Virtual interface prefixes to ignore
|
||||
* @returns {string|null} First valid IPv4 address found
|
||||
*/
|
||||
function scanAllInterfaces(ifaces, excludes) {
|
||||
for (const [name, addresses] of Object.entries(ifaces)) {
|
||||
// Skip interfaces with excluded prefixes (case-insensitive)
|
||||
if (excludes.some((ex) => name.toLowerCase().includes(ex.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
const addr = findValidAddress(addresses);
|
||||
if (addr) {
|
||||
console.log(`[Fallback] Using ${name}: ${addr}`);
|
||||
return addr;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a network address as:
|
||||
* - IPv4 family
|
||||
* - Non-internal (not loopback)
|
||||
* - Non-APIPA (not 169.254.x.x)
|
||||
* @param {Object[]} addresses - Network interface addresses
|
||||
* @returns {string|undefined} Valid address or undefined
|
||||
*/
|
||||
function findValidAddress(addresses) {
|
||||
return addresses?.find(
|
||||
(addr) => addr.family === 'IPv4' && !addr.internal && !addr.address.startsWith('169.254.'), // Exclude APIPA
|
||||
)?.address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the appropriate FFmpeg executable path for the current platform
|
||||
*
|
||||
* @param {string} platform - The Node.js process.platform value (darwin, linux, win32)
|
||||
* @returns {string} The first valid FFmpeg path found, or the default path for the platform
|
||||
*
|
||||
* @description
|
||||
* This function handles FFmpeg path detection across different operating systems.
|
||||
* It checks common installation locations and returns the first accessible path.
|
||||
* If no valid path is found, it returns the first default path for the platform.
|
||||
*/
|
||||
function getFFmpegPath(platform) {
|
||||
// Common FFmpeg installation paths organized by platform
|
||||
const paths = {
|
||||
// macOS (Homebrew default locations)
|
||||
darwin: [
|
||||
'/usr/local/bin/ffmpeg', // Traditional Homebrew location
|
||||
'/opt/homebrew/bin/ffmpeg', // Apple Silicon Homebrew location
|
||||
],
|
||||
// Linux (common package manager locations)
|
||||
linux: [
|
||||
'/usr/bin/ffmpeg', // System package manager installation
|
||||
'/usr/local/bin/ffmpeg', // Manual compilation default
|
||||
],
|
||||
// Windows (common installation paths)
|
||||
win32: [
|
||||
'C:\\ffmpeg\\bin\\ffmpeg.exe', // Standard FFmpeg Windows installation
|
||||
'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe', // Program Files installation
|
||||
],
|
||||
};
|
||||
|
||||
// Get platform-specific paths or default to Linux paths if platform not recognized
|
||||
const platformPaths = paths[platform] || ['/usr/bin/ffmpeg'];
|
||||
|
||||
// Try to find the first existing accessible path
|
||||
for (const path of platformPaths) {
|
||||
try {
|
||||
// Check if the path exists and is accessible
|
||||
fs.accessSync(path);
|
||||
return path;
|
||||
} catch (e) {
|
||||
// Path not accessible, try next one
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If no path was accessible, return the first default path for the platform
|
||||
// This allows the calling code to handle the "not found" case with proper error messaging
|
||||
return platformPaths[0];
|
||||
}
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم