commit d91e8ac96f6d3bc0d3ac0afdbefb5cd37a3b0b11 Author: jtricerolph Date: Wed Jul 1 12:09:54 2026 +0000 Initial commit: noticeboard diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..363b9db --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Shared PostgreSQL on internal network (same at all sites) +DATABASE_URL=postgresql://noticeboard:CHANGE_ME@10.10.10.100:5432/noticeboard_db + +# Shared HS256 secret — generate once, same value on all app LXCs +CENTRAL_AUTH_SECRET=CHANGE_ME + +# Offsite access restriction — supports: +# 203.0.113.1 static public IP +# 203.0.113.0/28 CIDR range +# hnf.dyndns.org DDNS hostname (resolved + cached 5 min) +# disabled no restriction (use during setup/testing) +OFFICE_IP_CHECK=disabled + +# Port the frontend nginx listens on (NPM proxies to this) +FRONTEND_PORT=3080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4ce5d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Dependencies +node_modules/ +.pnp/ +.pnp.js + +# Build output +dist/ +build/ +.next/ +out/ + +# Environment / secrets +.env +.env.local +.env.*.local +!.env.example + +# Editor +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Docker volumes (if any are mounted locally) +postgres-data/ + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ + +# Temp +*.tar.gz +*.tmp diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..35a6156 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..4bfc528 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 0000000..e89b153 --- /dev/null +++ b/backend/src/auth.js @@ -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, + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..a7883e0 --- /dev/null +++ b/backend/src/db.js @@ -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() + ) + `) +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..e0b14e4 --- /dev/null +++ b/backend/src/index.js @@ -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) +} diff --git a/backend/src/routes/notices.js b/backend/src/routes/notices.js new file mode 100644 index 0000000..e3a298b --- /dev/null +++ b/backend/src/routes/notices.js @@ -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() + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5630063 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + backend: + build: ./backend + environment: + - DATABASE_URL=${DATABASE_URL} + - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} + - APP_SLUG=noticeboard + - OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled} + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"] + interval: 10s + retries: 5 + start_period: 15s + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "${FRONTEND_PORT:-3080}:80" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +networks: + default: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..9a9862a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html/notices +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..49cf94e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Noticeboard + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..32b5bef --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + + location = /notices/manifest.json { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + + location /notices/api/ { + proxy_pass http://backend:3001/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + add_header Cache-Control "no-store"; + } + + location /notices/health { + proxy_pass http://backend:3001/health; + } + + location ~* /notices/.*\.(js|css|png|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /notices/ { + add_header Cache-Control "no-cache" always; + try_files $uri $uri/ /notices/index.html; + } + + location = / { + return 301 /notices/; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..784f1f2 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "hnf-noticeboard-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..46bdf0b --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "HNF Noticeboard", + "short_name": "Notices", + "start_url": "/notices/", + "scope": "/", + "display": "standalone", + "theme_color": "#1e3a5f", + "background_color": "#0f1f35", + "icons": [ + { "src": "/notices/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/notices/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..7d2d075 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,10 @@ +import { AuthGate } from './components/AuthGate' +import { NoticeBoard } from './components/NoticeBoard' + +export default function App() { + return ( + + {user => } + + ) +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..d5eea72 --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from 'react' + +interface User { + email: string + name: string + is_admin: boolean +} + +interface Props { + children: (user: User) => React.ReactNode +} + +export function AuthGate({ children }: Props) { + const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking') + const [user, setUser] = useState(null) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + fetch('/api/auth/verify?app=noticeboard', { credentials: 'include' }) + .then(async r => { + if (r.ok) { + const data = await r.json() + setUser(data) + setState('authed') + } else { + setState('login') + } + }) + .catch(() => setState('login')) + }, []) + + async function login(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError('') + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (!res.ok) { + setError('Invalid email or password') + return + } + const verify = await fetch('/api/auth/verify?app=noticeboard', { credentials: 'include' }) + if (verify.ok) { + const data = await verify.json() + setUser(data) + setState('authed') + } else { + setError("You don't have access to this app.") + } + } catch { + setError('Connection error — please try again') + } finally { + setLoading(false) + } + } + + if (state === 'checking') { + return ( +
+
Loading…
+
+ ) + } + + if (state === 'login') { + return ( +
+
+

+ Noticeboard +

+

+ Hotel Number Four +

+
+ setEmail(e.target.value)} + placeholder="Email" required autoComplete="email" + style={inputStyle} + /> + setPassword(e.target.value)} + placeholder="Password" required autoComplete="current-password" + style={inputStyle} + /> + {error &&

{error}

} + +
+
+
+ ) + } + + return <>{children(user!)} +} + +const inputStyle: React.CSSProperties = { + background: 'var(--navy-dark)', border: '1px solid var(--surface-2)', + borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem', + fontSize: '1rem', width: '100%', outline: 'none', +} + +const btnStyle: React.CSSProperties = { + background: 'var(--gold)', color: 'var(--navy-dark)', border: 'none', + borderRadius: '6px', padding: '0.625rem', fontSize: '1rem', + fontWeight: 600, marginTop: '0.25rem', +} diff --git a/frontend/src/components/NoticeBoard.tsx b/frontend/src/components/NoticeBoard.tsx new file mode 100644 index 0000000..8ddc36b --- /dev/null +++ b/frontend/src/components/NoticeBoard.tsx @@ -0,0 +1,221 @@ +import { useEffect, useReducer, useState } from 'react' + +interface Notice { + id: number + title: string + body: string + category: string + author_name: string + pinned: boolean + created_at: string + expires_at: string | null +} + +interface User { email: string; name: string; is_admin: boolean } + +const CATEGORIES = [ + { value: 'all', label: 'All' }, + { value: 'general', label: 'General' }, + { value: 'kitchen', label: 'Kitchen' }, + { value: 'housekeeping', label: 'Housekeeping' }, + { value: 'management', label: 'Management' }, +] + +export function NoticeBoard({ user }: { user: User }) { + const [notices, setNotices] = useState([]) + const [category, setCategory] = useState('all') + const [showForm, setShowForm] = useState(false) + const [, forceRefresh] = useReducer(x => x + 1, 0) + + useEffect(() => { + const params = category !== 'all' ? `?category=${category}` : '' + fetch(`/api/notices${params}`, { credentials: 'include' }) + .then(r => r.json()) + .then(setNotices) + }, [category, forceRefresh]) + + async function togglePin(id: number) { + await fetch(`/api/notices/${id}/pin`, { method: 'PATCH', credentials: 'include' }) + forceRefresh() + } + + async function deleteNotice(id: number) { + if (!confirm('Delete this notice?')) return + await fetch(`/api/notices/${id}`, { method: 'DELETE', credentials: 'include' }) + forceRefresh() + } + + return ( +
+
+
+

Noticeboard

+

{user.name}

+
+ {user.is_admin && ( + + )} +
+ + {showForm && user.is_admin && ( + { setShowForm(false); forceRefresh() }} /> + )} + +
+ {CATEGORIES.map(c => ( + + ))} +
+ + {notices.length === 0 && ( +

+ No notices yet. +

+ )} + +
+ {notices.map(n => ( +
+
+
+
+ {n.pinned && ( + + PINNED + + )} + + {n.category} + +
+

{n.title}

+

+ {n.body} +

+

+ {n.author_name} · {new Date(n.created_at).toLocaleDateString('en-GB', { + day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' + })} + {n.expires_at && ` · expires ${new Date(n.expires_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}`} +

+
+ {user.is_admin && ( +
+ + +
+ )} +
+
+ ))} +
+
+ ) +} + +function PostForm({ user, onPosted }: { user: User; onPosted: () => void }) { + const [title, setTitle] = useState('') + const [body, setBody] = useState('') + const [category, setCategory] = useState('general') + const [pinned, setPinned] = useState(false) + const [expires, setExpires] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + + async function submit(e: React.FormEvent) { + e.preventDefault() + setSubmitting(true) + setError('') + try { + const res = await fetch('/api/notices', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, body, category, pinned, expires_at: expires || null }), + }) + if (res.ok) onPosted() + else setError('Failed to post notice') + } catch { + setError('Connection error') + } finally { + setSubmitting(false) + } + } + + return ( +
+

New Notice

+ setTitle(e.target.value)} + placeholder="Title" required style={inputStyle} /> +