feat: MVP phase 1 complete

This commit is contained in:
Blake Ridgway
2026-03-25 02:41:17 -05:00
parent 81ae5c6c7b
commit bfa03e6fbf
32 changed files with 3503 additions and 39 deletions

257
main.go Normal file
View File

@@ -0,0 +1,257 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"
"arclineit/arcline-portal/internal/auth"
"arclineit/arcline-portal/internal/db"
"arclineit/arcline-portal/internal/mail"
"arclineit/arcline-portal/internal/uptime"
"arclineit/arcline-portal/internal/web"
)
func main() {
loadEnv(".env")
// --- CLI flags ---
seedFlag := flag.Bool("seed", false, "create the initial admin account and exit")
seedUser := flag.String("username", "", "admin username (used with -seed)")
seedName := flag.String("name", "", "admin display name (used with -seed)")
seedPass := flag.String("password", "", "admin password — min 8 chars (used with -seed)")
flag.Parse()
port := envOr("PORT", "8082")
dbPath := envOr("DB_PATH", "./portal.db")
uptimeDBPath := envOr("UPTIME_DB_PATH", "../arcline-uptime/uptime.db")
// --- Database ---
database, err := db.Open(dbPath)
if err != nil {
slog.Error("open portal db", "err", err)
os.Exit(1)
}
defer database.Close()
// --- Seed mode ---
if *seedFlag {
username := strings.TrimSpace(*seedUser)
name := strings.TrimSpace(*seedName)
password := *seedPass
if username == "" || name == "" {
fmt.Fprintln(os.Stderr, "error: -username and -name are required with -seed")
os.Exit(1)
}
if len(password) < 8 {
fmt.Fprintln(os.Stderr, "error: -password must be at least 8 characters")
os.Exit(1)
}
// Check if an admin already exists.
existing, err := database.GetClientByUsername(username)
if err != nil {
slog.Error("seed: lookup failed", "err", err)
os.Exit(1)
}
if existing != nil {
fmt.Fprintf(os.Stderr, "error: username %q already exists\n", username)
os.Exit(1)
}
hash, err := auth.HashPassword(password)
if err != nil {
slog.Error("seed: hash password", "err", err)
os.Exit(1)
}
client, err := database.CreateClient(username, name, "", hash, true)
if err != nil {
slog.Error("seed: create client", "err", err)
os.Exit(1)
}
fmt.Printf("Admin account created.\n id: %d\n username: %s\n name: %s\n",
client.ID, client.Username, client.DisplayName)
return
}
// --- Uptime reader (optional) ---
var uptimeReader *uptime.Reader
if uptime.Available(uptimeDBPath) {
uptimeReader, err = uptime.Open(uptimeDBPath)
if err != nil {
slog.Warn("uptime db unavailable — service status will not be shown", "err", err)
} else {
defer uptimeReader.Close()
slog.Info("uptime db connected", "path", uptimeDBPath)
}
} else {
slog.Warn("uptime db not found — service status disabled", "path", uptimeDBPath)
}
// --- Mail ---
var mailer *mail.Mailer
mailCfg := mail.Config{
Host: envOr("SMTP_HOST", ""),
Port: envOr("SMTP_PORT", "587"),
Username: envOr("SMTP_USER", ""),
Password: envOr("SMTP_PASS", ""),
From: envOr("SMTP_FROM", ""),
AdminEmail: envOr("ADMIN_EMAIL", ""),
BaseURL: envOr("BASE_URL", "https://portal.arclineit.com"),
}
if mailCfg.Host != "" && mailCfg.From != "" {
mailer, err = mail.New(mailCfg)
if err != nil {
slog.Warn("mail not configured", "err", err)
} else {
slog.Info("mail configured", "host", mailCfg.Host, "from", mailCfg.From)
}
} else {
slog.Warn("mail not configured — set SMTP_HOST and SMTP_FROM to enable email")
}
// --- Handlers ---
h := &web.Handler{
DB: database,
Uptime: uptimeReader,
Mail: mailer,
}
// --- Background jobs ---
go func() {
// SSL checker: run immediately, then daily.
h.RunSSLChecker()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for range ticker.C {
h.RunSSLChecker()
}
}()
go func() {
// Prune expired sessions every hour.
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
if err := database.PruneSessions(); err != nil {
slog.Error("prune sessions", "err", err)
}
}
}()
// --- Routes ---
mux := http.NewServeMux()
// Public
mux.HandleFunc("GET /login", h.LoginGET)
mux.HandleFunc("POST /login", h.LoginPOST)
mux.HandleFunc("POST /logout", h.LogoutPOST)
mux.HandleFunc("GET /forgot", h.ForgotGET)
mux.HandleFunc("POST /forgot", h.ForgotPOST)
mux.HandleFunc("GET /reset", h.ResetGET)
mux.HandleFunc("POST /reset", h.ResetPOST)
// Static assets
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// Authenticated — client
authed := auth.Middleware(database)
mux.Handle("GET /dashboard", authed(http.HandlerFunc(h.DashboardGET)))
mux.Handle("GET /ssl", authed(http.HandlerFunc(h.SSLGet)))
mux.Handle("POST /ssl/add", authed(http.HandlerFunc(h.SSLAddPOST)))
mux.Handle("POST /ssl/delete", authed(http.HandlerFunc(h.SSLDeletePOST)))
mux.Handle("GET /tickets", authed(http.HandlerFunc(h.TicketsGET)))
mux.Handle("POST /tickets/new", authed(http.HandlerFunc(h.TicketNewPOST)))
mux.Handle("GET /tickets/{id}", authed(http.HandlerFunc(h.TicketGET)))
mux.Handle("POST /tickets/{id}/reply", authed(http.HandlerFunc(h.TicketReplyPOST)))
mux.Handle("GET /settings", authed(http.HandlerFunc(h.SettingsGET)))
mux.Handle("POST /settings/email", authed(http.HandlerFunc(h.SettingsEmailPOST)))
mux.Handle("POST /settings/password", authed(http.HandlerFunc(h.SettingsPasswordPOST)))
// Authenticated — admin only
admin := func(hf http.HandlerFunc) http.Handler {
return authed(auth.AdminMiddleware(http.HandlerFunc(hf)))
}
mux.Handle("GET /admin", admin(h.AdminIndexGET))
mux.Handle("POST /admin/clients/new", admin(h.AdminClientNewPOST))
mux.Handle("GET /admin/clients/{id}", admin(h.AdminClientGET))
mux.Handle("POST /admin/clients/{id}/monitors/add", admin(h.AdminMonitorAddPOST))
mux.Handle("POST /admin/clients/{id}/monitors/delete", admin(h.AdminMonitorDeletePOST))
mux.Handle("POST /admin/clients/{id}/delete", admin(h.AdminClientDeletePOST))
// Root redirect / catch-all 404
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
h.NotFoundHandler(w, r)
})
srv := &http.Server{
Addr: ":" + port,
Handler: secHeaders(mux),
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
slog.Info("arcline-portal starting", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("server error", "err", err)
os.Exit(1)
}
}
func secHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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)
})
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// loadEnv reads a .env file and sets any key=value pairs as environment
// variables, skipping blank lines and lines beginning with #.
// Already-set variables (e.g. from the real environment) are not overwritten.
func loadEnv(path string) {
data, err := os.ReadFile(path)
if err != nil {
return // no .env is fine
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
// Strip surrounding quotes if present
if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) {
v = v[1 : len(v)-1]
}
// Don't overwrite values already set in the environment
if os.Getenv(k) == "" {
os.Setenv(k, v)
}
}
}