diff --git a/.gitignore b/.gitignore index 5c61289..5c835c7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ config.json public/b public/ca public/t +public/hall_cache +public/hall_custom deleted/b deleted/ca deleted/t diff --git a/config_example.json b/config_example.json index 0b3aced..e2f3a13 100644 --- a/config_example.json +++ b/config_example.json @@ -38,6 +38,8 @@ "cache": false, "eps": 155, "background": true, + "log_user_ips": false, + "hash_user_ips": true, "description": "Example Description", "themes": [ "amoled" ], diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql index 225bf52..24b38cb 100644 --- a/migrations/f0ckm_schema.sql +++ b/migrations/f0ckm_schema.sql @@ -2699,5 +2699,19 @@ GRANT ALL ON SCHEMA public TO PUBLIC; -- PostgreSQL database dump complete -- -\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG +-- Migration to add user_ips table for historical IP logging +CREATE TABLE IF NOT EXISTS user_ips ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + ip TEXT NOT NULL, + first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, ip) +); +CREATE INDEX IF NOT EXISTS idx_user_ips_user_id ON user_ips(user_id); + +-- Add IP tracking to user_sessions for "current" IP view +ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS ip TEXT; + +\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index ebb9b4a..fcdffa2 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -11642,15 +11642,13 @@ i.iconset#a_oc { .responsive-table td { border: none; position: relative; - padding: 12px 15px 12px 42% !important; - /* Slightly reduced labels width for more content space */ + padding: 12px 15px !important; min-height: 48px; display: flex; - align-items: center; - justify-content: flex-start; - flex-wrap: nowrap; - /* Prevent wrapping inside standard cells */ - gap: 12px; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 6px; text-align: left !important; border-bottom: 1px solid rgba(255, 255, 255, 0.03); box-sizing: border-box; @@ -11682,9 +11680,8 @@ i.iconset#a_oc { .responsive-table td::before { content: attr(data-label); - position: absolute; - left: 15px; - width: 35%; + position: static; + width: auto; white-space: nowrap; font-weight: 800; color: var(--accent); @@ -11692,6 +11689,7 @@ i.iconset#a_oc { text-transform: uppercase; letter-spacing: 1px; opacity: 0.8; + margin-bottom: 2px; } /* Special handling for the Actions cell */ @@ -13508,4 +13506,56 @@ body.scroller-active #gchat-reopen-bubble { #nav-meme-link, #nav-upload-link { white-space: nowrap; -} \ No newline at end of file +} +/* --- Admin IP History Styles --- */ +.admin-ips-table { + width: 100%; + border-collapse: separate; + border-spacing: 0 8px; + color: var(--white); +} +.admin-ips-table th { + padding: 15px; + text-align: left; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 1px; + color: #888; + border-bottom: 1px solid rgba(255,255,255,0.05); +} +.admin-ips-table tr { + transition: all 0.2s ease; +} +.admin-ips-table tbody tr { + background: rgba(255, 255, 255, 0.02); +} +.admin-ips-table tbody tr:hover { + background: rgba(255, 255, 255, 0.05); +} +.admin-ips-table td { + padding: 15px; + vertical-align: middle; +} +.ip-badge { + font-family: monospace; + padding: 4px 10px; + background: rgba(var(--accent-rgb, 153, 255, 0), 0.1); + border: 1px solid rgba(var(--accent-rgb, 153, 255, 0), 0.2); + color: var(--accent); + border-radius: 4px; + font-size: 0.9rem; + word-break: break-all; + display: inline-block; + max-width: 100%; +} +.date-cell { + font-size: 0.85rem; + color: #aaa; +} +.date-label { + display: block; + font-size: 0.7rem; + color: #666; + text-transform: uppercase; + margin-bottom: 2px; +} diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 2bb5ab1..9379e3d 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -23,6 +23,14 @@ window.cancelAnimFrame = (function () { return div.innerHTML; }; + window.getCurrentItemId = () => { + const path = window.location.pathname; + // Explicitly ignore admin/mod/settings paths to avoid false positives from user IDs, etc. + if (path.includes('/admin/') || path.includes('/mod/') || path.includes('/settings') || path.includes('/user/')) return null; + const match = path.match(/\/(\d+)\/?$/); + return match ? match[1] : null; + }; + // OS and Browser detection for CSS targeting const ua = navigator.userAgent; const htmlEl = document.documentElement; @@ -120,9 +128,7 @@ window.cancelAnimFrame = (function () { }); // Refresh background canvas if it matches the current item - const pathParts = window.location.pathname.split('/'); - const numParts = pathParts.filter(s => /^\d+$/.test(s)); - const currentId = numParts.length > 0 ? numParts[numParts.length - 1] : null; + const currentId = window.getCurrentItemId(); if (currentId === idStr && window.initBackground) { window.initBackground(); } @@ -943,9 +949,7 @@ window.cancelAnimFrame = (function () { // Always use the thumbnail first for instant backdrop — thumbnails are tiny, // often browser-cached from grid view, and give us a frame-0 equivalent for GIFs too. // Extract item ID from URL for thumbnail path. - const pathParts = window.location.pathname.split('/'); - const numParts = pathParts.filter(s => /^\d+$/.test(s)); - const itemId = numParts.length > 0 ? numParts[numParts.length - 1] : null; + const itemId = window.getCurrentItemId(); const showCanvas = () => { canvas.classList.remove('fader-out', 'fast-fade'); @@ -1055,9 +1059,7 @@ window.cancelAnimFrame = (function () { if (background) { canvas._bgFadingOut = false; // Draw the item thumbnail if we have an item ID in the URL - const pathParts = window.location.pathname.split('/'); - const numParts = pathParts.filter(s => /^\d+$/.test(s)); - const itemId = numParts.length > 0 ? numParts[numParts.length - 1] : null; + const itemId = window.getCurrentItemId(); if (itemId) { const context = canvas.getContext('2d'); const cw = canvas.width = canvas.clientWidth | 0; diff --git a/public/s/js/upload.js b/public/s/js/upload.js index bb26c1f..278e183 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -1,11 +1,11 @@ -const escapeHtmlUpload = (unsafe) => { +window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => { return (unsafe || '').toString() .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); -}; +}); window.initUploadForm = (selector) => { const form = (typeof selector === 'string') ? document.querySelector(selector) : selector; @@ -749,7 +749,7 @@ window.initUploadForm = (selector) => { chip.className = 'tag-chip'; chip.style.cursor = 'pointer'; chip.title = 'Click to edit prefix or tag'; - chip.innerHTML = `${escapeHtmlUpload(tagName)}`; + chip.innerHTML = `${window.escapeHtmlUpload(tagName)}`; // Remove button logic chip.querySelector('button').addEventListener('click', (e) => { @@ -867,7 +867,7 @@ window.initUploadForm = (selector) => { const sug = document.createElement('div'); sug.className = 'meta-suggestion'; sug.setAttribute('data-text', text); - sug.innerHTML = ` ${escapeHtmlUpload(text)}`; + sug.innerHTML = ` ${window.escapeHtmlUpload(text)}`; sug.addEventListener('mouseup', (ev) => { const sel = window.getSelection?.()?.toString().trim(); @@ -976,7 +976,7 @@ window.initUploadForm = (selector) => { const scoreStr = typeof s.score === 'number' ? s.score.toFixed(2) : '0.00'; html += `
- ${escapeHtmlUpload(s.tag)} + ${window.escapeHtmlUpload(s.tag)} ${s.tagged || 0}× · ${scoreStr}
`; diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index c9bb83a..4238b31 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -11,7 +11,7 @@ import cfg from "../config.mjs"; import security from "../security.mjs"; import crypto from "crypto"; import path from "path"; -import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf } from "../settings.mjs"; +import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps } from "../settings.mjs"; export default (router, tpl) => { router.get(/^\/login(\/)?$/, async (req, res) => { @@ -102,13 +102,17 @@ export default (router, tpl) => { created_at: stamp, last_used: stamp, last_action: "/login", - kmsi: typeof req.post.kmsi !== 'undefined' ? 1 : 0 + kmsi: typeof req.post.kmsi !== 'undefined' ? 1 : 0, + ip: ip }; await db` - insert into "user_sessions" ${db(blah, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi') + insert into "user_sessions" ${db(blah, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip') } `; + + // Log IP for historical data + await security.logUserIP(user[0].id, ip); return res.writeHead(301, { "Cache-Control": "no-cache, public", @@ -277,6 +281,8 @@ export default (router, tpl) => { totals: await lib.countf0cks(), session: req.session, manual_approval: getManualApproval(), + log_user_ips: getLogUserIps(), + hash_user_ips: getHashUserIps(), tmp: null }, req) }); @@ -303,6 +309,32 @@ export default (router, tpl) => { activeUsers: activeUsernames.length, activeUserList: activeUsernames, totals: await lib.countf0cks(), + log_user_ips: getLogUserIps(), + tmp: null + }, req) + }); + }); + + router.get(/\/admin\/user\/(?\d+)\/ips(\/)?$/, lib.auth, async (req, res) => { + const userId = +req.params.userId; + const user = await db`select "user", login from "user" where id = ${userId} limit 1`; + if (user.length === 0) return res.reply({ code: 404, body: 'User not found' }); + + const rows = await db` + select * from user_ips + where user_id = ${userId} + order by last_seen desc + `; + + res.reply({ + body: tpl.render("admin/user_ips", { + session: req.session, + targetUser: user[0], + ips: rows, + page_meta: { + title: `IP History - ${user[0].user}` + }, + totals: await lib.countf0cks(), tmp: null }, req) }); @@ -580,10 +612,14 @@ export default (router, tpl) => { router.post(/^\/admin\/settings\/?$/, lib.auth, async (req, res) => { const manual_approval = req.post.manual_approval === 'on' ? 'true' : 'false'; const registration_open = req.post.registration_open === 'on' ? 'true' : 'false'; + const log_user_ips = req.post.log_user_ips === 'on' ? 'true' : 'false'; + const hash_user_ips = req.post.hash_user_ips === 'on' ? 'true' : 'false'; const min_tags = isNaN(parseInt(req.post.min_tags)) ? 3 : Math.max(0, parseInt(req.post.min_tags)); const trusted_uploads = Math.max(0, parseInt(req.post.trusted_uploads) ?? 3); await db`INSERT INTO site_settings (key, value) VALUES ('manual_approval', ${manual_approval}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; + await db`INSERT INTO site_settings (key, value) VALUES ('log_user_ips', ${log_user_ips}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; + await db`INSERT INTO site_settings (key, value) VALUES ('hash_user_ips', ${hash_user_ips}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; if (cfg.websrv.open_registration_web_toggle !== false) { await db`INSERT INTO site_settings (key, value) VALUES ('registration_open', ${registration_open}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; @@ -594,6 +630,8 @@ export default (router, tpl) => { await db`INSERT INTO site_settings (key, value) VALUES ('trusted_uploads', ${trusted_uploads.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; setManualApproval(manual_approval === 'true'); + setLogUserIps(log_user_ips === 'true'); + setHashUserIps(hash_user_ips === 'true'); setMinTags(min_tags); setTrustedUploads(trusted_uploads); @@ -697,6 +735,7 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1); total, hasMore: users.length === limit, totals: await lib.countf0cks(), + log_user_ips: getLogUserIps(), tmp: null }; diff --git a/src/inc/security.mjs b/src/inc/security.mjs index ab23980..3268ad7 100644 --- a/src/inc/security.mjs +++ b/src/inc/security.mjs @@ -26,7 +26,10 @@ export default new class { * @returns {string} */ getRealIP(req) { - let ip = req.headers['x-real-ip'] || + let ip = req.headers['cf-connecting-ip'] || + req.headers['true-client-ip'] || + req.headers['x-client-ip'] || + req.headers['x-real-ip'] || (req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) || req.socket.remoteAddress; @@ -34,11 +37,14 @@ export default new class { // Handle IPv6 loopback and mapped IPv4 if (ip === "::1") ip = "127.0.0.1"; - if (ip.startsWith("::ffff:")) ip = ip.substring(7); + if (ip && ip.startsWith("::ffff:")) ip = ip.substring(7); // Basic IPv6 normalization (ensure consistent case and representation if possible) - // Note: Simple hex strings for IP are fine for hashing as long as Nginx is consistent. - if (ip.includes(":")) ip = ip.toLowerCase(); + if (ip && ip.includes(":")) ip = ip.toLowerCase(); + + if (cfg.main.development && ip === "127.0.0.1" && req.headers) { + console.debug('[SECURITY] Local IP detected. Headers:', req.headers); + } return ip; } @@ -163,4 +169,22 @@ export default new class { return false; } + + /** + * Log user IP for historical tracking if enabled. + * @param {number} userId + * @param {string} ip + */ + async logUserIP(userId, ip) { + if (!cfg.websrv.log_user_ips || !userId || !ip) return; + + const { getHashUserIps } = await import("./settings.mjs"); + const finalIp = getHashUserIps() ? this.hashIP(ip) : ip; + + await db` + insert into user_ips (user_id, ip) + values (${userId}, ${finalIp}) + on conflict (user_id, ip) do update set last_seen = now() + `.catch(err => console.error(`[SECURITY] Failed to log user IP:`, err)); + } }; diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs index 3f10a09..3437139 100644 --- a/src/inc/settings.mjs +++ b/src/inc/settings.mjs @@ -44,3 +44,18 @@ export const setPrivateMessages = (val) => private_messages = !!val; export const getDefaultLayout = () => default_layout; export const setDefaultLayout = (val) => default_layout = (val === 'legacy' ? 'legacy' : 'modern'); + +let log_user_ips = false; +export const getLogUserIps = () => log_user_ips; +export const setLogUserIps = (val) => { + log_user_ips = !!val; + // Also update the config object for components that read from it directly + cfg.websrv.log_user_ips = log_user_ips; +}; + +let hash_user_ips = false; +export const getHashUserIps = () => hash_user_ips; +export const setHashUserIps = (val) => { + hash_user_ips = !!val; + cfg.websrv.hash_user_ips = hash_user_ips; +}; diff --git a/src/index.mjs b/src/index.mjs index 0136667..607de2b 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -19,6 +19,7 @@ import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf } from "./inc/settings.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { createI18n } from "./inc/i18n.mjs"; +import security from "./inc/security.mjs"; const nginx502 = ` 502 Bad Gateway @@ -297,12 +298,17 @@ process.on('uncaughtException', err => { // log last action (Fire-and-Forget) if (!req.url.pathname.startsWith('/api/notifications')) { + const { getLogUserIps, getHashUserIps } = await import("./inc/settings.mjs"); + const currentIp = security.getRealIP(req); + const finalIp = getHashUserIps() ? security.hashIP(currentIp) : currentIp; + db` update "user_sessions" set ${db({ last_used: ~~(Date.now() / 1e3), last_action: req.url.pathname, - browser: req.headers['user-agent'] - }, 'last_used', 'last_action', 'browser') + browser: req.headers['user-agent'], + ...(getLogUserIps() ? { ip: finalIp } : {}) + }, 'last_used', 'last_action', 'browser', ...(getLogUserIps() ? ['ip'] : [])) } where id = ${+user[0].sess_id} `.catch(e => console.error('[MIDDLEWARE] Session update failed:', e)); @@ -310,6 +316,9 @@ process.on('uncaughtException', err => { // Update last_seen on user table (Fire-and-Forget) — feeds the 30-day orakel pool db`update "user" set last_seen = ${~~(Date.now() / 1e3)} where id = ${+user[0].id}` .catch(e => console.error('[MIDDLEWARE] last_seen update failed:', e)); + + // Log IP for historical data + security.logUserIP(user[0].id, currentIp); } if (req.session.admin) { @@ -655,6 +664,30 @@ process.on('uncaughtException', err => { setEnablePdf(!!cfg.enable_pdf); console.log(`[BOOT] Enable PDF setting: ${getEnablePdf()}`); + // Fetch log_user_ips and hash_user_ips setting + const { getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps } = await import("./inc/settings.mjs"); + try { + const lipSetting = await db`SELECT value FROM site_settings WHERE key = 'log_user_ips' LIMIT 1`; + if (lipSetting.length > 0) { + setLogUserIps(lipSetting[0].value === 'true'); + } else { + setLogUserIps(!!cfg.websrv.log_user_ips); + } + console.log(`[BOOT] Log User IPs: ${getLogUserIps()}`); + + const hipSetting = await db`SELECT value FROM site_settings WHERE key = 'hash_user_ips' LIMIT 1`; + if (hipSetting.length > 0) { + setHashUserIps(hipSetting[0].value === 'true'); + } else { + setHashUserIps(!!cfg.websrv.hash_user_ips); + } + console.log(`[BOOT] Hash User IPs: ${getHashUserIps()}`); + } catch (e) { + console.warn(`[BOOT] IP logging settings fetch failed:`, e.message); + setLogUserIps(!!cfg.websrv.log_user_ips); + setHashUserIps(!!cfg.websrv.hash_user_ips); + } + // Load bypass_duplicate_check from config.json (static — not a DB setting) if (cfg.websrv.bypass_duplicate_check === true) { setBypassDuplicateCheck(true); diff --git a/views/admin.html b/views/admin.html index b24fe5d..86ba84a 100644 --- a/views/admin.html +++ b/views/admin.html @@ -40,6 +40,26 @@ +
+
+ +

Log all historical IPs for user accounts.

+
+ +
+
+
+ +

Anonymize IPs by hashing them (same as failed logins).

+
+ +
@if(registration_web_toggle_enabled)
@@ -99,6 +119,8 @@ const status = document.getElementById('settings-status'); const approvalToggle = document.getElementById('manual_approval_toggle'); const registrationToggle = document.getElementById('registration_open_toggle'); + const logIpsToggle = document.getElementById('log_user_ips_toggle'); + const hashIpsToggle = document.getElementById('hash_user_ips_toggle'); const minTagsInput = document.getElementById('min_tags_input'); const trustedUploadsInput = document.getElementById('trusted_uploads_input'); @@ -114,6 +136,8 @@ }, body: new URLSearchParams({ manual_approval: approvalToggle.checked ? 'on' : 'off', + log_user_ips: logIpsToggle.checked ? 'on' : 'off', + hash_user_ips: hashIpsToggle.checked ? 'on' : 'off', ...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}), min_tags: minTagsInput.value, trusted_uploads: trustedUploadsInput.value, diff --git a/views/admin/sessions.html b/views/admin/sessions.html index ad4643c..22ec814 100644 --- a/views/admin/sessions.html +++ b/views/admin/sessions.html @@ -30,6 +30,12 @@
+ @if(log_user_ips) +
+ IP: + {{ s.ip || 'unknown' }} +
+ @endif
Browser: {{ s.browser }} diff --git a/views/admin/user_ips.html b/views/admin/user_ips.html new file mode 100644 index 0000000..e988544 --- /dev/null +++ b/views/admin/user_ips.html @@ -0,0 +1,56 @@ +@include(snippets/header) + +
+
+
+
+ + Back to User Manager + +

IP History: {!! targetUser.user !!}

+

Historical IP addresses associated with this account.

+
+ +
+ + + + + + + + + + @if(ips && ips.length > 0) + @each(ips as row) + + + + + + @endeach + @else + + + + @endif + +
IP AddressFirst SeenLast Seen
{{ row.ip }} +
+ Initial + {{ new Date(row.first_seen).toLocaleString() }} +
+
+
+ Most Recent + {{ new Date(row.last_seen).toLocaleString() }} +
+
+ No IP history records found for this user. +
+
+
+
+
+ +@include(snippets/footer) diff --git a/views/admin/users_list.html b/views/admin/users_list.html index 5b2faae..3f05c60 100644 --- a/views/admin/users_list.html +++ b/views/admin/users_list.html @@ -68,6 +68,9 @@ @endif @if(u.id) + @if(log_user_ips) + IP Hist + @endif diff --git a/views/snippets/footer.html b/views/snippets/footer.html index 4cd7376..ee75fb1 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -537,6 +537,7 @@ @if(session) + diff --git a/views/snippets/navbar.html b/views/snippets/navbar.html index 83ba063..3bb6d2b 100644 --- a/views/snippets/navbar.html +++ b/views/snippets/navbar.html @@ -375,7 +375,4 @@
- - - @endif