Skip to content

hkevin01/fayette_lib

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

25 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Fayette County Public Libraries β€” Website & Staff Portal

Self-hosted. Zero dependencies on the host. No frameworks. No API keys. No CMS subscriptions. A production-grade public library website + staff content management portal serving Fayette County, West Virginia.

Docker Node.js Nginx WCAG 2.1 AA OWASP License


Table of Contents

  1. Project Overview
  2. Key Features
  3. Architecture Overview
  4. Technology Stack
  5. Quick Start
  6. First-Time Setup
  7. Usage Flow
  8. Admin Portal Guide
  9. API Reference
  10. Security
  11. Site Structure
  12. Daily Staff Usage
  13. Backups & Recovery
  14. Updating the Site
  15. Production Deployment
  16. Debugging Common Problems
  17. Project Roadmap
  18. FAQ

Maintainer Documentation Bundle

Developer Ergonomics (New)

  • Admin API integration tests:
    • cd admin && npm test
  • Docker-based test execution (no host npm required):
    • docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile test run --rm fcpl-admin-test
  • Faster admin frontend/backend iteration without rebuilding image each edit:
    • docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile dev up -d --build
    • This mounts admin/server.js and admin/public directly into the running admin container.

1. Project Overview

What It Is

The FCPL Website & Staff Portal is a fully self-hosted web platform for Fayette County Public Libraries β€” a multi-branch public library system serving rural Fayette County, West Virginia. It replaces a legacy website with a modern, accessible, maintainable system that library staff can manage without any technical knowledge.

What Problem It Solves

Problem Solution
Legacy CMS required vendor maintenance contracts Fully self-hosted on any Docker-capable server
Staff needed technical skills to update content Browser-based admin portal β€” no coding needed
Website inaccessible to patrons with disabilities WCAG 2.1 AA compliant throughout
No content backup or audit trail Auto-backup + 60-day recycle bin + activity log
Expensive hosted platform subscriptions Runs on a single VPS or local server, zero SaaS fees
Slow page loads from heavy JavaScript frameworks Zero-framework static HTML β€” loads in milliseconds

Who It Is For

  • Library staff β€” manage events, hours, announcements, programs, and content through a browser
  • Library patrons β€” find branch hours, upcoming events, digital resources, and library services
  • System administrators β€” deploy and maintain the site via Docker Compose on any Linux server
  • Developers β€” extend the static site or admin API with minimal toolchain overhead

Why It Exists

Rural public libraries often lack IT budgets for commercial CMS platforms. This project delivers enterprise-grade security, accessibility, content management, and disaster recovery in a simple Docker stack that any library director can hand off to a successor without technical debt.




2. Key Features

Feature Description Impact Status
Zero-framework static site Pure HTML/CSS/JS β€” no React, Vue, or build pipeline Sub-100ms page loads; no Node.js needed on host βœ… Live
Browser-based CMS Staff manage all content via a tabbed SPA admin portal No coding or terminal access required for content changes βœ… Live
WCAG 2.1 AA Accessibility Skip links, ARIA labels, keyboard navigation, accessibility toolbar Usable by patrons on screen readers, elderly, and children βœ… Live
Multi-layer security Nginx + Express dual rate-limiting, bcrypt(12), JWT, Helmet.js Resistant to brute-force, XSS, clickjacking, CSRF, injection βœ… Live
Soft-delete recycle bin Deleted items recoverable for 60 days Prevents accidental permanent loss of content βœ… Live
Auto-backup before writes Snapshot of data files created before every destructive change One-click rollback to any previous state βœ… Live
Full audit log Every staff action logged with timestamp, IP, and detail Accountability and forensics for every content change βœ… Live
Self-signed / Let's Encrypt TLS Local: auto-generated self-signed cert. Production: Certbot script HTTPS on port 8443 out of the box βœ… Live
Image upload pipeline MIME-type validation, 5 MB cap, random filename on disk Prevents file-type spoofing and enumerable URLs βœ… Live
Interactive event calendar Dynamic calendar widget fed by events.json via API Patrons see accurate upcoming events in real-time βœ… Live
Bookmobile & Homebound pages Dedicated service pages for outreach programs Serves patrons who cannot visit branches in person βœ… Live
5-branch location maps OpenStreetMap embed with per-branch hours and directions Works without Google Maps API; no cost, no rate limits βœ… Live
Digital resources directory Staff-managed list of eBook/database links via admin portal Patron-facing resource page always stays current βœ… Live
Holiday closure management Staff check holiday checkboxes; changes live immediately Patrons never show up to a closed library βœ… Live
Docker Compose deployment Two-container stack: fcpl-site (Nginx) + fcpl-admin (Node.js) Deploy anywhere Docker runs β€” VPS, bare metal, local βœ… Live
Simple / No-JS Site JavaScript-free HTML 4.01 site at /simple/ for IE6–8, Windows XP, and JS-disabled browsers Auto-detected via nginx UA map + <noscript> redirect; usable on any browser without JS βœ… Live

3. Architecture Overview

