Initial commit: noticeboard
This commit is contained in:
commit
d91e8ac96f
21 changed files with 807 additions and 0 deletions
79
backend/src/auth.js
Normal file
79
backend/src/auth.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { jwtVerify } from 'jose'
|
||||
import dns from 'dns/promises'
|
||||
|
||||
const APP_SLUG = process.env.APP_SLUG || 'noticeboard'
|
||||
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
|
||||
|
||||
// Offsite IP check — supports static IP, CIDR, DDNS hostname, or 'disabled'
|
||||
const OFFICE_IP_CHECK = process.env.OFFICE_IP_CHECK || 'disabled'
|
||||
|
||||
let cachedOfficeIP = null
|
||||
let cacheExpiry = 0
|
||||
|
||||
function ipInCidr(ip, cidr) {
|
||||
const [range, bits] = cidr.split('/')
|
||||
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
|
||||
const ipInt = ip.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct), 0) >>> 0
|
||||
const rangeInt = range.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct), 0) >>> 0
|
||||
return (ipInt & mask) === (rangeInt & mask)
|
||||
}
|
||||
|
||||
async function resolveOfficeIP() {
|
||||
if (OFFICE_IP_CHECK === 'disabled') return null
|
||||
if (Date.now() < cacheExpiry && cachedOfficeIP) return cachedOfficeIP
|
||||
|
||||
const isHostname = /[a-zA-Z]/.test(OFFICE_IP_CHECK) && !OFFICE_IP_CHECK.includes('/')
|
||||
|
||||
if (isHostname) {
|
||||
try {
|
||||
const addrs = await dns.resolve4(OFFICE_IP_CHECK)
|
||||
cachedOfficeIP = addrs[0]
|
||||
cacheExpiry = Date.now() + 5 * 60 * 1000
|
||||
} catch {
|
||||
// DNS failed — keep last known IP if we have one, don't lock everyone out
|
||||
}
|
||||
} else {
|
||||
cachedOfficeIP = OFFICE_IP_CHECK
|
||||
cacheExpiry = Infinity
|
||||
}
|
||||
|
||||
return cachedOfficeIP
|
||||
}
|
||||
|
||||
export async function requireAuth(request, reply) {
|
||||
const token = request.cookies?.hnf_session
|
||||
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
||||
|
||||
let payload
|
||||
try {
|
||||
const { payload: p } = await jwtVerify(token, secret)
|
||||
payload = p
|
||||
} catch {
|
||||
return reply.status(401).send({ error: 'Invalid session' })
|
||||
}
|
||||
|
||||
if (!payload.apps?.includes(APP_SLUG)) {
|
||||
return reply.status(403).send({ error: 'No permission for this app' })
|
||||
}
|
||||
|
||||
// Offsite check — only for users without offsite_allowed flag
|
||||
if (!payload.offsite_allowed) {
|
||||
const officeIP = await resolveOfficeIP()
|
||||
if (officeIP) {
|
||||
const clientIP = request.headers['x-real-ip'] || request.ip
|
||||
const onsite = officeIP.includes('/')
|
||||
? ipInCidr(clientIP, officeIP)
|
||||
: clientIP === officeIP
|
||||
|
||||
if (!onsite) {
|
||||
return reply.status(403).send({ error: 'Access restricted to site network' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request.user = {
|
||||
email: payload.sub,
|
||||
name: payload.name,
|
||||
is_admin: payload.is_admin ?? false,
|
||||
}
|
||||
}
|
||||
23
backend/src/db.js
Normal file
23
backend/src/db.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
})
|
||||
|
||||
export async function initDb() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS notices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
author_name TEXT NOT NULL,
|
||||
author_email TEXT NOT NULL,
|
||||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`)
|
||||
}
|
||||
25
backend/src/index.js
Normal file
25
backend/src/index.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Fastify from 'fastify'
|
||||
import cookie from '@fastify/cookie'
|
||||
import cors from '@fastify/cors'
|
||||
import { initDb } from './db.js'
|
||||
import { noticeRoutes } from './routes/notices.js'
|
||||
|
||||
const app = Fastify({ logger: true, trustProxy: true })
|
||||
|
||||
await app.register(cookie)
|
||||
await app.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || false,
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
app.get('/health', async () => ({ status: 'healthy' }))
|
||||
|
||||
await app.register(noticeRoutes)
|
||||
|
||||
try {
|
||||
await initDb()
|
||||
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
59
backend/src/routes/notices.js
Normal file
59
backend/src/routes/notices.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { pool } from '../db.js'
|
||||
import { requireAuth } from '../auth.js'
|
||||
|
||||
export async function noticeRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
app.get('/api/notices', async (request) => {
|
||||
const { category } = request.query
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM notices
|
||||
WHERE (expires_at IS NULL OR expires_at > $1)
|
||||
${category && category !== 'all' ? 'AND category = $2' : ''}
|
||||
ORDER BY pinned DESC, created_at DESC`,
|
||||
category && category !== 'all' ? [now, category] : [now]
|
||||
)
|
||||
return result.rows
|
||||
})
|
||||
|
||||
app.post('/api/notices', async (request, reply) => {
|
||||
if (!request.user.is_admin) return reply.status(403).send({ error: 'Admins only' })
|
||||
|
||||
const { title, body, category = 'general', pinned = false, expires_at = null } = request.body
|
||||
|
||||
if (!title?.trim() || !body?.trim()) {
|
||||
return reply.status(400).send({ error: 'Title and body are required' })
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO notices (title, body, category, author_name, author_email, pinned, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[title.trim(), body.trim(), category, request.user.name, request.user.email, pinned, expires_at || null]
|
||||
)
|
||||
return reply.status(201).send(result.rows[0])
|
||||
})
|
||||
|
||||
app.patch('/api/notices/:id/pin', async (request, reply) => {
|
||||
if (!request.user.is_admin) return reply.status(403).send({ error: 'Admins only' })
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE notices SET pinned = NOT pinned WHERE id = $1 RETURNING *`,
|
||||
[request.params.id]
|
||||
)
|
||||
if (!result.rows.length) return reply.status(404).send({ error: 'Not found' })
|
||||
return result.rows[0]
|
||||
})
|
||||
|
||||
app.delete('/api/notices/:id', async (request, reply) => {
|
||||
if (!request.user.is_admin) return reply.status(403).send({ error: 'Admins only' })
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM notices WHERE id = $1 RETURNING id`,
|
||||
[request.params.id]
|
||||
)
|
||||
if (!result.rows.length) return reply.status(404).send({ error: 'Not found' })
|
||||
return reply.status(204).send()
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue