diff --git a/.env.example b/.env.example index a14bc11..8d06ec0 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,11 @@ ADMIN_PASSWORD_HASH=$2a$12$examplehashgoeshere... # Secret key for signing session cookies. Use a long random string. # Generate with: openssl rand -hex 32 SESSION_SECRET=change-me-use-openssl-rand-hex-32 + +# ------------------------------------------------------------------ +# Contact / Hire form +# ------------------------------------------------------------------ + +# Email address that receives hire form submissions. +# Requires a working local MTA (OpenSMTPD) listening on localhost:25. +CONTACT_EMAIL=hire@ridgwaysystems.org diff --git a/cmd/server/main.go b/cmd/server/main.go index 8df64dc..ef24796 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -48,14 +48,22 @@ func main() { mux.HandleFunc("GET /infrastructure", h.Infrastructure) mux.HandleFunc("GET /status", h.Status) mux.HandleFunc("GET /about", h.About) + mux.HandleFunc("GET /hire", h.Hire) + mux.HandleFunc("POST /hire", h.HirePost) + mux.HandleFunc("GET /resume", h.Resume) mux.HandleFunc("GET /sitemap.xml", h.Sitemap) // Admin routes (auth handled per-handler) mux.HandleFunc("/admin", h.AdminDashboard) mux.HandleFunc("/admin/", h.AdminRouter) + srv := handler.Chain(mux, + handler.LoggingMiddleware, + handler.SecurityHeadersMiddleware, + ) + log.Printf("ridgwaysystems.org starting on :%s (DEV=%s)", port, os.Getenv("DEV")) - log.Fatal(http.ListenAndServe(":"+port, mux)) + log.Fatal(http.ListenAndServe(":"+port, srv)) } // generateSyntaxCSS writes a chroma CSS file with light and dark themes. diff --git a/internal/blog/store.go b/internal/blog/store.go index c459040..2e2de42 100644 --- a/internal/blog/store.go +++ b/internal/blog/store.go @@ -161,6 +161,28 @@ func (s *Store) Search(query string) ([]*Post, error) { return results, nil } +// Neighbors returns the posts immediately older and newer than slug in +// date-descending order (newer = more recent = lower index). +// Either value may be nil if slug is at the boundary. +func (s *Store) Neighbors(slug string) (older, newer *Post, err error) { + posts, err := s.All(false) + if err != nil { + return nil, nil, err + } + for i, p := range posts { + if p.Slug == slug { + if i+1 < len(posts) { + older = posts[i+1] + } + if i > 0 { + newer = posts[i-1] + } + return older, newer, nil + } + } + return nil, nil, errors.New("post not found: " + slug) +} + // AllTags returns a deduplicated list of tags across all published posts. func (s *Store) AllTags() ([]string, error) { posts, err := s.All(false) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 1076c78..1d77841 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -60,6 +60,9 @@ func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) { case path == "/admin/upload": h.requireAuth(h.adminUpload)(w, r) + case path == "/admin/uploads": + h.requireAuth(h.adminUploads)(w, r) + default: h.renderErr(w, http.StatusNotFound, "Admin page not found.") } @@ -337,6 +340,40 @@ func (h *Handler) adminUpload(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"url":"%s","markdown":"![image](%s)"}`, url, url) } +// --- Uploads browser --- + +type uploadFile struct { + Name string + URL string + Markdown string +} + +type uploadsData struct { + Files []uploadFile + Flash string +} + +func (h *Handler) adminUploads(w http.ResponseWriter, r *http.Request) { + const dir = "static/uploads" + entries, err := os.ReadDir(dir) + var files []uploadFile + if err == nil { + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + url := "/static/uploads/" + name + files = append(files, uploadFile{ + Name: name, + URL: url, + Markdown: "![image](" + url + ")", + }) + } + } + h.render(w, "admin-uploads", uploadsData{Files: files, Flash: r.URL.Query().Get("flash")}) +} + // sanitizeSlug ensures a slug is filesystem-safe. func sanitizeSlug(s string) string { s = strings.ToLower(strings.TrimSpace(s)) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 641ff91..d5d3a6f 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -2,6 +2,7 @@ package handler import ( + "fmt" "html/template" "log" "net/http" @@ -13,20 +14,22 @@ import ( // Handler holds shared dependencies for all HTTP handlers. type Handler struct { - store *blog.Store - dataDir string - templates map[string]*template.Template - siteURL string - devMode bool + store *blog.Store + dataDir string + templates map[string]*template.Template + siteURL string + contactEmail string + devMode bool } // New creates a Handler. dataDir is the path to the data/ directory. func New(store *blog.Store, dataDir string) *Handler { h := &Handler{ - store: store, - dataDir: dataDir, - siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"), - devMode: os.Getenv("DEV") == "1", + store: store, + dataDir: dataDir, + siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"), + contactEmail: getenv("CONTACT_EMAIL", "hire@ridgwaysystems.org"), + devMode: os.Getenv("DEV") == "1", } if !h.devMode { h.templates = mustLoadTemplates() @@ -69,10 +72,14 @@ func mustLoadTemplates() map[string]*template.Template { {"infrastructure", "templates/infrastructure.html"}, {"status", "templates/status.html"}, {"about", "templates/about.html"}, + {"hire", "templates/hire.html"}, + {"resume", "templates/resume.html"}, + {"error", "templates/error.html"}, {"admin-login", "templates/admin/login.html"}, {"admin-dashboard", "templates/admin/dashboard.html"}, {"admin-editor", "templates/admin/editor.html"}, {"admin-status", "templates/admin/status.html"}, + {"admin-uploads", "templates/admin/uploads.html"}, } for _, p := range pages { @@ -98,8 +105,24 @@ func (h *Handler) render(w http.ResponseWriter, name string, data any) { } } +// errorData is passed to the error template. +type errorData struct { + Code int + Title string + Message string +} + func (h *Handler) renderErr(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(code) - w.Write([]byte("

