Initial commit: noticeboard

This commit is contained in:
jtricerolph 2026-07-01 12:09:54 +00:00
commit d91e8ac96f
21 changed files with 807 additions and 0 deletions

7
backend/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev
COPY src ./src
EXPOSE 3001
CMD ["node", "src/index.js"]

16
backend/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "hnf-noticeboard-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"pg": "^8.13.1"
}
}

79
backend/src/auth.js Normal file
View 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
View 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
View 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)
}

View 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()
})
}