High-Level Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                         Host Machine                                 β”‚
β”‚                                                                       β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚                    Docker Compose Stack                        β”‚   β”‚
β”‚  β”‚                                                                β”‚   β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚   β”‚
β”‚  β”‚  β”‚              fcpl-site  (nginx:alpine)               β”‚    β”‚   β”‚
β”‚  β”‚  β”‚                                                       β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  Port 8080 (HTTP)  ←──── Public Browser              β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  Port 8443 (HTTPS) ←──── Public Browser (TLS)        β”‚    β”‚   β”‚
β”‚  β”‚  β”‚                                                       β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  /               β†’ Static HTML (site/)               β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  /pages/*        β†’ Static HTML pages                 β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  /css, /js       β†’ Static assets (30d cache)         β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  /images/*       β†’ Uploaded + static images          β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  /data/*.json    β†’ Content files (no-cache)          β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  /simple/*       β†’ Simple site (IE6–8 / no-JS)       β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  /admin/*   ─────────────────────────────────────┐   β”‚    β”‚   β”‚
β”‚  β”‚  β”‚  /api/*     ─────────────────────────────────┐   β”‚   β”‚    β”‚   β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”‚β”€β”€β”€β”‚β”€β”€β”€β”˜    β”‚   β”‚
β”‚  β”‚                                                  β”‚   β”‚        β”‚   β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β–Όβ”€β”€β”   β”‚   β”‚
β”‚  β”‚  β”‚           fcpl-admin  (node:20-alpine)                β”‚   β”‚   β”‚
β”‚  β”‚  β”‚                                                        β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  Internal port 3000 (not exposed to host)             β”‚   β”‚   β”‚
β”‚  β”‚  β”‚                                                        β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  GET  /admin/         β†’ Staff Portal SPA              β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  POST /admin/api/auth/login                           β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  GET|POST|PUT|DELETE /admin/api/events                β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  GET|POST|PUT|DELETE /admin/api/announcements         β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  GET|PUT /admin/api/content/:section                  β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  POST /admin/api/upload                               β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  GET|DELETE /admin/api/recycle-bin                    β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  GET /admin/api/audit-log                             β”‚   β”‚   β”‚
β”‚  β”‚  β”‚  GET /admin/api/backups                               β”‚   β”‚   β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚   β”‚
β”‚  β”‚                                                                β”‚   β”‚
β”‚  β”‚  Shared Volumes (bind-mounted):                                β”‚   β”‚
β”‚  β”‚    ./site/data/   ←→ /data/        (events.json, content.json) β”‚  β”‚
β”‚  β”‚    ./site/images/ ←→ /images/      (uploaded photos)           β”‚  β”‚
β”‚  β”‚    ./docker/certs/ β†’ /etc/nginx/certs/ (TLS cert, read-only)   β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Component Breakdown

Component Technology Role
fcpl-site nginx:alpine Reverse proxy, static file serving, TLS termination, rate limiting
fcpl-admin node:20-alpine + Express REST API for all content mutations; serves staff portal SPA
site/ Plain HTML/CSS/JS Public-facing website β€” 16 pages, no JS framework
admin/public/index.html Vanilla JS SPA Staff content management portal β€” tabbed, responsive
site/data/events.json JSON Live event data β€” read by both nginx (static) and admin API
site/data/content.json JSON All other site content: branches, hours, programs, announcements
docker/nginx.conf Nginx config Security headers, CSP, rate limit zones, proxy rules, caching, legacy UA detection β†’ /simple/
admin/.env Environment file Credentials and secrets β€” never committed to git

Data Flow

Staff makes change in Admin Portal
          β”‚
          β–Ό
POST/PUT/DELETE /admin/api/...
          β”‚
    β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
    β”‚  Nginx    │──── Rate limit check (zone=admin_write: 30r/m)
    β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
          β”‚ proxy_pass
    β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚  Express (fcpl-admin)       β”‚
    β”‚  1. requireAuth (JWT check) β”‚
    β”‚  2. writeLimiter (60/15min) β”‚
    β”‚  3. Input sanitisation      β”‚
    β”‚  4. autoBackup(file)        β”‚
    β”‚  5. writeJSON (atomic)      β”‚
    β”‚  6. addToBin / auditLog     β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
          β”‚
          β–Ό
   ./site/data/*.json  (shared volume)
          β”‚
          β–Ό
   Public site reads via fetch() β†’ /data/events.json
   Calendar widget renders events in real-time

4. Technology Stack

Runtime Stack

Technology Version Why Chosen Alternatives Considered Tradeoffs
Docker + Compose 29.3 / 5.1 Zero host dependencies; reproducible deployments; easy backup of bind-mounted volumes Bare-metal nginx, Podman Docker requires root or docker group; Compose v2 has no separate install
Nginx (Alpine) latest-alpine Best-in-class static file serving; sub-ms latency; mature rate-limiting; tiny 8 MB image Caddy, Apache, Traefik Caddy has auto-HTTPS but adds complexity; Nginx config is well-understood by admins
Node.js (Alpine) 20 LTS LTS stability; native crypto module; excellent ecosystem for JWT/bcrypt; 50 MB image Deno, Python/Flask, Go Deno too new for rural IT handoff; Go requires compiled binary; Python slower startup
Express.js 4.18 Minimal, battle-tested, widely documented Fastify, Koa, Hono Fastify is faster but Express has more documentation for non-JS-native maintainers
Plain HTML/CSS/JS ES2020 Zero build pipeline; no npm vulnerabilities in frontend; loads in <100ms React, Vue, Astro, HTMX Frameworks add maintenance burden and version rot β€” library may not have a dev on staff

Security Dependencies (admin/)

Package Version Purpose Why This One
helmet ^7.2 Sets 12 security response headers (CSP, HSTS, X-Frame-Options, etc.) Industry standard; maintained by the Express team
bcryptjs ^2.4 Password hashing at cost factor 12 Pure JS (no native bindings); portable across Alpine
jsonwebtoken ^9.0 JWT session tokens (HS256, 8-hour expiry) Most widely audited JWT library for Node.js
express-rate-limit ^7.4 Request rate limiting per IP (login + write + global) Works with trust proxy 1 behind Nginx; draft-7 headers
multer ^2.0 Multipart file upload handling Integrates cleanly with Express; supports fileFilter + limits

Frontend Libraries (CDN via HTML <script>)

Library Purpose Notes
Leaflet.js (unpkg) Interactive branch location maps No Google Maps API key required; OpenStreetMap tiles are free
LibraryThing (ltfl) Book cover images in catalog widget Optional external integration
CDN Fonts Typography fonts.cdnfonts.com β€” scoped in CSP

5. Quick Start

# 1. Navigate to the project
cd /home/kevin/Projects/fayette_lib

# 2. Copy example env file (change password before going live!)
cp admin/.env.example admin/.env

# 3. Generate a self-signed TLS cert for local HTTPS
mkdir -p docker/certs
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout docker/certs/key.pem \
  -out docker/certs/cert.pem \
  -subj "/CN=localhost"

# 4. Start both containers
sudo docker compose up -d

# 5. Verify both containers are healthy
sudo docker compose ps

# 6. Open the website
xdg-open http://localhost:8080

# 7. Open the staff portal
xdg-open http://localhost:8080/admin/

Stop the stack: sudo docker compose down Rebuild after code changes: sudo docker compose up -d --build View logs: sudo docker compose logs -f

Docker permission error? Add yourself to the docker group: sudo usermod -aG docker $USER β€” then log out and back in (or newgrp docker).


6. First-Time Setup

Prerequisites

Requirement Check Install
Docker β‰₯ 24 docker --version docs.docker.com
Docker Compose v2 docker compose version Included with Docker Desktop; pacman -S docker-compose on Arch
OpenSSL openssl version Pre-installed on most Linux distros
curl (optional) curl --version For endpoint verification

No Node.js, Python, npm, or build tools are needed on the host machine.

Step 1 β€” Configure your admin password

# Generate a bcrypt hash (cost factor 12) of your chosen password
sudo docker run --rm node:20-alpine node -e \
  "require('bcryptjs').hash('YOUR_PASSWORD', 12).then(h => console.log(h))"

The output starts with $2a$12$.... Copy it.

# Generate a strong JWT secret
openssl rand -hex 48

Open admin/.env and set both values:

# Paste the bcrypt hash here (preferred β€” production-safe)
STAFF_PASSWORD_HASH=$2a$12$...your-hash-here...

# Or use plaintext during initial testing only (not for production)
# STAFF_PASSWORD=your-password-here

# JWT signing secret β€” must be at least 32 characters
JWT_SECRET=your-long-random-secret-here

⚠️ admin/.env is listed in .gitignore β€” it must never be committed to version control.

Step 2 β€” Generate TLS certificates

Local development (self-signed, browser will show a warning):

mkdir -p docker/certs
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout docker/certs/key.pem \
  -out docker/certs/cert.pem \
  -subj "/CN=localhost"

Production (trusted Let's Encrypt cert β€” after DNS is configured):

bash scripts/get-cert.sh your-library-domain.org admin@yourlibrary.org

Step 3 β€” Start and verify

sudo docker compose up -d
sudo docker compose ps

# Verify HTTP endpoint
curl -I http://localhost:8080/

# Verify admin portal
curl -I http://localhost:8080/admin/

Both should return HTTP/1.1 200 OK (or 302 redirect for HTTP→HTTPS).

Step 4 β€” Log in

Navigate to http://localhost:8080/admin/ and enter your password. Sessions last 8 hours, then require re-login.

Development vs Production

Setting Development Production
Password STAFF_PASSWORD=... (plaintext OK) STAFF_PASSWORD_HASH=... (bcrypt required)
JWT Secret Any string β‰₯ 32 chars openssl rand -hex 48 minimum
TLS cert Self-signed (browser warning) Let's Encrypt via scripts/get-cert.sh
HSTS preload Off Enable after HTTPS confirmed stable
Port 8080 / 8443 80 / 443 (reverse proxy or firewall redirect)

7. Usage Flow

Public Patron Flow

Browser visits http://library-domain.org
        β”‚
        β–Ό
   [Nginx: fcpl-site]
        β”‚
        β”œβ”€β”€ / ────────────────► index.html (homepage)
        β”‚                           β”‚
        β”‚                    JS fetches /data/events.json
        β”‚                    JS fetches /data/content.json
        β”‚                           β”‚
        β”‚                    Renders: announcements sidebar
        β”‚                            upcoming events preview
        β”‚                            homepage image slider
        β”‚
        β”œβ”€β”€ /pages/programs.html  β–Ί Programs & Events page
        β”œβ”€β”€ /pages/locations.html β–Ί Branch map + hours (Leaflet)
        β”œβ”€β”€ /pages/ebooks.html    β–Ί Digital resources list
        β”œβ”€β”€ /pages/bookmobile.htmlβ–Ί Bookmobile schedule
        β”œβ”€β”€ /pages/homebound.html β–Ί Homebound delivery service
        β”œβ”€β”€ /pages/...            β–Ί 16 total pages
        β”‚
        └── Legacy / no-JS browsers
                β”‚
                β”œβ”€β”€ IE6–8 or Windows XP: nginx UA map detects user-agent
                β”‚       └── 302 redirect ──────────────────► /simple/
                β”‚
                └── JavaScript disabled (any modern browser)
                        β”œβ”€β”€ index.html: <noscript> meta-refresh β†’ /simple/
                        └── pages/*.html: <noscript> amber banner with manual link

Staff Content Management Flow

Staff opens http://localhost:8080/admin/
        β”‚
        β–Ό
   Login screen β†’ POST /admin/api/auth/login
        β”‚                    β”‚
        β”‚              [rate limited: 5r/m Nginx + 10/15min Express]
        β”‚                    β”‚
        β”‚            βœ“ Returns JWT token (8-hour expiry)
        β”‚            βœ— Returns 401 (generic message)
        β”‚
        β–Ό
   Admin Portal SPA (tabbed)
        β”‚
        β”œβ”€β”€ πŸ“… Events tab
        β”‚       β”œβ”€β”€ GET /admin/api/events              ← list all
        β”‚       β”œβ”€β”€ POST /admin/api/events             ← add event
        β”‚       β”œβ”€β”€ PUT /admin/api/events/:id          ← edit event
        β”‚       └── DELETE /admin/api/events/:id       ← soft-delete
        β”‚
        β”œβ”€β”€ πŸ“’ Announcements tab
        β”‚       └── CRUD /admin/api/announcements
        β”‚
        β”œβ”€β”€ πŸ•’ Branch Hours tab
        β”‚       └── PUT /admin/api/content/branches
        β”‚
        β”œβ”€β”€ πŸ“š Programs / 🌐 Resources / βš™οΈ Settings
        β”‚       └── GET|PUT /admin/api/content/:section
        β”‚
        β”œβ”€β”€ πŸ–ΌοΈ Images tab
        β”‚       └── POST /admin/api/upload  (MIME check + 5MB cap)
        β”‚
        β”œβ”€β”€ πŸ—‘οΈ Recycle Bin  β†’ restore/purge soft-deleted items
        β”œβ”€β”€ πŸ” Activity Log β†’ audit trail (last 500 actions)
        └── πŸ’Ύ Backups      β†’ browse/restore auto-snapshots

8. Admin Portal Guide

Tab Reference

Tab Icon What You Can Do
Events πŸ“… Add / edit / delete calendar events; upload event photos
Announcements πŸ“’ Manage homepage sidebar announcements
Branch Hours πŸ•’ Edit hours for all 5 branches; holiday closures
Programs πŸ“š Storytime schedules, adult book club, Library Chef
Digital Resources 🌐 Manage eBook/database/website links
Analytics πŸ“Š Page-view stats
System ⚑ Server health check; restart signal
Settings βš™οΈ Site name, phone number, social media links
Calendars πŸ“† Manage shareable calendar feeds
Hosting Info 🏠 DNS, SSL cert status, server IP records
Staff πŸ‘₯ Director, assistant, bookmobile staff names
Images πŸ–ΌοΈ Homepage slider photos and featured event cards
Recycle Bin πŸ—‘οΈ Restore items deleted in the last 60 days
Activity Log πŸ” Full audit trail β€” every change with IP + timestamp
Backups πŸ’Ύ Browse and restore automatic pre-change snapshots

Adding an Event

  1. Click πŸ“… Events β†’ + Add Event
  2. Fill in: title, start date/time, end date/time, location, category, description
  3. Optionally upload a photo (JPEG/PNG/GIF/WebP, max 5 MB)
  4. Click Save β€” appears on the public calendar immediately

Recurrence options: None Β· Daily Β· Weekly Β· Monthly (by weekday)

Managing Branch Hours

  1. Click πŸ•’ Branch Hours β†’ Edit Hours next to the branch
  2. Update rows β€” +Row to add time ranges, trash icon to remove
  3. Holiday Closures section at the bottom: check holidays + optional notes
  4. Click Save Hours β€” live instantly

Recycle Bin

  • Events, Announcements, Programs, Digital Resources, and Images are soft-deleted
  • Items are kept for 60 days, then permanently purged
  • Click Restore to put an item back into its original tab
  • Click Delete Forever to remove immediately

Activity Log

  • Records every save/delete with: timestamp Β· IP address Β· action type Β· detail
  • Last 500 entries retained
  • Delete actions highlighted in red for quick scanning

Backups

  • Auto-snapshot of events.json or content.json taken before every destructive write
  • Up to 20 backups kept per file (oldest auto-pruned)
  • Click Restore to roll back β€” this also creates a backup of the current state first (undo the undo)

9. API Reference

All API endpoints are under /admin/api/. All mutating endpoints require a Bearer JWT token in the Authorization header.

Authentication

Endpoint Method Auth Description
/admin/api/auth/login POST None Exchange password for JWT

Request:

{ "password": "your-staff-password" }

Response:

{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }

Events

Endpoint Method Auth Description
/admin/api/events GET βœ… List all events
/admin/api/events POST βœ… Create new event
/admin/api/events/:id PUT βœ… Update event by ID
/admin/api/events/:id DELETE βœ… Soft-delete event (moves to recycle bin)

Event object fields:

{
  "id": 42,
  "title": "Summer Reading Kickoff",
  "start": "2026-06-01T10:00:00",
  "end": "2026-06-01T12:00:00",
  "location": "Oak Hill Branch",
  "category": "children",
  "description": "Join us for the start of summer reading...",
  "recurrence": "none",
  "image": "/images/events/1717200000-a1b2c3d4e5f6.jpg"
}

Announcements

Endpoint Method Auth Description
/admin/api/announcements GET βœ… List all announcements
/admin/api/announcements POST βœ… Create announcement
/admin/api/announcements/:idx PUT βœ… Update by array index
/admin/api/announcements/:idx DELETE βœ… Soft-delete announcement

Content Sections

Endpoint Method Auth Description
/admin/api/content/:section GET βœ… Read a content section
/admin/api/content/:section PUT βœ… Update a content section

Allowed sections: site Β· staff Β· branches Β· programs Β· digital_resources Β· services Β· memorial_program Β· hosting Β· holiday_closures Β· homepage_features Β· jobs

Images

Endpoint Method Auth Description
/admin/api/upload POST βœ… Upload image (multipart/form-data)

Accepted: JPEG Β· PNG Β· GIF Β· WebP Β· Max 5 MB Returns: { "url": "/images/events/timestamp-randomhex.ext" }

System & Audit

Endpoint Method Auth Description
/admin/api/recycle-bin GET βœ… List deleted items
/admin/api/recycle-bin/:bin_id/restore POST βœ… Restore an item
/admin/api/recycle-bin/:bin_id DELETE βœ… Permanently delete
/admin/api/audit-log GET βœ… Last 500 audit entries
/admin/api/backups GET βœ… List available backups
/admin/api/backups/restore POST βœ… Restore a backup

Response Conventions

Status Meaning
200 OK Read success
201 Created Write success
400 Bad Request Invalid input
401 Unauthorized Missing or expired JWT
404 Not Found Resource doesn't exist
413 Payload Too Large Image over 5 MB
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Server-side failure

10. Security

Defense-in-Depth Model

Internet Request
      β”‚
      β–Ό
[Nginx β€” Layer 1]
  β€’ Rate limit zones (general: 120r/m, admin_write: 30r/m, login: 5r/m)
  β€’ Returns 429 on breach (not 503)
  β€’ Security headers on every response
  β€’ TLS 1.2/1.3 with strong cipher suite
      β”‚
      β–Ό
[Express β€” Layer 2]
  β€’ apiLimiter: 200 req/15min per IP
  β€’ writeLimiter: 60 writes/15min per IP
  β€’ loginLimiter: 10 attempts/15min per IP
  β€’ JWT verification (HS256, 8-hour expiry)
  β€’ Helmet.js (12 security headers)
      β”‚
      β–Ό
[Application β€” Layer 3]
  β€’ Password: bcrypt(12) + constant-time compare
  β€’ Input: all fields sanitized and length-capped
  β€’ Uploads: MIME-type checked, random filename
  β€’ Files: path traversal prevention on all reads/writes
  β€’ Writes: atomic (temp-file rename; no corruption on crash)

Security Headers (Nginx β€” all responses)

Header Value
X-Frame-Options SAMEORIGIN
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy Camera/mic/payment blocked; geolocation self-only
X-DNS-Prefetch-Control off
Cross-Origin-Opener-Policy same-origin
Content-Security-Policy Restricts scripts/styles/images/frames to trusted origins

Additional Admin Headers (Helmet.js)

Header Value
Strict-Transport-Security max-age=31536000; includeSubDomains
X-Frame-Options DENY (stricter for admin)
Content-Security-Policy Admin-specific; blocks all external CDNs

Rate Limits

Endpoint Nginx Express
Login (/admin/api/auth/login) 5 req/min, burst 3 10 req/15 min
Admin API writes 30 req/min, burst 10 60 req/15 min
All admin API β€” 200 req/15 min
Public site 120 req/min, burst 30 β€”

OWASP Top 10 Mitigations

OWASP Risk Mitigation
A02 Cryptographic Failures bcrypt(12) for passwords; HS256 JWT with strong secret
A03 Injection All inputs sanitized and truncated; no eval/exec; no SQL
A05 Misconfiguration Helmet defaults; server_tokens off; no X-Powered-By header
A06 Vulnerable Components Pinned npm dependencies; minimal image size
A07 Auth Failures Rate limiting on login; JWT expiry; constant-time password comparison
A08 Software Integrity Atomic writes (write→temp→rename); no partial JSON on crash

Changing the Password (Staff Departure)

# Step 1: Generate a new bcrypt hash
sudo docker run --rm node:20-alpine node -e \
  "require('bcryptjs').hash('NEW_PASSWORD_HERE', 12).then(h => console.log(h))"

# Step 2: Edit admin/.env β€” replace STAFF_PASSWORD_HASH
nano /home/kevin/Projects/fayette_lib/admin/.env

# Step 3: Restart admin container (< 5 seconds)
sudo docker compose restart fcpl-admin

To immediately invalidate all active sessions, also rotate JWT_SECRET in .env and restart.

Known Limitations

  • Single shared password β€” all staff use one credential. Departing staff access is revoked by changing the password. The Activity Log provides per-IP accountability.
  • No TOTP/2FA β€” suitable for internal deployment; consider adding for internet-facing portals.
  • Self-signed cert β€” local only; use scripts/get-cert.sh for production.

11. Site Structure

fayette_lib/
β”œβ”€β”€ README.md                         ← This file
β”œβ”€β”€ docker-compose.yml                ← Starts both containers
β”œβ”€β”€ .gitignore
β”‚
β”œβ”€β”€ admin/                            ← Staff portal backend
β”‚   β”œβ”€β”€ .env.example                  ← Template β€” copy to .env and edit
β”‚   β”œβ”€β”€ .env                          ← ⚠️ SECRETS β€” never commit
β”‚   β”œβ”€β”€ server.js                     ← Express REST API (~700 lines)
β”‚   β”œβ”€β”€ package.json                  ← 5 production dependencies
β”‚   β”œβ”€β”€ Dockerfile                    ← node:20-alpine, 9 steps
β”‚   └── public/
β”‚       └── index.html                ← Staff portal SPA (vanilla JS)
β”‚
β”œβ”€β”€ docker/
β”‚   β”œβ”€β”€ Dockerfile                    ← nginx:alpine, 8 steps
β”‚   β”œβ”€β”€ nginx.conf                    ← Nginx config + security headers
β”‚   β”œβ”€β”€ docker-entrypoint.sh
β”‚   └── certs/
β”‚       β”œβ”€β”€ cert.pem                  ← TLS certificate (self-signed or LE)
β”‚       └── key.pem                   ← TLS private key
β”‚
β”œβ”€β”€ site/                             ← Public website (static files)
β”‚   β”œβ”€β”€ index.html                    ← Homepage
β”‚   β”œβ”€β”€ 404.html
β”‚   β”œβ”€β”€ robots.txt
β”‚   β”œβ”€β”€ favicon.ico
β”‚   β”œβ”€β”€ css/
β”‚   β”‚   └── style.css                 ← All styles; edit :root vars to retheme
β”‚   β”œβ”€β”€ js/
β”‚   β”‚   β”œβ”€β”€ main.js                   ← Content loading, nav, tabs
β”‚   β”‚   β”œβ”€β”€ calendar.js               ← Public calendar widget
β”‚   β”‚   └── a11y.js                   ← Accessibility toolbar
β”‚   β”œβ”€β”€ pages/                        ← All secondary pages
β”‚   β”‚   β”œβ”€β”€ about.html
β”‚   β”‚   β”œβ”€β”€ programs.html
β”‚   β”‚   β”œβ”€β”€ programs-adults.html
β”‚   β”‚   β”œβ”€β”€ programs-children.html
β”‚   β”‚   β”œβ”€β”€ programs-teens.html
β”‚   β”‚   β”œβ”€β”€ programs-community.html
β”‚   β”‚   β”œβ”€β”€ locations.html            ← Branch map (OpenStreetMap + Leaflet)
β”‚   β”‚   β”œβ”€β”€ research.html
β”‚   β”‚   β”œβ”€β”€ ebooks.html
β”‚   β”‚   β”œβ”€β”€ bookmobile.html
β”‚   β”‚   β”œβ”€β”€ homebound.html
β”‚   β”‚   β”œβ”€β”€ archives.html
β”‚   β”‚   β”œβ”€β”€ news.html
β”‚   β”‚   β”œβ”€β”€ jobs.html
β”‚   β”‚   β”œβ”€β”€ catalog.html
β”‚   β”‚   └── myaccount.html
β”‚   β”œβ”€β”€ simple/                       ← No-JS / legacy browser version (IE6+)
β”‚   β”‚   β”œβ”€β”€ index.html                ← Simple homepage β€” HTML 4.01, no JS required
β”‚   β”‚   β”œβ”€β”€ about.html
β”‚   β”‚   β”œβ”€β”€ locations.html
β”‚   β”‚   β”œβ”€β”€ programs.html
β”‚   β”‚   β”œβ”€β”€ ebooks.html
β”‚   β”‚   β”œβ”€β”€ bookmobile.html
β”‚   β”‚   β”œβ”€β”€ homebound.html
β”‚   β”‚   β”œβ”€β”€ research.html
β”‚   β”‚   β”œβ”€β”€ news.html
β”‚   β”‚   β”œβ”€β”€ jobs.html
β”‚   β”‚   β”œβ”€β”€ catalog.html
β”‚   β”‚   β”œβ”€β”€ archives.html
β”‚   β”‚   └── css/
β”‚   β”‚       └── simple.css            ← IE6+ compatible stylesheet (float layout, no CSS custom properties)
β”‚   β”œβ”€β”€ images/                       ← Static images + uploaded event photos
β”‚   └── data/
β”‚       β”œβ”€β”€ events.json               ← All calendar events (live data)
β”‚       β”œβ”€β”€ content.json              ← Branches, hours, programs, announcements
β”‚       β”œβ”€β”€ audit_log.json            ← Staff action log (0o640 β€” not public)
β”‚       β”œβ”€β”€ recycle_bin.json          ← Soft-deleted items (0o640)
β”‚       └── backups/                  ← Auto-snapshots (up to 20 per file)
β”‚
β”œβ”€β”€ scripts/
β”‚   └── get-cert.sh                   ← Let's Encrypt cert for production
└── docs/
    └── site-audit.md                 ← Original site content audit

The two key data files:

File Contents Who edits it
site/data/events.json All calendar events with dates, times, locations Admin portal β†’ Events tab
site/data/content.json Branch hours, announcements, programs, staff, settings Admin portal β†’ all other tabs

12. Daily Staff Usage

All content management happens at http://localhost:8080/admin/ β€” no terminal access needed.

Adding an Event

  1. πŸ“… Events β†’ + Add Event
  2. Fill in title, date/time, location, category, description
  3. Optionally upload a photo
  4. Save β€” event appears on the public calendar immediately

Adding an Announcement

  1. πŸ“’ Announcements β†’ + Add Announcement
  2. Fill in title, body text, optional link
  3. Save β€” appears in the homepage sidebar immediately

Editing Branch Hours

  1. πŸ•’ Branch Hours β†’ Edit Hours next to the branch
  2. Update time rows; +Row adds a new row; trash icon removes
  3. Save β€” live immediately

Setting Holiday Closures

  1. πŸ•’ Branch Hours β†’ scroll to Holiday Closures
  2. Check the holiday checkboxes when the library is closed
  3. Add any patron-facing notice in the text field
  4. Save Holiday Closures

Managing Digital Resources

  • 🌐 Digital Resources β†’ Add / Edit / Delete eBook and database links

Homepage Images

  • πŸ–ΌοΈ Images β†’ Upload slider photos and featured event cards
  • Keep original images under 1 MB for fast page loads

13. Backups & Recovery

Automatic Backups

A snapshot of events.json or content.json is saved to site/data/backups/ before every destructive write through the admin portal. Up to 20 backups are kept per file (oldest auto-pruned).

Manual Backup

# Full data export to a timestamped folder
cp -r /home/kevin/Projects/fayette_lib/site/data/ ~/fcpl-backup-$(date +%Y%m%d)/

Restoring via Admin Portal

  1. Log into the admin portal
  2. Click πŸ’Ύ Backups
  3. Find the backup and click Restore

Restoring via Terminal

# List available backups
ls /home/kevin/Projects/fayette_lib/site/data/backups/

# Restore events
cp site/data/backups/events_2026-03-31T14-22-00.json site/data/events.json

# Restore content
cp site/data/backups/content_2026-03-31T09-15-00.json site/data/content.json

No container restart needed β€” changes are live immediately since files are bind-mounted.


14. Updating the Site

After Editing HTML, CSS, or JS

sudo docker compose up -d --build

After Editing site/data/events.json or site/data/content.json Directly

No rebuild needed β€” volume-mounted files are live immediately.

After Editing admin/server.js or admin/public/index.html

sudo docker compose up -d --build

Changing the Color Theme

Open site/css/style.css and edit the CSS variables in :root { ... } at the top of the file. Then rebuild.

Updating Branch Map Coordinates

  1. Look up the address at nominatim.openstreetmap.org
  2. Find the BRANCHES object in site/pages/locations.html
  3. Update lat and lng for the branch
  4. Rebuild

15. Production Deployment

Server Requirements

Resource Minimum Recommended
CPU 1 vCPU 2 vCPU
RAM 512 MB 1 GB
Disk 5 GB 20 GB
OS Any Linux with Docker Ubuntu 24 LTS / Arch
Inbound ports 80, 443 80, 443

DNS Setup

Point your domain's A record to the server's public IP, then:

# After DNS propagates, obtain a real TLS certificate
bash scripts/get-cert.sh your-library-domain.org admin@yourlibrary.org

Going Live Checklist

[ ] Set STAFF_PASSWORD_HASH (bcrypt, not plaintext) in admin/.env
[ ] Set JWT_SECRET to output of: openssl rand -hex 48
[ ] Replace self-signed cert with Let's Encrypt cert
[ ] Confirm HTTPS works at https://your-domain.org
[ ] Set HSTS preload: true in admin/server.js (after HTTPS is stable)
[ ] Add the user to the docker group: sudo usermod -aG docker $USER
[ ] Set up daily data backup cron: cp -r site/data/ ~/backups/fcpl-$(date +%Y%m%d)/
[ ] Configure server firewall to allow only ports 80 and 443
[ ] Point domain DNS A record to server IP

16. Debugging Common Problems

Site Won't Start

# Check container status
sudo docker compose ps

# View logs
sudo docker compose logs fcpl-site
sudo docker compose logs fcpl-admin

# Rebuild from scratch
sudo docker compose down
sudo docker compose up -d --build

Admin Portal Login Loop / "Authentication Required"

  • Verify admin/.env exists and contains STAFF_PASSWORD_HASH or STAFF_PASSWORD
  • Hash must start with $2a$12$...
  • JWT_SECRET must be at least 32 characters
  • Restart: sudo docker compose restart fcpl-admin

Calendar Not Showing Events

# Validate JSON syntax
python3 -m json.tool site/data/events.json

Events need: "id", "title", and "start" (ISO 8601: "2026-06-01T10:00:00").

Images Not Uploading

  • Max size: 5 MB
  • Accepted types: JPEG, PNG, GIF, WebP
  • Check disk space: df -h
  • Check logs: sudo docker compose logs fcpl-admin

"Too Many Requests" in Admin Portal

  • You've hit the write rate limit (60 ops/15 minutes per IP)
  • Wait 15 minutes β€” this is intentional security behaviour

Self-Signed Cert Warning in Browser

Expected for local development. For production, run scripts/get-cert.sh to get a trusted Let's Encrypt cert.

nginx Won't Start ("cannot load certificate")

The docker/certs/ directory is missing cert.pem / key.pem. Generate them:

mkdir -p docker/certs
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout docker/certs/key.pem -out docker/certs/cert.pem -subj "/CN=localhost"

How to View the Audit Log from Terminal

cat site/data/audit_log.json | python3 -m json.tool | head -100

17. Project Roadmap

Phase Timeline Goals Status
Phase 1 β€” Core Site Q1 2026 Static website with all 16 pages; Docker stack; WCAG 2.1 AA βœ… Complete
Phase 2 β€” Admin Portal Q1 2026 Staff CMS: events, hours, announcements, programs, images βœ… Complete
Phase 3 β€” Security Hardening Q1 2026 OWASP Top 10 mitigations; dual-layer rate limiting; bcrypt + JWT βœ… Complete
Phase 4 β€” Resilience Q1 2026 Auto-backup, recycle bin, activity log, atomic writes βœ… Complete
Phase 5 β€” Production TLS Q2 2026 Let's Encrypt cert automation; HSTS preload βœ… Complete
Phase 5b β€” Legacy Browser Support Q2 2026 No-JS simple site at /simple/; nginx IE6–8 + Windows XP UA detection; <noscript> redirects and banners on all pages βœ… Complete
Phase 6 β€” Production Deploy Q2 2026 Go live at fayette.lib.wv.us; DNS cutover; smoke tests 🟑 In Progress
Phase 7 β€” Analytics Q3 2026 Self-hosted page-view analytics (no Google Analytics) β­• Planned
Phase 8 β€” Mobile App Q4 2026 Progressive web app (PWA) manifest + offline support β­• Planned
Phase 9 β€” Multi-Staff 2027 Optional: per-staff accounts with role-based permissions β­• Backlog

18. FAQ

Q: How do I add a new branch? Go to admin β†’ πŸ•’ Branch Hours β†’ scroll to the bottom β†’ + Add Branch.

Q: Can I restore something I deleted by mistake? Yes β€” πŸ—‘οΈ Recycle Bin tab. Items are kept for 60 days.

Q: How do I close the library for a holiday? Check the holiday in πŸ•’ Branch Hours β†’ Holiday Closures, or add a calendar event with category closure.

Q: What is the simple site and who uses it? The simple site at /simple/ is a JavaScript-free HTML 4.01 version of the site. It is served automatically to browsers that don't support modern JS β€” specifically IE6, IE7, IE8, and any browser running on Windows XP. It is also shown to any browser with JavaScript manually disabled, via a <noscript> meta-refresh on the homepage and amber warning banners on all subpages.

Q: How do I update the content in the simple site? Edit the HTML files directly in site/simple/. The content is hardcoded (no JSON fetching). After editing, rebuild with sudo docker compose up -d --build.

Q: A staff member left β€” how do I prevent access? Change the admin password. See Section 10 β€” Security. Takes effect immediately on restart.

Q: How do I see what someone changed? πŸ” Activity Log tab β€” every action is recorded with IP and timestamp.

Q: The site looks wrong after I edited something β€” how do I undo? πŸ’Ύ Backups tab β†’ restore the snapshot before your change. Or from terminal: copy from site/data/backups/.

Q: How do I move this to a real web server?

  1. Copy the project directory to the server
  2. Install Docker
  3. sudo docker compose up -d
  4. Point your domain DNS to the server IP
  5. Run scripts/get-cert.sh your-domain.org your@email.com
  6. Done β€” no other dependencies needed

Q: How do I back up everything?

cp -r site/data/ ~/fcpl-backup-$(date +%Y%m%d)/

Q: Can staff access the portal from another computer? Yes β€” navigate to http://SERVER_IP:8080/admin/ from any machine on the same network.

Q: Why aren't we using WordPress / Drupal / Squarespace? Those platforms require ongoing maintenance, license fees, plugin management, and vulnerability patching. This codebase has 5 npm dependencies, no database, and runs on any $6/month VPS. The staff can manage all content without ever touching code.


Built for Fayette County Public Libraries, Fayette County, West Virginia. All site content and data belong to FCPL.


About

A fully self-hosted modern website for fayette.lib.wv.us, served via Docker + Nginx.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors