Skip to content
  • 36 Votes
    45 Posts
    13k Views
    OGProxy : follow-up: second memory leak found & fixed Context After this morning’s fixes (download limit, cache TTL, systemd MemoryMax), the server stayed up, but during the afternoon OGProxy slowly climbed to 464 MB RSS with all 4 GB of swap consumed. The systemd MemoryMax=512M guard rail did its job (it capped OGProxy instead of letting it take the whole box down like before), which bought time to diagnose calmly. This was a second, separate leak, slower than the first. Root cause The logs showed the smoking gun: MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 22 terminated listeners added to [Fetch]. MaxListeners is 21. Stack: ogs 6.1.0 → undici 5.22.1 on Node 24. ogs v6 implements its timeout option via an AbortSignal passed to undici. With this version combo, when a request is aborted by that internal timeout, the abort listener attached to the Fetch object is not removed. Every timed-out request leaks one listener, and they accumulate in memory. Trigger: a 10-day-old forum post listing ~10 store.ubisoft.com links. Opening that topic fires ~10 previews in parallel, all hitting the timeout, each leaking a listener. Repeated views over the day pushed it to 464 MB + full swap. There was also a vicious circle: as the process bloated, its own outbound fetches got slow enough to time out, which created more timeouts, which leaked more listeners. That explains the flood of Connect Timeout Error in the afternoon logs, hey were a symptom of the leak, not an external block. Once restarted fresh, those same Ubisoft URLs returned success: true in ~2.4 s. Fix Stop using ogs’s internal timeout option (the leaking path). Instead, manage the timeout with our own AbortController + setTimeout, pass the signal via fetchOptions, and always clearTimeout() in a finally block, which detaches the abort listener on every exit path (success, failure, or timeout). Also raised EventEmitter.defaultMaxListeners to 50 as a safety net for legitimate concurrency bursts (like that 10-link post). Verified: with our own signal aborting at 3 s, a Ubisoft URL completed in 2.4 s (signal is respected by ogs 6.1). After deploy, no more MaxListenersExceededWarning, no more cascade timeouts, and memory now oscillates (217 MB under load → back down to 91 MB at rest) instead of climbing and staying climbed. Note on RuntimeMaxSec The existing RuntimeMaxSec=86400 (forced daily restart) was almost certainly an earlier band-aid masking exactly this leak. Now that the cause is fixed, it can be removed once stability is confirmed over 24–48 h, but it’s harmless to keep for now. Only server.js changed (client ACP + systemd unit unchanged) const express = require('express'); const ogs = require('open-graph-scraper'); const cors = require('cors'); const { URL } = require('url'); const cache = require('memory-cache'); const dns = require('dns').promises; const net = require('net'); // Raise the listener ceiling as a safety net against transient concurrency spikes require('events').EventEmitter.defaultMaxListeners = 50; const app = express(); const port = 2000; // API key from environment, fallback to inline value for compatibility const apiKey = process.env.OGPROXY_API_KEY || 'YOUR_API_KEY_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; // --- Limits / safeguards --- const REQUEST_TIMEOUT = 15000; // 15s max per fetch const MAX_CONTENT_BYTES = 5 * 1024 * 1024; // 5 MB max downloaded page const CACHE_TTL_MS = 60 * 60 * 1000; // success cache: 1h const FAIL_CACHE_TTL_MS = 10 * 60 * 1000; // negative cache: 10 min const CACHE_MAX_ENTRIES = 1000; // max cached entries const MAX_REDIRECTS = 3; // cap redirect hops // Returns true if an IP string is private / loopback / link-local / reserved function isBlockedIp(ip) { if (!ip) return true; if (net.isIPv4(ip)) { const p = ip.split('.').map(Number); if (p[0] === 10) return true; if (p[0] === 127) return true; if (p[0] === 0) return true; if (p[0] === 169 && p[1] === 254) return true; // link-local / cloud metadata if (p[0] === 192 && p[1] === 168) return true; if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true; if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true; // CGNAT return false; } if (net.isIPv6(ip)) { const v = ip.toLowerCase(); if (v === '::1') return true; if (v.startsWith('fc') || v.startsWith('fd')) return true; // unique local if (v.startsWith('fe80')) return true; // link-local if (v.startsWith('::ffff:')) return isBlockedIp(v.split(':').pop()); // IPv4-mapped return false; } return true; // not a valid IP -> block by default } // Static hostname guard (fast reject before any DNS work) function isBlockedHost(hostname) { if (!hostname) return true; const h = hostname.toLowerCase(); return ( h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.internal') || h.endsWith('.local') || (net.isIP(h) && isBlockedIp(h)) // literal IP in URL ); } // Resolve hostname and ensure no resolved IP is private (anti-SSRF via DNS) async function resolvesToPublicIp(hostname) { try { const records = await dns.lookup(hostname, { all: true }); if (!records || records.length === 0) return false; return records.every(r => !isBlockedIp(r.address)); } catch (e) { return false; // DNS failure -> treat as unsafe } } app.use(cors({ origin: 'https://YOUR_DOMAINE.EXT' })); app.get('/ogproxy', async (req, res) => { let { url } = req.query; const requestApiKey = req.headers['x-api-key']; if (requestApiKey !== apiKey) { return res.status(401).send('Unauthorized'); } if (!url || typeof url !== 'string') { return res.status(400).send('Missing URL parameter'); } if (!url.startsWith('http')) { try { url = new URL(url, `${req.protocol}://${req.get('host')}`).href; } catch (e) { return res.status(400).send('Invalid URL'); } } // Parse + protocol check let parsedUrl; try { parsedUrl = new URL(url); } catch (e) { console.warn(`OGProxy reject [${url}]: invalid URL`); return res.status(400).send('Invalid URL'); } if (!['http:', 'https:'].includes(parsedUrl.protocol)) { console.warn(`OGProxy reject [${url}]: invalid protocol`); return res.status(400).send('Invalid protocol'); } // Static host guard if (isBlockedHost(parsedUrl.hostname)) { console.warn(`OGProxy reject [${url}]: forbidden host (static guard)`); return res.status(403).send('Forbidden host'); } // Cache hit (success OR negative) — checked before DNS to stay fast const cachedResult = cache.get(url); if (cachedResult) { if (cachedResult.__ogproxyFail === true) { return res.status(500).send('Error scraping Open Graph data (cached)'); } return res.json(cachedResult); } // DNS-based SSRF guard: make sure the hostname doesn't resolve to a private IP if (!(await resolvesToPublicIp(parsedUrl.hostname))) { console.warn(`OGProxy reject [${url}]: resolves to private IP or DNS fail (SSRF guard)`); cache.put(url, { __ogproxyFail: true }, FAIL_CACHE_TTL_MS); return res.status(403).send('Forbidden host'); } // Enforce cache cap before inserting a new entry if (cache.keys().length >= CACHE_MAX_ENTRIES) { cache.clear(); } // Manage the timeout ourselves with an AbortController we clean up explicitly. // This avoids the listener leak from ogs/undici's internal `timeout` option // (ogs 6.x + undici 5.x on Node 24 leaks an abort listener per timed-out request, // which slowly fills RAM/swap). clearTimeout() in finally detaches the listener. const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); const options = { url, downloadLimit: MAX_CONTENT_BYTES, fetchOptions: { signal: controller.signal, redirect: 'follow', follow: MAX_REDIRECTS, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8', }, }, }; try { const results = await ogs(options); cache.put(url, results, CACHE_TTL_MS); return res.json(results); } catch (error) { const reason = (error && error.result && error.result.error) || (error && error.message) || 'unknown'; const status = (error && error.response && error.response.status) || 'n/a'; console.error(`OGProxy fail [${url}]: ${reason} (HTTP ${status})`); cache.put(url, { __ogproxyFail: true }, FAIL_CACHE_TTL_MS); return res.status(500).send('Error scraping Open Graph data'); } finally { // Always clear the timer — detaches the abort listener and stops the leak clearTimeout(timer); } }); app.listen(port, () => { console.log(`OGProxy server listening on port ${port}`); }); Possible upstream-clean alternative (optional) Upgrading open-graph-scraper to its latest 6.x (which bundles a newer undici) may fix the listener cleanup at the source, letting you go back to the simpler built-in timeout option. Worth checking when convenient, but the AbortController approach above is robust regardless of the undici version, so there’s no rush.
  • [NODEBB] Help for my custom CSS

    Solved Customisation nodebb css bugfix
    247
    49 Votes
    247 Posts
    110k Views
    Mark, the situation is that since I used your CSS files, I don’t even have the Frontawese folder. Because of that, I really need your direct help. I’d really appreciate it if you could take a look whenever you have some free time.
  • Cloud Storage

    General zeitkapsl europe cloud proton storage
    2
    0 Votes
    2 Posts
    82 Views
    For EU-based, end-to-end encrypted cloud storage, here are the strongest options beyond what you’re already using: Tresorit (Switzerland) : Probably the gold standard for E2EE. Zero-knowledge, Swiss/EU data residency, excellent for both file sync and secure sharing. More expensive than competitors but very polished and audited. Filen (Germany) : Zero-knowledge E2EE by default, generous free tier (10GB), very competitive pricing on paid plans, and lifetime plans available. Open-source clients. Popular on the privacy subreddits as a Proton Drive alternative. Internxt (Spain) : Open-source, zero-knowledge E2EE, GDPR-based. Offers storage plus a few extra privacy tools. Lifetime plans available. Younger company, so weigh that against the price. pCloud (Switzerland, registered in EU) : Massive caveat: E2EE is not default. It’s a paid add-on called “pCloud Crypto.” Without it your files are encrypted at rest but not zero-knowledge. The draw is genuine lifetime plans, which can be cost-effective over years if you trust their longevity.
  • Nodebb to Xenforo

    Solved Configure xenforo nodebb
    3
    0 Votes
    3 Posts
    290 Views
    @cagatay as @downpw stated, there isn’t a native tool that will do this for you. You’d need to either develop your own or ask the nodebb team to assist which will be a paid exercise.
  • What’s going on with NodeBB?

    Performance nodebb script die
    20
    8 Votes
    20 Posts
    2k Views
    @cagatay The most reliable way to upgrade Node.js on Ubuntu depends on how you originally installed it. Method 1: Using NVM (Recommended) If you already use Node Version Manager (NVM), upgrading is simple. NVM allows you to keep both versions and switch between them if needed. Install Node 22: nvm install 22 Switch to Node 22: nvm use 22 Set it as your default: nvm alias default 22 Verify the change: node -v Method 2: Using NodeSource (PPA) If you installed Node.js via apt using the NodeSource repository, you need to update the repository script to point to the new version. Remove the old NodeSource list (optional but cleaner): sudo rm /etc/apt/sources.list.d/nodesource.list Download and run the NodeSource setup script for Node 22: curl -fsSL [https://deb.nodesource.com/setup_22.x](https://deb.nodesource.com/setup_22.x) | sudo -E bash - Install/Upgrade Node.js: sudo apt-get install -y nodejs Verify the installation: node -v Method 3: Using the ‘n’ Package If you have npm installed, you can use the n interactive manager. Clear the npm cache: sudo npm cache clean -f Install the ‘n’ helper: sudo npm install -g n Install Node 22: sudo n 22 Update your shell: hash -r Troubleshooting Permission Denied: If you see permission errors using Method 2 or 3, ensure you are using sudo. Path Issues: If node -v still shows version 20 after upgrading via NVM, restart your terminal or run source ~/.bashrc. Conflicts: Avoid mixing these methods. If you switch from apt to nvm, it is best to sudo apt remove nodejs first to avoid path conflicts.
  • 2 Votes
    1 Posts
    210 Views
    No one has replied
  • 5 Votes
    4 Posts
    696 Views
    @crazycells I did see something similar to that article, yes. I never fully understood why anyone would want to give unfettered and complete access to AI without first understanding exactly what it had control of, and what it intended to do with that access. This isn’t the first “horror story” and it won’t be the last.
  • CTA banner for visitors

    Solved Bugs bug
    3
    1
    2 Votes
    3 Posts
    483 Views
    @crazycells Yes, because of this code $(document).ready(function () { $(window).on('action:ajaxify.end', function (data) { if (config && config.uid > 0) { // User is logged in, so don't fire any message } else { // Insert content into the selected element var addAfterLastPost = $( "<div class='alert alert-warning alert-dismissible fade show' role='alert'>" + "<p><strong>Hello! It looks like you're interested in this conversation, but you don't have an account yet.</strong></p>" + "<p>Getting fed up of having to scroll through the same posts each visit? When you register for an account, you'll always come back to exactly where you were before, " + "and choose to be notified of new replies (ether email, or push notification). You'll also be able to save bookmarks, use reactions, and upvote to show your appreciation to other community members.</p>" + "<p>With your input, this post could be even better 💗"+ "<br><br>"+ "<a style='margin-right:5px;' component='topic/reply/guest' href='/register' class='fw-semibold btn btn-sm btn-warning'>Register</a>" + "<a component='topic/reply/guest' href='/login' class='fw-semibold btn btn-sm btn-info'>Log in</a>" + "<button type='button' class='btn-close' data-bs-dismiss='alert' aria-label='Close'></button>" + "</div>" ); $('ul[component="topic"]').after(addAfterLastPost); } }); }); This was adopted into core as far as I know, so I’ve removed my manual code.
  • 4 Votes
    3 Posts
    515 Views
    thanks @DownPW ! this is definitely very helpful.
  • NodeBB socket with CloudFlare

    Solved Performance socket cloudflare nodebb
    24
    3 Votes
    24 Posts
    9k Views
    Solved. Tuto here and here
  • Arch Server Progress

    Chitchat arch linux server web server
    63
    30 Votes
    63 Posts
    16k Views
    @phenomlab thank you! I appreciate it!
  • 13 Votes
    12 Posts
    1k Views
    @Madchatthew This is really inspiring! Keep it up! There is life after Windows…
  • 3 Votes
    2 Posts
    484 Views
    @phenomlab this is good. I see this happen a lot. It is unclear because usually it is a group talking and going over things and trying to plan according to the knowledge that they have with what is working and not working now. And yet some time down the road, something new comes out, something changes and now what was possible isn’t and vise versa. I have seen these kinds of situations, and I encourage taking very good notes and making them so they are always accessible to you/your team so that way a search can be done and the notes appear and you know exactly why, what, how and where the decision was made. When I say you, I mean anyone in a position to make decisions that bring about change in whatever area it might be.
  • Nord VPN renewal

    Chitchat nord vpn renewal
    37
    2
    30 Votes
    37 Posts
    11k Views
    So far so good .
  • Adding a banner to chat messages

    Tips banner custom
    38
    1
    18 Votes
    38 Posts
    10k Views
    @phenomlab said: @DownPW Possible, yes, but not using the existing code. It would need to be changed to test for multiple entries based on two distinct widget areas. This should work (it’s already applied on your DEV server) function chatBanner() { var roomName = $("h5[component='chat/header/title']").text().trim(); var roomNameWidget = $('[id*="chat-modal"] .btn-ghost.btn-sm.dropdown-toggle').text().trim(); var bannerContent; if (roomName === "General" || roomNameWidget === "General") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Message 1. </div>'; } else if (roomName === "Support" || roomNameWidget === "Support") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Message 2.</div>'; } else if (roomName === "Info" || roomNameWidget === "Info") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Message 3</div>'; } else if (roomName === "xxxxxx" || roomNameWidget === "xxxxxx") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Message 4</div>'; } else if (roomName === "xxxxxx" || roomNameWidget === "xxxxxx") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Message 5</div>'; } else if (roomName === "xxxxxx" || roomNameWidget === "xxxxxx") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Message 6</div>'; } else { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Ce canal est une discussion privée. </div>'; } var chatMessagesContainer = $('[component="chat/system-message"]:last-of-type'); //var existingMessages = $('[component="chat/message"]'); var existingMessages = $('[component="chat/composer"]'); if (existingMessages.length === 0) { // If there are no messages, append the banner to the messages container chatMessagesContainer.first().after(bannerContent); } else { // If there are messages, add the banner after the last message //existingMessages.last().after(bannerContent); existingMessages.before(bannerContent); } } Here, we are using || which is essentially an OR operator. Because we cannot know the chat room ID in advance, it is necessary to use a wildcard to track it [id*="chat-modal"] .btn-ghost.btn-sm.dropdown-toggle I see bugs with this code and chat box widget I use on my categories page What was happening NodeBB allows multiple chat windows to be open simultaneously , the widget and the full/modal-page DM view. Both exist in the DOM at the same time. The original code used global jQuery selectors like $(‘[component=“chat/composer”]’) which scanned the entire page and found elements from both chat windows at once. When you opened “XY” caht while “XXY” was still open in the widget, the selectors would pick up the wrong room name or inject the banner into the wrong window. The key discovery was that the action:chat.loaded event passes the modal DOM element directly as data. By wrapping it in $(data) and using $modal.find(…) for every selector, all queries are scoped exclusively to the correct modal, making it impossible for two open chat windows to interfere with each other. FIX code (to adapt to your rooms) : function chatBanner(modalElement) { var $modal = $(modalElement); $modal.find('#chatbanner').remove(); var roomName = $modal.find('[component="chat/room/name"]').text().trim(); if (!roomName) { var placeholder = $modal.find('[component="chat/input"]').attr('placeholder') || ''; roomName = placeholder.replace(/^Message #?/, '').trim(); } var bannerContent; if (roomName === "General") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Chat message banner</div>'; } else if (roomName === "xxxxxxxxxx") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Chat message banner</div>'; } else if (roomName === "xxxxxxxxxx") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Chat message banner</div>'; } else if (roomName === "xxxxxxxxxx") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Chat message banner</div>'; } else if (roomName === "xxxxxxxxxx") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Chat message banner</div>'; } else if (roomName === "Les geeks de l'espace") { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Chat message banner</div>'; } else { bannerContent = '<div id="chatbanner"><i class="fa fa-fw fa-circle-info link-primary" aria-hidden="true"></i> Chat message banner</div>'; } $modal.find('[component="chat/composer"]').first().before(bannerContent); } $(window).on('action:chat.loaded', function(ev, data) { chatBanner(data); });
  • Custom Page - nodebb

    Solved Customisation custom-pages nodebb
    13
    2
    5 Votes
    13 Posts
    1k Views
    I’m happy to see this
  • Nodebb vs Wordpress vs Other

    General wordpress nodebb woocomerce business
    4
    2 Votes
    4 Posts
    715 Views
    PrestaShop + modules IA https://www.prestashop.com Magento https://developer.adobe.com/open/magento
  • External Links - New Window

    Solved Customisation nodebb links settings
    8
    2 Votes
    8 Posts
    892 Views
    @Sampo2910 search the forum here for ogproxy which is the client side version of that plugin I wrote. It’s in use here on this forum.
  • To the Window to the Linux . . .

    Pinned Linux arch linux windows endoflife
    22
    19 Votes
    22 Posts
    4k Views
    @phenomlab said: @Madchatthew ouch. Sounds nasty. Did you get to the bottom of why it happened? I believe it is due to not everything getting upgraded because i wasn’t checking on the different packages I had installed from the AUR. Then when I ran yay it was like, hey would you like to update all of these things that you haven’t updated in months, perhaps years or ever for that matter and I was like yes please If you don’t have yay there are no notifications that you need more updates than what you realize. Chrome was staying updated because it would give me a notification, but there was the nvidia package that needed to be upgraded as well and I had never upgraded it. I didn’t realize it and should have. Then some of those packages use cmake and that needed to be updated as well. So using yay is beneficial to make sure you get all the updates you need.
  • NodeBB Twitter / X embeds

    Let's Build It twitter script
    34
    21 Votes
    34 Posts
    10k Views
    @phenomlab said: @DownPW thanks for spotting (and fixing) this issue. I admittedly threw this together quickly for @jac some time ago, and it hasn’t had any love since. If OK with you, I’ll merge these changes into the github repository? No problem dude