Adding option to log users ips
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,6 +5,8 @@ config.json
|
|||||||
public/b
|
public/b
|
||||||
public/ca
|
public/ca
|
||||||
public/t
|
public/t
|
||||||
|
public/hall_cache
|
||||||
|
public/hall_custom
|
||||||
deleted/b
|
deleted/b
|
||||||
deleted/ca
|
deleted/ca
|
||||||
deleted/t
|
deleted/t
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
"cache": false,
|
"cache": false,
|
||||||
"eps": 155,
|
"eps": 155,
|
||||||
"background": true,
|
"background": true,
|
||||||
|
"log_user_ips": false,
|
||||||
|
"hash_user_ips": true,
|
||||||
|
|
||||||
"description": "Example Description",
|
"description": "Example Description",
|
||||||
"themes": [ "amoled" ],
|
"themes": [ "amoled" ],
|
||||||
|
|||||||
@@ -2699,5 +2699,19 @@ GRANT ALL ON SCHEMA public TO PUBLIC;
|
|||||||
-- PostgreSQL database dump complete
|
-- 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
|
||||||
|
|||||||
@@ -11642,15 +11642,13 @@ i.iconset#a_oc {
|
|||||||
.responsive-table td {
|
.responsive-table td {
|
||||||
border: none;
|
border: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 12px 15px 12px 42% !important;
|
padding: 12px 15px !important;
|
||||||
/* Slightly reduced labels width for more content space */
|
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: flex-start;
|
align-items: flex-start;
|
||||||
flex-wrap: nowrap;
|
justify-content: center;
|
||||||
/* Prevent wrapping inside standard cells */
|
gap: 6px;
|
||||||
gap: 12px;
|
|
||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -11682,9 +11680,8 @@ i.iconset#a_oc {
|
|||||||
|
|
||||||
.responsive-table td::before {
|
.responsive-table td::before {
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
position: absolute;
|
position: static;
|
||||||
left: 15px;
|
width: auto;
|
||||||
width: 35%;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -11692,6 +11689,7 @@ i.iconset#a_oc {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Special handling for the Actions cell */
|
/* Special handling for the Actions cell */
|
||||||
@@ -13508,4 +13506,56 @@ body.scroller-active #gchat-reopen-bubble {
|
|||||||
|
|
||||||
#nav-meme-link, #nav-upload-link {
|
#nav-meme-link, #nav-upload-link {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
/* --- 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ window.cancelAnimFrame = (function () {
|
|||||||
return div.innerHTML;
|
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
|
// OS and Browser detection for CSS targeting
|
||||||
const ua = navigator.userAgent;
|
const ua = navigator.userAgent;
|
||||||
const htmlEl = document.documentElement;
|
const htmlEl = document.documentElement;
|
||||||
@@ -120,9 +128,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Refresh background canvas if it matches the current item
|
// Refresh background canvas if it matches the current item
|
||||||
const pathParts = window.location.pathname.split('/');
|
const currentId = window.getCurrentItemId();
|
||||||
const numParts = pathParts.filter(s => /^\d+$/.test(s));
|
|
||||||
const currentId = numParts.length > 0 ? numParts[numParts.length - 1] : null;
|
|
||||||
if (currentId === idStr && window.initBackground) {
|
if (currentId === idStr && window.initBackground) {
|
||||||
window.initBackground();
|
window.initBackground();
|
||||||
}
|
}
|
||||||
@@ -943,9 +949,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
// Always use the thumbnail first for instant backdrop — thumbnails are tiny,
|
// 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.
|
// often browser-cached from grid view, and give us a frame-0 equivalent for GIFs too.
|
||||||
// Extract item ID from URL for thumbnail path.
|
// Extract item ID from URL for thumbnail path.
|
||||||
const pathParts = window.location.pathname.split('/');
|
const itemId = window.getCurrentItemId();
|
||||||
const numParts = pathParts.filter(s => /^\d+$/.test(s));
|
|
||||||
const itemId = numParts.length > 0 ? numParts[numParts.length - 1] : null;
|
|
||||||
|
|
||||||
const showCanvas = () => {
|
const showCanvas = () => {
|
||||||
canvas.classList.remove('fader-out', 'fast-fade');
|
canvas.classList.remove('fader-out', 'fast-fade');
|
||||||
@@ -1055,9 +1059,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (background) {
|
if (background) {
|
||||||
canvas._bgFadingOut = false;
|
canvas._bgFadingOut = false;
|
||||||
// Draw the item thumbnail if we have an item ID in the URL
|
// Draw the item thumbnail if we have an item ID in the URL
|
||||||
const pathParts = window.location.pathname.split('/');
|
const itemId = window.getCurrentItemId();
|
||||||
const numParts = pathParts.filter(s => /^\d+$/.test(s));
|
|
||||||
const itemId = numParts.length > 0 ? numParts[numParts.length - 1] : null;
|
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
const cw = canvas.width = canvas.clientWidth | 0;
|
const cw = canvas.width = canvas.clientWidth | 0;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
const escapeHtmlUpload = (unsafe) => {
|
window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
|
||||||
return (unsafe || '').toString()
|
return (unsafe || '').toString()
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """)
|
.replace(/"/g, """)
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
};
|
});
|
||||||
|
|
||||||
window.initUploadForm = (selector) => {
|
window.initUploadForm = (selector) => {
|
||||||
const form = (typeof selector === 'string') ? document.querySelector(selector) : selector;
|
const form = (typeof selector === 'string') ? document.querySelector(selector) : selector;
|
||||||
@@ -749,7 +749,7 @@ window.initUploadForm = (selector) => {
|
|||||||
chip.className = 'tag-chip';
|
chip.className = 'tag-chip';
|
||||||
chip.style.cursor = 'pointer';
|
chip.style.cursor = 'pointer';
|
||||||
chip.title = 'Click to edit prefix or tag';
|
chip.title = 'Click to edit prefix or tag';
|
||||||
chip.innerHTML = `<span class="tag-text">${escapeHtmlUpload(tagName)}</span><button type="button">×</button>`;
|
chip.innerHTML = `<span class="tag-text">${window.escapeHtmlUpload(tagName)}</span><button type="button">×</button>`;
|
||||||
|
|
||||||
// Remove button logic
|
// Remove button logic
|
||||||
chip.querySelector('button').addEventListener('click', (e) => {
|
chip.querySelector('button').addEventListener('click', (e) => {
|
||||||
@@ -867,7 +867,7 @@ window.initUploadForm = (selector) => {
|
|||||||
const sug = document.createElement('div');
|
const sug = document.createElement('div');
|
||||||
sug.className = 'meta-suggestion';
|
sug.className = 'meta-suggestion';
|
||||||
sug.setAttribute('data-text', text);
|
sug.setAttribute('data-text', text);
|
||||||
sug.innerHTML = `<i class="fa fa-plus-circle" style="user-select:none"></i> <span>${escapeHtmlUpload(text)}</span>`;
|
sug.innerHTML = `<i class="fa fa-plus-circle" style="user-select:none"></i> <span>${window.escapeHtmlUpload(text)}</span>`;
|
||||||
|
|
||||||
sug.addEventListener('mouseup', (ev) => {
|
sug.addEventListener('mouseup', (ev) => {
|
||||||
const sel = window.getSelection?.()?.toString().trim();
|
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';
|
const scoreStr = typeof s.score === 'number' ? s.score.toFixed(2) : '0.00';
|
||||||
html += `
|
html += `
|
||||||
<div class="tag-suggestion-item">
|
<div class="tag-suggestion-item">
|
||||||
<span class="tag-suggestion-name">${escapeHtmlUpload(s.tag)}</span>
|
<span class="tag-suggestion-name">${window.escapeHtmlUpload(s.tag)}</span>
|
||||||
<span class="tag-suggestion-meta">${s.tagged || 0}× · ${scoreStr}</span>
|
<span class="tag-suggestion-meta">${s.tagged || 0}× · ${scoreStr}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import cfg from "../config.mjs";
|
|||||||
import security from "../security.mjs";
|
import security from "../security.mjs";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import path from "path";
|
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) => {
|
export default (router, tpl) => {
|
||||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
router.get(/^\/login(\/)?$/, async (req, res) => {
|
||||||
@@ -102,13 +102,17 @@ export default (router, tpl) => {
|
|||||||
created_at: stamp,
|
created_at: stamp,
|
||||||
last_used: stamp,
|
last_used: stamp,
|
||||||
last_action: "/login",
|
last_action: "/login",
|
||||||
kmsi: typeof req.post.kmsi !== 'undefined' ? 1 : 0
|
kmsi: typeof req.post.kmsi !== 'undefined' ? 1 : 0,
|
||||||
|
ip: ip
|
||||||
};
|
};
|
||||||
|
|
||||||
await db`
|
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, {
|
return res.writeHead(301, {
|
||||||
"Cache-Control": "no-cache, public",
|
"Cache-Control": "no-cache, public",
|
||||||
@@ -277,6 +281,8 @@ export default (router, tpl) => {
|
|||||||
totals: await lib.countf0cks(),
|
totals: await lib.countf0cks(),
|
||||||
session: req.session,
|
session: req.session,
|
||||||
manual_approval: getManualApproval(),
|
manual_approval: getManualApproval(),
|
||||||
|
log_user_ips: getLogUserIps(),
|
||||||
|
hash_user_ips: getHashUserIps(),
|
||||||
tmp: null
|
tmp: null
|
||||||
}, req)
|
}, req)
|
||||||
});
|
});
|
||||||
@@ -303,6 +309,32 @@ export default (router, tpl) => {
|
|||||||
activeUsers: activeUsernames.length,
|
activeUsers: activeUsernames.length,
|
||||||
activeUserList: activeUsernames,
|
activeUserList: activeUsernames,
|
||||||
totals: await lib.countf0cks(),
|
totals: await lib.countf0cks(),
|
||||||
|
log_user_ips: getLogUserIps(),
|
||||||
|
tmp: null
|
||||||
|
}, req)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get(/\/admin\/user\/(?<userId>\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
|
tmp: null
|
||||||
}, req)
|
}, req)
|
||||||
});
|
});
|
||||||
@@ -580,10 +612,14 @@ export default (router, tpl) => {
|
|||||||
router.post(/^\/admin\/settings\/?$/, lib.auth, async (req, res) => {
|
router.post(/^\/admin\/settings\/?$/, lib.auth, async (req, res) => {
|
||||||
const manual_approval = req.post.manual_approval === 'on' ? 'true' : 'false';
|
const manual_approval = req.post.manual_approval === 'on' ? 'true' : 'false';
|
||||||
const registration_open = req.post.registration_open === '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 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);
|
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 ('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) {
|
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`;
|
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`;
|
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');
|
setManualApproval(manual_approval === 'true');
|
||||||
|
setLogUserIps(log_user_ips === 'true');
|
||||||
|
setHashUserIps(hash_user_ips === 'true');
|
||||||
setMinTags(min_tags);
|
setMinTags(min_tags);
|
||||||
setTrustedUploads(trusted_uploads);
|
setTrustedUploads(trusted_uploads);
|
||||||
|
|
||||||
@@ -697,6 +735,7 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
|||||||
total,
|
total,
|
||||||
hasMore: users.length === limit,
|
hasMore: users.length === limit,
|
||||||
totals: await lib.countf0cks(),
|
totals: await lib.countf0cks(),
|
||||||
|
log_user_ips: getLogUserIps(),
|
||||||
tmp: null
|
tmp: null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ export default new class {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
getRealIP(req) {
|
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.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) ||
|
||||||
req.socket.remoteAddress;
|
req.socket.remoteAddress;
|
||||||
|
|
||||||
@@ -34,11 +37,14 @@ export default new class {
|
|||||||
|
|
||||||
// Handle IPv6 loopback and mapped IPv4
|
// Handle IPv6 loopback and mapped IPv4
|
||||||
if (ip === "::1") ip = "127.0.0.1";
|
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)
|
// 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 && ip.includes(":")) ip = ip.toLowerCase();
|
||||||
if (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;
|
return ip;
|
||||||
}
|
}
|
||||||
@@ -163,4 +169,22 @@ export default new class {
|
|||||||
|
|
||||||
return false;
|
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));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,3 +44,18 @@ export const setPrivateMessages = (val) => private_messages = !!val;
|
|||||||
|
|
||||||
export const getDefaultLayout = () => default_layout;
|
export const getDefaultLayout = () => default_layout;
|
||||||
export const setDefaultLayout = (val) => default_layout = (val === 'legacy' ? 'legacy' : 'modern');
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 { 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 { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||||
import { createI18n } from "./inc/i18n.mjs";
|
import { createI18n } from "./inc/i18n.mjs";
|
||||||
|
import security from "./inc/security.mjs";
|
||||||
|
|
||||||
const nginx502 = `<html>
|
const nginx502 = `<html>
|
||||||
<head><title>502 Bad Gateway</title></head>
|
<head><title>502 Bad Gateway</title></head>
|
||||||
@@ -297,12 +298,17 @@ process.on('uncaughtException', err => {
|
|||||||
|
|
||||||
// log last action (Fire-and-Forget)
|
// log last action (Fire-and-Forget)
|
||||||
if (!req.url.pathname.startsWith('/api/notifications')) {
|
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`
|
db`
|
||||||
update "user_sessions" set ${db({
|
update "user_sessions" set ${db({
|
||||||
last_used: ~~(Date.now() / 1e3),
|
last_used: ~~(Date.now() / 1e3),
|
||||||
last_action: req.url.pathname,
|
last_action: req.url.pathname,
|
||||||
browser: req.headers['user-agent']
|
browser: req.headers['user-agent'],
|
||||||
}, 'last_used', 'last_action', 'browser')
|
...(getLogUserIps() ? { ip: finalIp } : {})
|
||||||
|
}, 'last_used', 'last_action', 'browser', ...(getLogUserIps() ? ['ip'] : []))
|
||||||
}
|
}
|
||||||
where id = ${+user[0].sess_id}
|
where id = ${+user[0].sess_id}
|
||||||
`.catch(e => console.error('[MIDDLEWARE] Session update failed:', e));
|
`.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
|
// 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}`
|
db`update "user" set last_seen = ${~~(Date.now() / 1e3)} where id = ${+user[0].id}`
|
||||||
.catch(e => console.error('[MIDDLEWARE] last_seen update failed:', e));
|
.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) {
|
if (req.session.admin) {
|
||||||
@@ -655,6 +664,30 @@ process.on('uncaughtException', err => {
|
|||||||
setEnablePdf(!!cfg.enable_pdf);
|
setEnablePdf(!!cfg.enable_pdf);
|
||||||
console.log(`[BOOT] Enable PDF setting: ${getEnablePdf()}`);
|
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)
|
// Load bypass_duplicate_check from config.json (static — not a DB setting)
|
||||||
if (cfg.websrv.bypass_duplicate_check === true) {
|
if (cfg.websrv.bypass_duplicate_check === true) {
|
||||||
setBypassDuplicateCheck(true);
|
setBypassDuplicateCheck(true);
|
||||||
|
|||||||
@@ -40,6 +40,26 @@
|
|||||||
<span class="slider round"></span>
|
<span class="slider round"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-toggle" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: bold; color: var(--accent);">Log User IPs</label>
|
||||||
|
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Log all historical IPs for user accounts.</p>
|
||||||
|
</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="log_user_ips_toggle" {{ log_user_ips ? 'checked' : '' }} onchange="saveAdminSettings()">
|
||||||
|
<span class="slider round"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-toggle" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-weight: bold; color: var(--accent);">Hash User IPs</label>
|
||||||
|
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Anonymize IPs by hashing them (same as failed logins).</p>
|
||||||
|
</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="hash_user_ips_toggle" {{ hash_user_ips ? 'checked' : '' }} onchange="saveAdminSettings()">
|
||||||
|
<span class="slider round"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if(registration_web_toggle_enabled)
|
@if(registration_web_toggle_enabled)
|
||||||
<div class="settings-toggle" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
|
<div class="settings-toggle" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
|
||||||
@@ -99,6 +119,8 @@
|
|||||||
const status = document.getElementById('settings-status');
|
const status = document.getElementById('settings-status');
|
||||||
const approvalToggle = document.getElementById('manual_approval_toggle');
|
const approvalToggle = document.getElementById('manual_approval_toggle');
|
||||||
const registrationToggle = document.getElementById('registration_open_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 minTagsInput = document.getElementById('min_tags_input');
|
||||||
const trustedUploadsInput = document.getElementById('trusted_uploads_input');
|
const trustedUploadsInput = document.getElementById('trusted_uploads_input');
|
||||||
|
|
||||||
@@ -114,6 +136,8 @@
|
|||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
manual_approval: approvalToggle.checked ? 'on' : 'off',
|
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' } : {}),
|
...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}),
|
||||||
min_tags: minTagsInput.value,
|
min_tags: minTagsInput.value,
|
||||||
trusted_uploads: trustedUploadsInput.value,
|
trusted_uploads: trustedUploadsInput.value,
|
||||||
|
|||||||
@@ -30,6 +30,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-body">
|
<div class="session-body">
|
||||||
|
@if(log_user_ips)
|
||||||
|
<div class="session-info">
|
||||||
|
<span class="label">IP:</span>
|
||||||
|
<span class="value">{{ s.ip || 'unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div class="session-info">
|
<div class="session-info">
|
||||||
<span class="label">Browser:</span>
|
<span class="label">Browser:</span>
|
||||||
<span class="value browser-info" title="{{ s.browser }}">{{ s.browser }}</span>
|
<span class="value browser-info" title="{{ s.browser }}">{{ s.browser }}</span>
|
||||||
|
|||||||
56
views/admin/user_ips.html
Normal file
56
views/admin/user_ips.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
@include(snippets/header)
|
||||||
|
|
||||||
|
<div class="pagewrapper">
|
||||||
|
<div id="main" class="admin-container">
|
||||||
|
<div class="container">
|
||||||
|
<div style="margin-bottom: 30px;">
|
||||||
|
<a href="/admin/users" style="color: #888; text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; margin-bottom: 15px;">
|
||||||
|
<i class="fa fa-arrow-left"></i> Back to User Manager
|
||||||
|
</a>
|
||||||
|
<h2 style="margin: 0; font-weight: 800; letter-spacing: -0.5px;">IP History: {!! targetUser.user !!}</h2>
|
||||||
|
<p style="color: #888; margin: 5px 0 0 0;">Historical IP addresses associated with this account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-form">
|
||||||
|
<table class="admin-ips-table responsive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>First Seen</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if(ips && ips.length > 0)
|
||||||
|
@each(ips as row)
|
||||||
|
<tr>
|
||||||
|
<td data-label="IP Address"><span class="ip-badge">{{ row.ip }}</span></td>
|
||||||
|
<td data-label="First Seen">
|
||||||
|
<div class="date-cell">
|
||||||
|
<span class="date-label">Initial</span>
|
||||||
|
{{ new Date(row.first_seen).toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td data-label="Last Seen">
|
||||||
|
<div class="date-cell">
|
||||||
|
<span class="date-label">Most Recent</span>
|
||||||
|
{{ new Date(row.last_seen).toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endeach
|
||||||
|
@else
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" style="text-align: center; padding: 40px; color: #666;">
|
||||||
|
No IP history records found for this user.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@include(snippets/footer)
|
||||||
@@ -68,6 +68,9 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(u.id)
|
@if(u.id)
|
||||||
|
@if(log_user_ips)
|
||||||
|
<a href="/admin/user/{{ u.id }}/ips" class="btn-modern" style="background: rgba(150, 150, 150, 0.1); color: #aaa; border: 1px solid rgba(150, 150, 150, 0.2); text-decoration: none; display: inline-flex; align-items: center; justify-content: center; height: 32px; padding: 0 10px;" title="View IP History">IP Hist</a>
|
||||||
|
@endif
|
||||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Files</button>
|
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Files</button>
|
||||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteComments(this)" class="btn-modern btn-comms">Del Comms</button>
|
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteComments(this)" class="btn-modern btn-comms">Del Comms</button>
|
||||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminBulkDeleteHalls(this)" class="btn-modern btn-comms" title="Delete all user halls" style="background: rgba(255, 0, 255, 0.1); color: #ff00ff; border: 1px solid rgba(255, 0, 255, 0.2);"><i class="fa fa-folder-open"></i> Del Halls</button>
|
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminBulkDeleteHalls(this)" class="btn-modern btn-comms" title="Delete all user halls" style="background: rgba(255, 0, 255, 0.1); color: #ff00ff; border: 1px solid rgba(255, 0, 255, 0.2);"><i class="fa fa-folder-open"></i> Del Halls</button>
|
||||||
|
|||||||
@@ -537,6 +537,7 @@
|
|||||||
|
|
||||||
@if(session)
|
@if(session)
|
||||||
<script src="/s/js/upload.js?v={{ ts }}"></script>
|
<script src="/s/js/upload.js?v={{ ts }}"></script>
|
||||||
|
<script src="/s/js/f0ck_upload_init.js?v={{ ts }}"></script>
|
||||||
<script src="/s/js/tag_autocomplete.js?v={{ ts }}"></script>
|
<script src="/s/js/tag_autocomplete.js?v={{ ts }}"></script>
|
||||||
<script src="/s/js/mention_autocomplete.js?v={{ ts }}"></script>
|
<script src="/s/js/mention_autocomplete.js?v={{ ts }}"></script>
|
||||||
<script src="/s/js/user.js?v={{ ts }}"></script>
|
<script src="/s/js/user.js?v={{ ts }}"></script>
|
||||||
|
|||||||
@@ -375,7 +375,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/s/js/upload.js?v={{ ts }}"></script>
|
|
||||||
<script src="/s/js/f0ck_upload_init.js?v={{ ts }}"></script>
|
|
||||||
|
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
Reference in New Issue
Block a user