" + http.StatusText(code) + "

" + msg + "

Home")) + data := errorData{Code: code, Title: http.StatusText(code), Message: msg} + t := h.tmpl("error") + if t == nil { + fmt.Fprintf(w, "

%d %s

%s

Home", + code, http.StatusText(code), msg) + return + } + if err := t.ExecuteTemplate(w, "base", data); err != nil { + log.Printf("renderErr %d: %v", code, err) + } } diff --git a/internal/handler/middleware.go b/internal/handler/middleware.go new file mode 100644 index 0000000..46e35ff --- /dev/null +++ b/internal/handler/middleware.go @@ -0,0 +1,54 @@ +package handler + +import ( + "log" + "net/http" + "strings" + "time" +) + +// Chain wraps h with each middleware in order (first applied outermost). +func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler { + for i := len(mw) - 1; i >= 0; i-- { + h = mw[i](h) + } + return h +} + +// LoggingMiddleware logs method, path, status code, and duration. +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lw := &loggingResponseWriter{ResponseWriter: w, code: http.StatusOK} + next.ServeHTTP(lw, r) + log.Printf("%s %s %d %s", r.Method, r.URL.RequestURI(), lw.code, time.Since(start)) + }) +} + +type loggingResponseWriter struct { + http.ResponseWriter + code int +} + +func (lw *loggingResponseWriter) WriteHeader(code int) { + lw.code = code + lw.ResponseWriter.WriteHeader(code) +} + +// SecurityHeadersMiddleware sets security-related HTTP response headers. +// Admin paths get script-src 'self'; all other paths get script-src 'none'. +func SecurityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + scriptSrc := "'none'" + if strings.HasPrefix(r.URL.Path, "/admin") { + scriptSrc = "'self'" + } + w.Header().Set("Content-Security-Policy", + "default-src 'self'; script-src "+scriptSrc+"; style-src 'self'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/handler/public.go b/internal/handler/public.go index 583d758..9ce8e34 100644 --- a/internal/handler/public.go +++ b/internal/handler/public.go @@ -2,6 +2,8 @@ package handler import ( "encoding/xml" + "fmt" + "log" "net/http" "path/filepath" "strconv" @@ -10,6 +12,7 @@ import ( "ridgwaysystems.org/website/internal/blog" "ridgwaysystems.org/website/internal/feed" + "ridgwaysystems.org/website/internal/mailer" "ridgwaysystems.org/website/internal/status" ) @@ -107,6 +110,13 @@ func (h *Handler) BlogList(w http.ResponseWriter, r *http.Request) { }) } +// postPageData is passed to the post template. +type postPageData struct { + *blog.Post + Older *blog.Post + Newer *blog.Post +} + func (h *Handler) BlogPost(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") if slug == "" { @@ -123,7 +133,8 @@ func (h *Handler) BlogPost(w http.ResponseWriter, r *http.Request) { h.renderErr(w, http.StatusNotFound, "Post not found.") return } - h.render(w, "post", post) + older, newer, _ := h.store.Neighbors(slug) + h.render(w, "post", postPageData{Post: post, Older: older, Newer: newer}) } func (h *Handler) Feed(w http.ResponseWriter, r *http.Request) { @@ -167,6 +178,62 @@ func (h *Handler) About(w http.ResponseWriter, r *http.Request) { h.render(w, "about", nil) } +func (h *Handler) Resume(w http.ResponseWriter, r *http.Request) { + h.render(w, "resume", nil) +} + +// --- Hire / Contact --- + +type hireData struct { + Name string + Email string + Company string + Message string + Error string + Success bool +} + +func (h *Handler) Hire(w http.ResponseWriter, r *http.Request) { + h.render(w, "hire", hireData{}) +} + +func (h *Handler) HirePost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderErr(w, http.StatusBadRequest, "Bad form data.") + return + } + d := hireData{ + Name: strings.TrimSpace(r.FormValue("name")), + Email: strings.TrimSpace(r.FormValue("email")), + Company: strings.TrimSpace(r.FormValue("company")), + Message: strings.TrimSpace(r.FormValue("message")), + } + if d.Name == "" || d.Email == "" || d.Message == "" { + d.Error = "Name, email, and message are required." + h.render(w, "hire", d) + return + } + if !strings.Contains(d.Email, "@") { + d.Error = "Please enter a valid email address." + h.render(w, "hire", d) + return + } + + subject := "Hire inquiry from " + d.Name + body := fmt.Sprintf("Name: %s\nEmail: %s\nCompany: %s\n\nMessage:\n%s\n", + d.Name, d.Email, d.Company, d.Message) + + if err := mailer.Send(h.contactEmail, subject, body); err != nil { + log.Printf("contact form mail error: %v", err) + d.Error = "Could not send message. Please email hire@ridgwaysystems.org directly." + h.render(w, "hire", d) + return + } + + d.Success = true + h.render(w, "hire", d) +} + // --- Sitemap --- type urlset struct { @@ -188,6 +255,8 @@ func (h *Handler) Sitemap(w http.ResponseWriter, r *http.Request) { urls := []sitemapURL{ {Loc: h.siteURL + "/", Freq: "weekly", Prio: "1.0"}, {Loc: h.siteURL + "/blog", Freq: "weekly", Prio: "0.9"}, + {Loc: h.siteURL + "/hire", Freq: "monthly", Prio: "0.9"}, + {Loc: h.siteURL + "/resume", Freq: "monthly", Prio: "0.7"}, {Loc: h.siteURL + "/infrastructure", Freq: "monthly", Prio: "0.7"}, {Loc: h.siteURL + "/status", Freq: "daily", Prio: "0.6"}, {Loc: h.siteURL + "/about", Freq: "monthly", Prio: "0.5"}, diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..3b55a35 --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,18 @@ +// Package mailer sends email via the local SMTP relay (OpenSMTPD on localhost:25). +package mailer + +import ( + "fmt" + "net/smtp" +) + +const from = "noreply@ridgwaysystems.org" + +// Send delivers a plain-text email through the local MTA. +func Send(to, subject, body string) error { + msg := fmt.Sprintf( + "From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s", + from, to, subject, body, + ) + return smtp.SendMail("localhost:25", nil, from, []string{to}, []byte(msg)) +} diff --git a/static/css/style.css b/static/css/style.css index 3ac01ad..c8c54a5 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -619,6 +619,29 @@ blockquote { /* === About / Prose === */ +.about-header { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.about-avatar { + width: 72px; + height: 72px; + border-radius: 6px; + flex-shrink: 0; + border: 1px solid var(--border); +} + +.about-header h1 { margin: 0; } + +@media (max-width: 480px) { + .about-avatar { width: 52px; height: 52px; } +} + .prose { max-width: 640px; line-height: 1.75; @@ -915,6 +938,396 @@ blockquote { font-family: var(--font-mono); } +/* === Post Nav (prev/next) === */ + +.post-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + font-size: 0.88rem; + font-family: var(--font-mono); +} + +.post-nav-prev, +.post-nav-next { flex: 1; } + +.post-nav-next { text-align: right; } + +.post-nav-all { + color: var(--text-muted); + white-space: nowrap; +} + +/* === Error Page === */ + +.error-page { + text-align: center; + padding: 4rem 1rem; +} + +.error-code { + font-family: var(--font-mono); + font-size: 4rem; + font-weight: 700; + color: var(--accent); + line-height: 1; + margin-bottom: 0.25em; +} + +.error-page h1 { margin-top: 0; } +.error-page p { color: var(--text-muted); } +.error-page .btn { margin: 0.3rem; } + +/* === Upload Browser === */ + +.upload-browser { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.upload-item { + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.upload-thumb { + width: 100%; + height: 140px; + object-fit: cover; + display: block; + background: var(--bg-code); +} + +.upload-info { + padding: 0.6rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.upload-name, +.upload-md { + font-size: 0.75rem; + word-break: break-all; + color: var(--text-muted); + user-select: all; +} + +/* === Hire Page === */ + +.nav-hire { color: var(--accent) !important; } +.nav-hire:hover { color: var(--accent-dim) !important; } + +.hire-page { max-width: var(--max-w); } + +.hire-intro { + margin-bottom: 2.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.hire-intro h1 { margin-bottom: 0.3em; } + +.hire-tagline { + font-size: 1.05rem; + color: var(--text-muted); + font-style: italic; + margin: 0 0 1em; +} + +.services-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(195px, 1fr)); + gap: 0.85rem; + margin: 1.25rem 0 2.5rem; +} + +.service-card { + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.9rem 1rem; +} + +.service-card h3 { + font-family: var(--font-mono); + font-size: 0.88rem; + margin: 0 0 0.4em; + color: var(--accent); + font-weight: 600; +} + +.service-card p { + font-size: 0.84rem; + color: var(--text-muted); + margin: 0; + line-height: 1.5; +} + +.hire-availability { + background: var(--bg-alt); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + padding: 0.8rem 1rem; + font-size: 0.9rem; + margin-bottom: 2.5rem; + color: var(--text-muted); +} + +.hire-availability strong { + font-family: var(--font-mono); + color: var(--accent); +} + +.contact-section h2 { margin-bottom: 0.4em; } + +.contact-form { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 540px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.form-group label { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-muted); +} + +.form-group input, +.form-group textarea { + padding: 0.5em 0.75em; + background: var(--bg); + border: 1px solid var(--border-dark); + border-radius: var(--radius); + color: var(--text); + font-size: 0.95rem; + font-family: var(--font-sans); + line-height: 1.5; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; + border-color: var(--accent); +} + +.form-group textarea { + min-height: 140px; + resize: vertical; + line-height: 1.65; +} + +.field-note { + font-size: 0.78rem; + color: var(--text-muted); + font-family: var(--font-mono); + font-weight: 400; +} + +.required-mark { color: var(--accent); } + +.form-success { + background: #efe; + border: 1px solid #9d9; + border-radius: var(--radius); + padding: 0.9rem 1.1rem; + color: #2a7a2a; + margin-bottom: 1rem; + font-size: 0.95rem; +} + +@media (prefers-color-scheme: dark) { + .form-success { background: #1a3020; border-color: #2a6a3a; color: #6db88a; } +} + +/* === Resume Page === */ + +.resume-page { max-width: var(--max-w); } + +.resume-actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.resume-print-hint { + font-size: 0.8rem; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.resume-print-hint kbd { + font-family: var(--font-mono); + font-size: 0.78em; + background: var(--bg-alt); + border: 1px solid var(--border-dark); + border-radius: 3px; + padding: 0.1em 0.35em; +} + +.resume-header { + margin-bottom: 2rem; + padding-bottom: 1.25rem; + border-bottom: 2px solid var(--border-dark); +} + +.resume-header h1 { font-size: 2rem; margin-bottom: 0.1em; } + +.resume-tagline { + font-size: 0.95rem; + color: var(--text-muted); + margin: 0 0 0.75em; +} + +.resume-contact { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1.25rem; + font-size: 0.82rem; + font-family: var(--font-mono); + color: var(--text-muted); +} + +.resume-contact a { color: var(--text-muted); } +.resume-contact a:hover { color: var(--accent); } + +.resume-section { margin-bottom: 2rem; } + +.resume-section > p { font-size: 0.9rem; line-height: 1.7; } + +.resume-section h2 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + padding-bottom: 0.3em; + margin: 0 0 1.25rem; + font-family: var(--font-mono); + font-weight: 600; +} + +.resume-job { margin-bottom: 1.4rem; } +.resume-job:last-child { margin-bottom: 0; } + +.resume-job-header { + display: flex; + justify-content: space-between; + align-items: baseline; + flex-wrap: wrap; + gap: 0.15rem 1rem; + margin-bottom: 0.1em; +} + +.resume-role { font-weight: 700; font-size: 0.97rem; } + +.resume-dates { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-muted); + white-space: nowrap; +} + +.resume-org { + font-size: 0.88rem; + margin-bottom: 0.45em; +} + +.resume-company { font-weight: 600; color: var(--accent); } + +.resume-location { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.82rem; +} + +.resume-job ul { + margin: 0; + padding-left: 1.2em; +} + +.resume-job li { + font-size: 0.87rem; + margin-bottom: 0.3em; + line-height: 1.55; +} + +.resume-cert-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0; + list-style: none; + margin: 0; +} + +.resume-cert { + font-family: var(--font-mono); + font-size: 0.78rem; + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.2em 0.65em; + color: var(--text-muted); +} + +.resume-skills dl { + display: grid; + grid-template-columns: 6rem 1fr; + gap: 0.5em 1em; + font-size: 0.87rem; + margin: 0; +} + +.resume-skills dt { + font-weight: 600; + color: var(--text); + line-height: 1.55; +} + +.resume-skills dd { + margin: 0; + color: var(--text-muted); + line-height: 1.55; +} + +@media (max-width: 500px) { + .resume-skills dl { grid-template-columns: 1fr; gap: 0.15em; } + .resume-skills dt { margin-top: 0.5em; } +} + +@media print { + .site-header, + .site-footer, + .resume-actions { display: none !important; } + + body { background: #fff !important; color: #000 !important; } + .main-content { max-width: 100%; padding: 0.5cm 1cm; } + a { color: #000 !important; } + .resume-header { border-bottom-color: #999 !important; } + .resume-section h2 { border-bottom-color: #ccc !important; color: #555 !important; } + .resume-company { color: #000 !important; } + .resume-cert { background: #f5f5f5 !important; border-color: #ccc !important; } + .resume-skills dt { color: #000 !important; } + .resume-skills dd { color: #333 !important; } +} + /* === Responsive === */ @media (max-width: 600px) { diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..413c1d8 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/static/img/avatar.svg b/static/img/avatar.svg new file mode 100644 index 0000000..44ec0f2 --- /dev/null +++ b/static/img/avatar.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/static/img/wallpaper.svg b/static/img/wallpaper.svg new file mode 100644 index 0000000..e395b9e --- /dev/null +++ b/static/img/wallpaper.svg @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # /etc/pf.conf -- fw01 (SuperMicro 1U) + ext_if = "em0" + vlan10 = "vlan10" # servers + vlan20 = "vlan20" # desktop + vlan30 = "vlan30" # game + vlan40 = "vlan40" # iot/guest + set block-policy drop + set skip on lo + block all + antispoof for $ext_if inet + pass in on $ext_if proto tcp \ + to port { 22 80 443 } keep state + pass out on $ext_if all keep state + pass in on $vlan10 all keep state + pass in on $vlan20 to $vlan10 + block in on $vlan40 to 10.0.0.0/8 + + + + + # ridgwaysystems.org -- network + internet + | + fw01 10.0.1.1 OpenBSD + pf relayd unbound wireguard + | + +-- vlan10 10.0.10.0/24 servers + | srv01 R720 httpd gitea mail + | srv02 R710 dns vmm backup + +-- vlan20 10.0.20.0/24 desktop + | ws01 ansible control node + +-- vlan30 10.0.30.0/24 game + | linux VMs via vmm(4) + +-- vlan40 10.0.40.0/24 iot/guest + block to 10.0.0.0/8 + + + + + $ rcctl status + httpd active srv01 :80 :443 + relayd active fw01 :80 :443 + unbound active fw01 :53 + nsd active srv02 :53 + smtpd active srv01 :25 :465 + gitea active srv01 :3000 + prometheus active srv01 :9090 + grafana active srv01 :3001 + wireguard active fw01 :51820 + matrix degraded srv01 :8448 + jellyfin active srv02 :8096 + game-srv stopped srv02 + + + + + # hardware inventory + fw01 SuperMicro 1U OpenBSD 7.6 + Xeon E3-1230v2 / 16 GB ECC + pf · relayd · wireguard + srv01 Dell R720 OpenBSD 7.6 + 2x Xeon E5-2600 / 64 GB ECC + httpd · gitea · smtpd · matrix + srv02 Dell R710 OpenBSD 7.6 + Xeon X5650 / 48 GB ECC + nsd · vmm · jellyfin + ws01 desktop Linux + Ryzen / 32 GB ansible + + + + + + + + + OPENBSD HOMELAB + + + + + + + + + ridgwaysystems.org + + + + + + + + + + + + + + + + diff --git a/static/js/editor.js b/static/js/editor.js new file mode 100644 index 0000000..3adc27d --- /dev/null +++ b/static/js/editor.js @@ -0,0 +1,55 @@ +(function() { + var previewBtn = document.getElementById('preview-btn'); + var uploadBtn = document.getElementById('upload-btn'); + var imgFile = document.getElementById('img-file'); + var textarea = document.getElementById('content'); + var output = document.getElementById('preview-output'); + var uploadStatus = document.getElementById('upload-status'); + + if (!textarea) return; + + // --- Preview --- + function refreshPreview() { + var fd = new FormData(); + fd.append('content', textarea.value); + fetch('/admin/preview', { method: 'POST', body: fd }) + .then(function(r) { return r.text(); }) + .then(function(html) { output.innerHTML = html; }) + .catch(function() { output.innerHTML = '

