Scaffold custom reports app (LXC 122 · /reports)

Framework for categorised custom reports pulling from NewBook, ResOS,
SambaPOS, and internal data. Report tree with collapsible categories
and subcategories; date-range params; run-audit log in reports_db.

Initial reports: NewBook arrivals/departures/stayovers/bookings-by-source,
ResOS covers/revenue, SambaPOS sales-summary/top-products, internal run-history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-06 12:58:19 +00:00
commit 0511ac8d82
31 changed files with 1818 additions and 0 deletions

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}