Preview failed.

'; }); + } + + if (previewBtn) previewBtn.addEventListener('click', refreshPreview); + if (textarea.value.trim()) { refreshPreview(); } + + // --- Image upload --- + if (uploadBtn) uploadBtn.addEventListener('click', function() { imgFile.click(); }); + + if (imgFile) imgFile.addEventListener('change', function() { + if (!this.files.length) return; + var file = this.files[0]; + var fd = new FormData(); + fd.append('image', file); + + uploadStatus.textContent = 'Uploading\u2026'; + uploadBtn.disabled = true; + + fetch('/admin/upload', { method: 'POST', body: fd }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.error) { + uploadStatus.textContent = 'Error: ' + data.error; + return; + } + var pos = textarea.selectionStart; + var before = textarea.value.substring(0, pos); + var after = textarea.value.substring(textarea.selectionEnd); + textarea.value = before + data.markdown + after; + textarea.selectionStart = textarea.selectionEnd = pos + data.markdown.length; + textarea.focus(); + uploadStatus.textContent = 'Inserted: ' + data.url; + setTimeout(function() { uploadStatus.textContent = ''; }, 3000); + }) + .catch(function() { uploadStatus.textContent = 'Upload failed.'; }) + .finally(function() { uploadBtn.disabled = false; imgFile.value = ''; }); + }); +})(); diff --git a/templates/about.html b/templates/about.html index ac60fe9..4c4e8e7 100644 --- a/templates/about.html +++ b/templates/about.html @@ -2,8 +2,11 @@ {{define "meta-desc"}}About Ridgway Systems — a personal OpenBSD homelab project.{{end}} {{define "content"}} -