diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1019db9 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# arcline-portal environment variables +# Copy to .env and fill in real values before running. + +# HTTP listen address +PORT=8082 + +# Path to the portal's own SQLite database +DB_PATH=./portal.db + +# Path to arcline-uptime's SQLite database (read-only) +UPTIME_DB_PATH=../arcline-uptime/uptime.db + +# Secret key for session token HMAC (generate with: openssl rand -hex 32) +SESSION_SECRET=changeme + +# SMTP — used for password reset and ticket notification emails +SMTP_HOST=mail.arclineit.com +SMTP_PORT=587 +SMTP_USER=portal@arclineit.com +SMTP_PASS=changeme +SMTP_FROM=portal@arclineit.com + +# Admin notification email (new tickets, alerts) +ADMIN_EMAIL=blake@arclineit.com + +# Base URL (used in email links) +BASE_URL=https://portal.arclineit.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b90572 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +arcline-portal +arcline-portal-linux-amd64 +arcline-portal-linux-arm64 +*.db +.env +*.test +*.out diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d993a43 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +BINARY := arcline-portal +MODULE := arclineit/arcline-portal +GO := go +GOFLAGS := -trimpath -ldflags="-s -w" + +.PHONY: build run linux-amd64 linux-arm64 all test clean + +build: + $(GO) build $(GOFLAGS) -o $(BINARY) . + +run: + $(GO) run . + +linux-amd64: + GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -o $(BINARY)-linux-amd64 . + +linux-arm64: + GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -o $(BINARY)-linux-arm64 . + +all: linux-amd64 linux-arm64 + +test: + $(GO) test ./... + +clean: + rm -f $(BINARY) $(BINARY)-linux-amd64 $(BINARY)-linux-arm64 diff --git a/README.md b/README.md index 6fa483b..ef59fc0 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,110 @@ # arcline-portal -Customer dashboard for arclineit.com. Provides SSL expiry monitoring, one-click static site deployment, a log viewer, and a basic support ticket system — without requiring customers to SSH into anything. +Customer dashboard for arclineit.com. Provides SSL expiry monitoring and a support ticket system — without requiring customers to SSH into anything. Sits alongside WHMCS for billing; handles everything WHMCS doesn't. -## Status - -Planned. Not yet started. - ## Stack - Go backend, vanilla HTML/CSS/JS (Arcline design system) -- PostgreSQL or SQLite -- Session-based auth with optional TOTP 2FA -- Ships as a single binary with embedded static assets +- SQLite (single file, no server required) +- Session-based auth (bcrypt + secure cookies) +- Ships as a single binary with embedded static assets and templates ## Modules ### SSL Expiry Dashboard -Customers add domains; the system checks cert expiry daily and sends alerts at 30/14/7 days. Color-coded: green > 30d, amber 14–30d, red < 14d. - -### Static Deployment -Connect a GitLab repo or upload a zip. On push to main, Arcline pulls, builds, and deploys via rsync. Supports static HTML, Hugo, Jekyll, plain PHP. Last 3 deployments kept for rollback. - -### Log Viewer -Browse access/error logs in the browser. Filter by date, status code, IP, path. Live tail via SSE. +Customers add domains; the system checks cert expiry daily via TLS dial and displays status color-coded: green > 30d, amber 14–30d, red < 14d. ### Support Tickets -Customer opens a ticket; Blake gets an email. Replies go back into the thread. No third-party helpdesk. - -## Environment variables - -To be documented once scaffold is started. +Customer opens a ticket; Blake gets an email. Replies go back into the thread from the portal UI. No third-party helpdesk. ## Deployment -Single binary + systemd unit behind nginx. See [todo.md](todo.md) for the full task list. +### Prerequisites + +- Linux server (amd64 or arm64) +- nginx +- An `arcline` system user + +### Build + +```sh +# Local binary +make build + +# Cross-compile for Linux +make linux-amd64 +make linux-arm64 +``` + +### Install + +```sh +# Create directories and user +sudo useradd -r -s /sbin/nologin -d /opt/arcline-portal arcline +sudo mkdir -p /opt/arcline-portal +sudo chown arcline:arcline /opt/arcline-portal + +# Copy binary +sudo cp arcline-portal-linux-amd64 /opt/arcline-portal/arcline-portal +sudo chmod +x /opt/arcline-portal/arcline-portal + +# Copy and populate env file +sudo cp .env.example /opt/arcline-portal/.env +sudo chown arcline:arcline /opt/arcline-portal/.env +sudo chmod 600 /opt/arcline-portal/.env +# Edit /opt/arcline-portal/.env and fill in real values +``` + +### systemd + +```sh +sudo cp deploy/arcline-portal.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now arcline-portal +sudo systemctl status arcline-portal +``` + +### nginx + +```sh +sudo cp deploy/nginx-portal.conf /etc/nginx/sites-available/arcline-portal +sudo ln -s /etc/nginx/sites-available/arcline-portal /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +Expects TLS certificates at: +- `/etc/ssl/arclineit.com/fullchain.pem` +- `/etc/ssl/arclineit.com/privkey.pem` + +### Seed first admin account + +```sh +sudo -u arcline /opt/arcline-portal/arcline-portal \ + -seed \ + -username blake \ + -name "Blake" \ + -password "changeme" +``` + +## Environment variables + +Copy `.env.example` to `.env` and set the following: + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `8082` | HTTP listen port (nginx proxies to this) | +| `DB_PATH` | `./portal.db` | Path to the portal SQLite database | +| `UPTIME_DB_PATH` | `../arcline-uptime/uptime.db` | Path to arcline-uptime's database (read-only); omit if not using uptime integration | +| `SESSION_SECRET` | — | 32-byte hex secret for session tokens. Generate with: `openssl rand -hex 32` | +| `SMTP_HOST` | `mail.arclineit.com` | SMTP server hostname | +| `SMTP_PORT` | `587` | SMTP port (STARTTLS) | +| `SMTP_USER` | — | SMTP username | +| `SMTP_PASS` | — | SMTP password | +| `SMTP_FROM` | `portal@arclineit.com` | From address for outbound email | +| `ADMIN_EMAIL` | `blake@arclineit.com` | Receives new ticket notifications | +| `BASE_URL` | `https://portal.arclineit.com` | Base URL used in email links (no trailing slash) | ## License diff --git a/deploy/arcline-portal.service b/deploy/arcline-portal.service new file mode 100644 index 0000000..56d4b25 --- /dev/null +++ b/deploy/arcline-portal.service @@ -0,0 +1,23 @@ +[Unit] +Description=Arcline Portal — customer dashboard +After=network.target + +[Service] +Type=simple +User=arcline +Group=arcline +WorkingDirectory=/opt/arcline-portal +EnvironmentFile=/opt/arcline-portal/.env +ExecStart=/opt/arcline-portal/arcline-portal +Restart=on-failure +RestartSec=5s + +# Hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/opt/arcline-portal + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx-portal.conf b/deploy/nginx-portal.conf new file mode 100644 index 0000000..87786e9 --- /dev/null +++ b/deploy/nginx-portal.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name portal.arclineit.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name portal.arclineit.com; + + ssl_certificate /etc/ssl/arclineit.com/fullchain.pem; + ssl_certificate_key /etc/ssl/arclineit.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Security headers + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + + location / { + proxy_pass http://127.0.0.1:8082; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8b97288 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module arclineit/arcline-portal + +go 1.25.7 + +require ( + golang.org/x/crypto v0.37.0 + modernc.org/sqlite v1.47.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..172603d --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..b7c163c --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,107 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/http" + "time" + + "arclineit/arcline-portal/internal/db" + "golang.org/x/crypto/bcrypt" +) + +type contextKey string + +const clientKey contextKey = "client" + +const SessionCookie = "arc_session" +const SessionTTL = 30 * 24 * time.Hour // 30 days + +// HashPassword returns a bcrypt hash of the password. +func HashPassword(password string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(b), err +} + +// CheckPassword reports whether password matches the stored hash. +func CheckPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// GenerateToken returns a 32-byte hex-encoded random token. +func GenerateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// SetSessionCookie writes a secure session cookie to the response. +func SetSessionCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookie, + Value: token, + Path: "/", + Expires: time.Now().Add(SessionTTL), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// ClearSessionCookie removes the session cookie. +func ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// Middleware validates the session cookie and injects the client into context. +// Redirects to /login on missing or invalid session. +func Middleware(database *db.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(SessionCookie) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + client, err := database.GetClientBySession(cookie.Value) + if err != nil || client == nil { + ClearSessionCookie(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + ctx := context.WithValue(r.Context(), clientKey, client) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// AdminMiddleware enforces that the authenticated client is an admin. +// Must be used after Middleware. +func AdminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + client := ClientFromContext(r.Context()) + if client == nil || !client.IsAdmin { + http.Error(w, "403 Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// ClientFromContext retrieves the authenticated client from context. +// Returns nil if not present. +func ClientFromContext(ctx context.Context) *db.Client { + c, _ := ctx.Value(clientKey).(*db.Client) + return c +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..845a5fc --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,605 @@ +package db + +import ( + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +// DB wraps the portal SQLite database. +type DB struct { + db *sql.DB +} + +// --- Model types --- + +type Client struct { + ID int64 + Username string + DisplayName string + Email string + PasswordHash string + IsAdmin bool + CreatedAt time.Time +} + +type Session struct { + Token string + ClientID int64 + ExpiresAt time.Time +} + +// Domain is a customer-owned domain tracked for SSL expiry. +type Domain struct { + ID int64 + ClientID int64 + Domain string + AddedAt time.Time + LastCheckedAt time.Time + ExpiresAt time.Time + DaysRemaining int + IsValid bool + CheckError string +} + +// Monitor links a client to a monitor name in arcline-uptime. +type Monitor struct { + ID int64 + ClientID int64 + MonitorName string + Label string // human-friendly display name +} + +type TicketStatus string + +const ( + TicketOpen TicketStatus = "open" + TicketInProgress TicketStatus = "in_progress" + TicketClosed TicketStatus = "closed" +) + +type Ticket struct { + ID int64 + ClientID int64 + ClientName string // populated by ListAllTickets (admin view) + Subject string + Status TicketStatus + CreatedAt time.Time + UpdatedAt time.Time +} + +type TicketMessage struct { + ID int64 + TicketID int64 + Body string + FromAdmin bool + CreatedAt time.Time +} + +type PasswordReset struct { + Token string + ClientID int64 + ExpiresAt time.Time + Used bool +} + +// --- Open / migrate --- + +func Open(path string) (*DB, error) { + sqlDB, err := sql.Open("sqlite", path+"?_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + sqlDB.SetMaxOpenConns(1) + + d := &DB{db: sqlDB} + if err := d.migrate(); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + return d, nil +} + +func (d *DB) Close() error { return d.db.Close() } + +func (d *DB) migrate() error { + _, err := d.db.Exec(` + CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_sessions_client ON sessions(client_id); + + CREATE TABLE IF NOT EXISTS password_resets ( + token TEXT PRIMARY KEY, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS client_monitors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + monitor_name TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + UNIQUE(client_id, monitor_name) + ); + + CREATE TABLE IF NOT EXISTS domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + domain TEXT NOT NULL, + added_at INTEGER NOT NULL DEFAULT (unixepoch()), + last_checked_at INTEGER NOT NULL DEFAULT 0, + expires_at INTEGER NOT NULL DEFAULT 0, + days_remaining INTEGER NOT NULL DEFAULT 0, + is_valid INTEGER NOT NULL DEFAULT 0, + check_error TEXT NOT NULL DEFAULT '', + UNIQUE(client_id, domain) + ); + + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + subject TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_tickets_client ON tickets(client_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS ticket_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + body TEXT NOT NULL, + from_admin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_messages_ticket ON ticket_messages(ticket_id, created_at ASC); + `) + if err != nil { + return err + } + // Add email column to existing databases — SQLite has no IF NOT EXISTS for columns. + _, _ = d.db.Exec(`ALTER TABLE clients ADD COLUMN email TEXT NOT NULL DEFAULT ''`) + return nil +} + +// --- Client queries --- + +func (d *DB) CreateClient(username, displayName, email, passwordHash string, isAdmin bool) (*Client, error) { + res, err := d.db.Exec( + `INSERT INTO clients (username, display_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)`, + username, displayName, email, passwordHash, boolToInt(isAdmin), + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return d.GetClientByID(id) +} + +func (d *DB) GetClientByID(id int64) (*Client, error) { + row := d.db.QueryRow( + `SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients WHERE id = ?`, id, + ) + return scanClient(row) +} + +func (d *DB) GetClientByUsername(username string) (*Client, error) { + row := d.db.QueryRow( + `SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients WHERE username = ?`, username, + ) + return scanClient(row) +} + +func (d *DB) GetClientByEmail(email string) (*Client, error) { + row := d.db.QueryRow( + `SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients WHERE email = ? AND email != ''`, email, + ) + return scanClient(row) +} + +func (d *DB) UpdateClientEmail(clientID int64, email string) error { + _, err := d.db.Exec(`UPDATE clients SET email = ? WHERE id = ?`, email, clientID) + return err +} + +func (d *DB) ListClients() ([]Client, error) { + rows, err := d.db.Query( + `SELECT id, username, display_name, email, password_hash, is_admin, created_at FROM clients ORDER BY display_name`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Client + for rows.Next() { + c, err := scanClientFromRows(rows) + if err != nil { + return nil, err + } + out = append(out, *c) + } + return out, rows.Err() +} + +func (d *DB) UpdateClientPassword(clientID int64, hash string) error { + _, err := d.db.Exec(`UPDATE clients SET password_hash = ? WHERE id = ?`, hash, clientID) + return err +} + +func (d *DB) DeleteClient(id int64) error { + _, err := d.db.Exec(`DELETE FROM clients WHERE id = ?`, id) + return err +} + +// --- Session queries --- + +func (d *DB) CreateSession(token string, clientID int64, expiresAt time.Time) error { + _, err := d.db.Exec( + `INSERT INTO sessions (token, client_id, expires_at) VALUES (?, ?, ?)`, + token, clientID, expiresAt.Unix(), + ) + return err +} + +func (d *DB) GetClientBySession(token string) (*Client, error) { + var clientID int64 + var exp int64 + err := d.db.QueryRow( + `SELECT client_id, expires_at FROM sessions WHERE token = ?`, token, + ).Scan(&clientID, &exp) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("session not found") + } + if err != nil { + return nil, err + } + if time.Now().After(time.Unix(exp, 0)) { + _ = d.DeleteSession(token) + return nil, fmt.Errorf("session expired") + } + return d.GetClientByID(clientID) +} + +func (d *DB) DeleteSession(token string) error { + _, err := d.db.Exec(`DELETE FROM sessions WHERE token = ?`, token) + return err +} + +func (d *DB) PruneSessions() error { + _, err := d.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now().Unix()) + return err +} + +// --- Password reset queries --- + +func (d *DB) CreatePasswordReset(token string, clientID int64) error { + exp := time.Now().Add(time.Hour).Unix() + _, err := d.db.Exec( + `INSERT INTO password_resets (token, client_id, expires_at) VALUES (?, ?, ?)`, + token, clientID, exp, + ) + return err +} + +func (d *DB) UsePasswordReset(token string) (int64, error) { + var clientID int64 + var exp int64 + var used int + err := d.db.QueryRow( + `SELECT client_id, expires_at, used FROM password_resets WHERE token = ?`, token, + ).Scan(&clientID, &exp, &used) + if err == sql.ErrNoRows { + return 0, fmt.Errorf("reset token not found") + } + if err != nil { + return 0, err + } + if used != 0 { + return 0, fmt.Errorf("reset token already used") + } + if time.Now().After(time.Unix(exp, 0)) { + return 0, fmt.Errorf("reset token expired") + } + _, err = d.db.Exec(`UPDATE password_resets SET used = 1 WHERE token = ?`, token) + return clientID, err +} + +// --- Monitor queries --- + +func (d *DB) AddMonitor(clientID int64, monitorName, label string) error { + _, err := d.db.Exec( + `INSERT OR IGNORE INTO client_monitors (client_id, monitor_name, label) VALUES (?, ?, ?)`, + clientID, monitorName, label, + ) + return err +} + +func (d *DB) RemoveMonitor(id int64) error { + _, err := d.db.Exec(`DELETE FROM client_monitors WHERE id = ?`, id) + return err +} + +func (d *DB) ListMonitors(clientID int64) ([]Monitor, error) { + rows, err := d.db.Query( + `SELECT id, client_id, monitor_name, label FROM client_monitors WHERE client_id = ? ORDER BY label, monitor_name`, + clientID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Monitor + for rows.Next() { + var m Monitor + if err := rows.Scan(&m.ID, &m.ClientID, &m.MonitorName, &m.Label); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// --- Domain queries --- + +func (d *DB) AddDomain(clientID int64, domain string) error { + _, err := d.db.Exec( + `INSERT OR IGNORE INTO domains (client_id, domain) VALUES (?, ?)`, + clientID, domain, + ) + return err +} + +func (d *DB) RemoveDomain(id int64) error { + _, err := d.db.Exec(`DELETE FROM domains WHERE id = ?`, id) + return err +} + +func (d *DB) UpdateDomainStatus(id int64, expiresAt time.Time, daysRemaining int, isValid bool, checkErr string) error { + _, err := d.db.Exec( + `UPDATE domains SET last_checked_at = ?, expires_at = ?, days_remaining = ?, is_valid = ?, check_error = ? WHERE id = ?`, + time.Now().Unix(), expiresAt.Unix(), daysRemaining, boolToInt(isValid), checkErr, id, + ) + return err +} + +func (d *DB) ListDomains(clientID int64) ([]Domain, error) { + rows, err := d.db.Query( + `SELECT id, client_id, domain, added_at, last_checked_at, expires_at, days_remaining, is_valid, check_error + FROM domains WHERE client_id = ? ORDER BY domain`, + clientID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDomains(rows) +} + +// AllDomainsForCheck returns all domains across all clients (for the background checker). +func (d *DB) AllDomainsForCheck() ([]Domain, error) { + rows, err := d.db.Query( + `SELECT id, client_id, domain, added_at, last_checked_at, expires_at, days_remaining, is_valid, check_error FROM domains`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDomains(rows) +} + +// --- Ticket queries --- + +func (d *DB) CreateTicket(clientID int64, subject, body string) (*Ticket, error) { + res, err := d.db.Exec( + `INSERT INTO tickets (client_id, subject) VALUES (?, ?)`, clientID, subject, + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + _, err = d.db.Exec( + `INSERT INTO ticket_messages (ticket_id, body, from_admin) VALUES (?, ?, 0)`, id, body, + ) + if err != nil { + return nil, err + } + return d.GetTicket(id) +} + +func (d *DB) GetTicket(id int64) (*Ticket, error) { + row := d.db.QueryRow( + `SELECT id, client_id, subject, status, created_at, updated_at FROM tickets WHERE id = ?`, id, + ) + return scanTicket(row) +} + +func (d *DB) ListTickets(clientID int64) ([]Ticket, error) { + rows, err := d.db.Query( + `SELECT id, client_id, subject, status, created_at, updated_at + FROM tickets WHERE client_id = ? ORDER BY updated_at DESC`, + clientID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Ticket + for rows.Next() { + t, err := scanTicketFromRows(rows) + if err != nil { + return nil, err + } + out = append(out, *t) + } + return out, rows.Err() +} + +func (d *DB) ListAllTickets() ([]Ticket, error) { + rows, err := d.db.Query( + `SELECT t.id, t.client_id, c.display_name, t.subject, t.status, t.created_at, t.updated_at + FROM tickets t + JOIN clients c ON c.id = t.client_id + ORDER BY t.updated_at DESC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Ticket + for rows.Next() { + var t Ticket + var createdTS, updatedTS int64 + if err := rows.Scan(&t.ID, &t.ClientID, &t.ClientName, &t.Subject, &t.Status, &createdTS, &updatedTS); err != nil { + return nil, err + } + t.CreatedAt = time.Unix(createdTS, 0) + t.UpdatedAt = time.Unix(updatedTS, 0) + out = append(out, t) + } + return out, rows.Err() +} + +func (d *DB) AddTicketMessage(ticketID int64, body string, fromAdmin bool) error { + _, err := d.db.Exec( + `INSERT INTO ticket_messages (ticket_id, body, from_admin) VALUES (?, ?, ?)`, + ticketID, body, boolToInt(fromAdmin), + ) + if err != nil { + return err + } + _, err = d.db.Exec( + `UPDATE tickets SET updated_at = unixepoch() WHERE id = ?`, ticketID, + ) + return err +} + +func (d *DB) SetTicketStatus(id int64, status TicketStatus) error { + _, err := d.db.Exec(`UPDATE tickets SET status = ?, updated_at = unixepoch() WHERE id = ?`, string(status), id) + return err +} + +func (d *DB) GetTicketMessages(ticketID int64) ([]TicketMessage, error) { + rows, err := d.db.Query( + `SELECT id, ticket_id, body, from_admin, created_at FROM ticket_messages WHERE ticket_id = ? ORDER BY created_at ASC`, + ticketID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []TicketMessage + for rows.Next() { + var m TicketMessage + var ts int64 + var fa int + if err := rows.Scan(&m.ID, &m.TicketID, &m.Body, &fa, &ts); err != nil { + return nil, err + } + m.FromAdmin = fa != 0 + m.CreatedAt = time.Unix(ts, 0) + out = append(out, m) + } + return out, rows.Err() +} + +// --- Helpers --- + +func scanClient(row *sql.Row) (*Client, error) { + var c Client + var ts int64 + var admin int + err := row.Scan(&c.ID, &c.Username, &c.DisplayName, &c.Email, &c.PasswordHash, &admin, &ts) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + c.IsAdmin = admin != 0 + c.CreatedAt = time.Unix(ts, 0) + return &c, nil +} + +func scanClientFromRows(rows *sql.Rows) (*Client, error) { + var c Client + var ts int64 + var admin int + if err := rows.Scan(&c.ID, &c.Username, &c.DisplayName, &c.Email, &c.PasswordHash, &admin, &ts); err != nil { + return nil, err + } + c.IsAdmin = admin != 0 + c.CreatedAt = time.Unix(ts, 0) + return &c, nil +} + +func scanDomains(rows *sql.Rows) ([]Domain, error) { + var out []Domain + for rows.Next() { + var dom Domain + var addedTS, checkedTS, expiresTS int64 + var valid int + if err := rows.Scan( + &dom.ID, &dom.ClientID, &dom.Domain, + &addedTS, &checkedTS, &expiresTS, + &dom.DaysRemaining, &valid, &dom.CheckError, + ); err != nil { + return nil, err + } + dom.AddedAt = time.Unix(addedTS, 0) + dom.LastCheckedAt = time.Unix(checkedTS, 0) + dom.ExpiresAt = time.Unix(expiresTS, 0) + dom.IsValid = valid != 0 + out = append(out, dom) + } + return out, rows.Err() +} + +func scanTicket(row *sql.Row) (*Ticket, error) { + var t Ticket + var createdTS, updatedTS int64 + err := row.Scan(&t.ID, &t.ClientID, &t.Subject, &t.Status, &createdTS, &updatedTS) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + t.CreatedAt = time.Unix(createdTS, 0) + t.UpdatedAt = time.Unix(updatedTS, 0) + return &t, nil +} + +func scanTicketFromRows(rows *sql.Rows) (*Ticket, error) { + var t Ticket + var createdTS, updatedTS int64 + if err := rows.Scan(&t.ID, &t.ClientID, &t.Subject, &t.Status, &createdTS, &updatedTS); err != nil { + return nil, err + } + t.CreatedAt = time.Unix(createdTS, 0) + t.UpdatedAt = time.Unix(updatedTS, 0) + return &t, nil +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 0000000..42f2d8a --- /dev/null +++ b/internal/mail/mail.go @@ -0,0 +1,124 @@ +// Package mail sends transactional emails via SMTP with STARTTLS. +package mail + +import ( + "bytes" + "fmt" + "net" + "net/smtp" + "strings" + "time" +) + +// Config holds SMTP connection settings sourced from environment variables. +type Config struct { + Host string // SMTP_HOST + Port string // SMTP_PORT (default "587") + Username string // SMTP_USER + Password string // SMTP_PASS + From string // SMTP_FROM + AdminEmail string // ADMIN_EMAIL + BaseURL string // BASE_URL (used in link generation) +} + +// Mailer sends email using the provided Config. +type Mailer struct { + cfg Config +} + +// New returns a Mailer. Returns an error if required fields are missing. +func New(cfg Config) (*Mailer, error) { + if cfg.Host == "" || cfg.From == "" { + return nil, fmt.Errorf("mail: SMTP_HOST and SMTP_FROM are required") + } + if cfg.Port == "" { + cfg.Port = "587" + } + return &Mailer{cfg: cfg}, nil +} + +// Configured reports whether the mailer has enough config to send. +func (m *Mailer) Configured() bool { + return m.cfg.Host != "" && m.cfg.From != "" +} + +// SendPasswordReset sends a password-reset link to the given address. +func (m *Mailer) SendPasswordReset(toAddr, toName, token string) error { + link := strings.TrimRight(m.cfg.BaseURL, "/") + "/reset?token=" + token + subject := "Reset your Arcline Portal password" + body := fmt.Sprintf(`Hi %s, + +Someone requested a password reset for your Arcline Portal account. +If that was you, click the link below to set a new password. +The link expires in 1 hour. + + %s + +If you did not request this, you can safely ignore this email. + +— Arcline IT +`, toName, link) + return m.send(toAddr, subject, body) +} + +// SendTicketCreated notifies the admin that a new ticket was opened. +func (m *Mailer) SendTicketCreated(clientName, subject, body string, ticketID int64) error { + if m.cfg.AdminEmail == "" { + return nil + } + link := strings.TrimRight(m.cfg.BaseURL, "/") + fmt.Sprintf("/tickets/%d", ticketID) + msg := fmt.Sprintf(`New support ticket from %s + +Subject: %s + +%s + +--- +View ticket: %s +`, clientName, subject, body, link) + return m.send(m.cfg.AdminEmail, fmt.Sprintf("[Arcline Portal] New ticket: %s", subject), msg) +} + +// SendTicketReply notifies a party that a reply was added to their ticket. +// toAddr is the recipient; fromName is who replied. +func (m *Mailer) SendTicketReply(toAddr, toName, fromName, ticketSubject, replyBody string, ticketID int64) error { + if toAddr == "" { + return nil + } + link := strings.TrimRight(m.cfg.BaseURL, "/") + fmt.Sprintf("/tickets/%d", ticketID) + msg := fmt.Sprintf(`Hi %s, + +%s replied to your ticket "%s": + +--- +%s +--- + +View the full thread: %s + +— Arcline IT +`, toName, fromName, ticketSubject, replyBody, link) + return m.send(toAddr, fmt.Sprintf("[Arcline Portal] Re: %s", ticketSubject), msg) +} + +// send composes and sends a plain-text email via SMTP STARTTLS. +func (m *Mailer) send(to, subject, body string) error { + addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port) + + var buf bytes.Buffer + fmt.Fprintf(&buf, "From: %s\r\n", m.cfg.From) + fmt.Fprintf(&buf, "To: %s\r\n", to) + fmt.Fprintf(&buf, "Subject: %s\r\n", subject) + fmt.Fprintf(&buf, "Date: %s\r\n", time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700")) + fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n") + fmt.Fprintf(&buf, "Content-Type: text/plain; charset=UTF-8\r\n") + fmt.Fprintf(&buf, "\r\n") + fmt.Fprintf(&buf, "%s", strings.ReplaceAll(body, "\n", "\r\n")) + + var auth smtp.Auth + if m.cfg.Username != "" { + auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) + } + + return smtp.SendMail(addr, auth, m.cfg.From, []string{to}, buf.Bytes()) +} diff --git a/internal/ssl/checker.go b/internal/ssl/checker.go new file mode 100644 index 0000000..bce047a --- /dev/null +++ b/internal/ssl/checker.go @@ -0,0 +1,78 @@ +package ssl + +import ( + "crypto/tls" + "fmt" + "net" + "time" +) + +// Result holds the outcome of a single cert check. +type Result struct { + Domain string + ExpiresAt time.Time + DaysRemaining int + IsValid bool + Error string +} + +// Severity returns a CSS class name for the expiry status. +// +// "ok" — > 30 days +// "warn" — 14–30 days +// "crit" — < 14 days or invalid +func (r Result) Severity() string { + if !r.IsValid { + return "crit" + } + switch { + case r.DaysRemaining > 30: + return "ok" + case r.DaysRemaining >= 14: + return "warn" + default: + return "crit" + } +} + +// Check dials domain:443, retrieves the TLS certificate chain, and returns +// the expiry of the leaf certificate. +func Check(domain string) Result { + r := Result{Domain: domain} + + conn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 10 * time.Second}, + "tcp", + net.JoinHostPort(domain, "443"), + &tls.Config{ServerName: domain}, + ) + if err != nil { + r.Error = fmt.Sprintf("tls dial: %s", err) + return r + } + defer conn.Close() + + certs := conn.ConnectionState().PeerCertificates + if len(certs) == 0 { + r.Error = "no certificates in chain" + return r + } + + leaf := certs[0] + now := time.Now() + + r.ExpiresAt = leaf.NotAfter + r.DaysRemaining = int(leaf.NotAfter.Sub(now).Hours() / 24) + + if now.Before(leaf.NotBefore) { + r.Error = fmt.Sprintf("certificate not yet valid (valid from %s)", leaf.NotBefore.Format("2006-01-02")) + return r + } + if now.After(leaf.NotAfter) { + r.Error = fmt.Sprintf("certificate expired %s", leaf.NotAfter.Format("2006-01-02")) + return r + } + + r.IsValid = true + return r +} diff --git a/internal/uptime/reader.go b/internal/uptime/reader.go new file mode 100644 index 0000000..969ed8a --- /dev/null +++ b/internal/uptime/reader.go @@ -0,0 +1,107 @@ +// Package uptime provides read-only access to arcline-uptime's SQLite database. +// The portal never writes to the uptime DB — it only queries it. +package uptime + +import ( + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +// Reader is a read-only view of the arcline-uptime database. +type Reader struct { + db *sql.DB +} + +// MonitorStatus is a summary of a single monitor's current state. +type MonitorStatus struct { + Name string + Label string // display label from the portal (not from uptime) + Up bool + LastChecked time.Time + ResponseMS int64 + Uptime24h float64 + Uptime7d float64 + Uptime30d float64 +} + +// Open opens the uptime database in read-only mode. +func Open(path string) (*Reader, error) { + db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro", path)) + if err != nil { + return nil, fmt.Errorf("open uptime db: %w", err) + } + db.SetMaxOpenConns(1) + // Verify the expected schema exists. + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='checks'`).Scan(&n); err != nil || n == 0 { + db.Close() + return nil, fmt.Errorf("uptime db does not contain a 'checks' table — is the path correct?") + } + return &Reader{db: db}, nil +} + +func (r *Reader) Close() error { return r.db.Close() } + +// GetStatus returns the current status for each of the supplied monitor names. +// Unknown monitors (no check records) are included with Up=false. +func (r *Reader) GetStatus(monitors []string) ([]MonitorStatus, error) { + out := make([]MonitorStatus, 0, len(monitors)) + for _, name := range monitors { + ms := MonitorStatus{Name: name} + + // Latest check + var ts int64 + var up, statusCode int + var responseMS int64 + err := r.db.QueryRow( + `SELECT checked_at, up, status_code, response_ms FROM checks + WHERE monitor_name = ? ORDER BY checked_at DESC LIMIT 1`, + name, + ).Scan(&ts, &up, &statusCode, &responseMS) + if err != nil && err != sql.ErrNoRows { + return nil, err + } + if err != sql.ErrNoRows { + ms.Up = up != 0 + ms.LastChecked = time.Unix(ts, 0) + ms.ResponseMS = responseMS + } + + // Uptime percentages + ms.Uptime24h, _ = r.uptimePct(name, time.Now().Add(-24*time.Hour)) + ms.Uptime7d, _ = r.uptimePct(name, time.Now().Add(-7*24*time.Hour)) + ms.Uptime30d, _ = r.uptimePct(name, time.Now().Add(-30*24*time.Hour)) + + out = append(out, ms) + } + return out, nil +} + +func (r *Reader) uptimePct(monitorName string, since time.Time) (float64, error) { + var total, upCount int64 + err := r.db.QueryRow( + `SELECT COUNT(*), COALESCE(SUM(up), 0) FROM checks WHERE monitor_name = ? AND checked_at >= ?`, + monitorName, since.Unix(), + ).Scan(&total, &upCount) + if err != nil { + return 0, err + } + if total == 0 { + return 100.0, nil + } + return float64(upCount) / float64(total) * 100.0, nil +} + +// Available reports whether the uptime DB can be opened at path. +// Used at startup to warn if the path is wrong without hard-failing. +func Available(path string) bool { + r, err := Open(path) + if err != nil { + return false + } + r.Close() + return true +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..889a266 --- /dev/null +++ b/internal/web/handler.go @@ -0,0 +1,575 @@ +package web + +import ( + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "arclineit/arcline-portal/internal/auth" + "arclineit/arcline-portal/internal/db" + "arclineit/arcline-portal/internal/mail" + "arclineit/arcline-portal/internal/ssl" + "arclineit/arcline-portal/internal/uptime" +) + +// Handler holds all HTTP handler dependencies. +type Handler struct { + DB *db.DB + Uptime *uptime.Reader // may be nil if uptime DB unavailable + Mail *mail.Mailer // may be nil if SMTP not configured +} + +// clientFromCtx is a package-local shortcut for auth.ClientFromContext. +func clientFromCtx(r *http.Request) *db.Client { + return auth.ClientFromContext(r.Context()) +} + +func redirect(w http.ResponseWriter, r *http.Request, path string) { + http.Redirect(w, r, path, http.StatusSeeOther) +} + +func redirectFlash(w http.ResponseWriter, r *http.Request, path, msg string) { + http.Redirect(w, r, path+"?flash="+msg, http.StatusSeeOther) +} + +// --- Auth handlers --- + +func (h *Handler) LoginGET(w http.ResponseWriter, r *http.Request) { + render(w, r, "login.html", "Log in — Arcline Portal", nil) +} + +func (h *Handler) LoginPOST(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + + client, err := h.DB.GetClientByUsername(username) + if err != nil || client == nil || !auth.CheckPassword(client.PasswordHash, password) { + render(w, r, "login.html", "Log in — Arcline Portal", map[string]string{ + "Error": "Invalid username or password.", + }) + return + } + + token, err := auth.GenerateToken() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := h.DB.CreateSession(token, client.ID, time.Now().Add(auth.SessionTTL)); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + auth.SetSessionCookie(w, token) + redirect(w, r, "/dashboard") +} + +func (h *Handler) LogoutPOST(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(auth.SessionCookie); err == nil { + _ = h.DB.DeleteSession(cookie.Value) + } + auth.ClearSessionCookie(w) + redirect(w, r, "/login") +} + +// --- Password reset --- + +func (h *Handler) ForgotGET(w http.ResponseWriter, r *http.Request) { + render(w, r, "forgot.html", "Reset Password — Arcline Portal", nil) +} + +func (h *Handler) ForgotPOST(w http.ResponseWriter, r *http.Request) { + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + // Always show success to prevent email enumeration. + success := map[string]string{"Success": "If that email is registered, a reset link has been sent."} + + client, err := h.DB.GetClientByEmail(email) + if err != nil || client == nil { + render(w, r, "forgot.html", "Reset Password — Arcline Portal", success) + return + } + + token, err := auth.GenerateToken() + if err != nil { + render(w, r, "forgot.html", "Reset Password — Arcline Portal", success) + return + } + if err := h.DB.CreatePasswordReset(token, client.ID); err != nil { + slog.Error("create password reset", "err", err) + render(w, r, "forgot.html", "Reset Password — Arcline Portal", success) + return + } + if h.Mail != nil && h.Mail.Configured() { + if err := h.Mail.SendPasswordReset(client.Email, client.DisplayName, token); err != nil { + slog.Error("send password reset email", "err", err) + } + } + render(w, r, "forgot.html", "Reset Password — Arcline Portal", success) +} + +func (h *Handler) ResetGET(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(r.URL.Query().Get("token")) + if token == "" { + redirect(w, r, "/forgot") + return + } + render(w, r, "reset.html", "Set New Password — Arcline Portal", map[string]string{"Token": token}) +} + +func (h *Handler) ResetPOST(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(r.FormValue("token")) + password := r.FormValue("password") + confirm := r.FormValue("confirm") + + errData := func(msg string) { + render(w, r, "reset.html", "Set New Password — Arcline Portal", map[string]string{ + "Token": token, + "Error": msg, + }) + } + + if token == "" { + redirect(w, r, "/forgot") + return + } + if len(password) < 8 { + errData("Password must be at least 8 characters.") + return + } + if password != confirm { + errData("Passwords do not match.") + return + } + + clientID, err := h.DB.UsePasswordReset(token) + if err != nil { + errData("Reset link is invalid or has expired.") + return + } + + hash, err := auth.HashPassword(password) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := h.DB.UpdateClientPassword(clientID, hash); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + redirectFlash(w, r, "/login", "Password+updated.+Please+log+in.") +} + +// --- Settings --- + +func (h *Handler) SettingsGET(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{ + "Email": client.Email, + }) +} + +func (h *Handler) SettingsEmailPOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + if email == "" { + render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{ + "Email": client.Email, + "Error": "Email cannot be empty.", + }) + return + } + if err := h.DB.UpdateClientEmail(client.ID, email); err != nil { + render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{ + "Email": client.Email, + "Error": "Failed to update email.", + }) + return + } + redirectFlash(w, r, "/settings", "Email+updated.") +} + +func (h *Handler) SettingsPasswordPOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + current := r.FormValue("current") + newPass := r.FormValue("password") + confirm := r.FormValue("confirm") + + errData := func(msg string) { + render(w, r, "settings.html", "Settings — Arcline Portal", map[string]string{ + "Email": client.Email, + "Error": msg, + }) + } + + if !auth.CheckPassword(client.PasswordHash, current) { + errData("Current password is incorrect.") + return + } + if len(newPass) < 8 { + errData("New password must be at least 8 characters.") + return + } + if newPass != confirm { + errData("Passwords do not match.") + return + } + hash, err := auth.HashPassword(newPass) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := h.DB.UpdateClientPassword(client.ID, hash); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + redirectFlash(w, r, "/settings", "Password+changed.") +} + +// --- Dashboard --- + +type dashboardData struct { + Monitors []uptime.MonitorStatus + Domains []db.Domain + Tickets []db.Ticket +} + +func (h *Handler) DashboardGET(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + + dbMonitors, err := h.DB.ListMonitors(client.ID) + if err != nil { + slog.Error("list monitors", "err", err) + } + + var statuses []uptime.MonitorStatus + if h.Uptime != nil && len(dbMonitors) > 0 { + names := make([]string, len(dbMonitors)) + labels := make(map[string]string, len(dbMonitors)) + for i, m := range dbMonitors { + names[i] = m.MonitorName + labels[m.MonitorName] = m.Label + } + statuses, err = h.Uptime.GetStatus(names) + if err != nil { + slog.Error("get uptime status", "err", err) + } + for i := range statuses { + if l, ok := labels[statuses[i].Name]; ok && l != "" { + statuses[i].Label = l + } else { + statuses[i].Label = statuses[i].Name + } + } + } + + domains, _ := h.DB.ListDomains(client.ID) + tickets, _ := h.DB.ListTickets(client.ID) + + render(w, r, "dashboard.html", "Dashboard — Arcline Portal", dashboardData{ + Monitors: statuses, + Domains: domains, + Tickets: tickets, + }) +} + +// --- SSL --- + +func (h *Handler) SSLGet(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + domains, _ := h.DB.ListDomains(client.ID) + render(w, r, "ssl.html", "SSL Certificates — Arcline Portal", domains) +} + +func (h *Handler) SSLAddPOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + domain := strings.TrimSpace(strings.ToLower(r.FormValue("domain"))) + if domain == "" { + redirectFlash(w, r, "/ssl", "Domain+cannot+be+empty.") + return + } + // Strip scheme if pasted in + domain = strings.TrimPrefix(domain, "https://") + domain = strings.TrimPrefix(domain, "http://") + domain = strings.TrimSuffix(domain, "/") + + if err := h.DB.AddDomain(client.ID, domain); err != nil { + redirectFlash(w, r, "/ssl", "Failed+to+add+domain.") + return + } + // Kick off an immediate check in the background. + go func() { + domains, err := h.DB.ListDomains(client.ID) + if err != nil { + return + } + for _, d := range domains { + if d.Domain == domain { + res := ssl.Check(d.Domain) + _ = h.DB.UpdateDomainStatus(d.ID, res.ExpiresAt, res.DaysRemaining, res.IsValid, res.Error) + break + } + } + }() + redirect(w, r, "/ssl") +} + +func (h *Handler) SSLDeletePOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) + if err != nil { + redirect(w, r, "/ssl") + return + } + // Verify the domain belongs to this client before deleting. + domains, _ := h.DB.ListDomains(client.ID) + for _, d := range domains { + if d.ID == id { + _ = h.DB.RemoveDomain(id) + break + } + } + redirect(w, r, "/ssl") +} + +// --- Tickets --- + +func (h *Handler) TicketsGET(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + tickets, _ := h.DB.ListTickets(client.ID) + render(w, r, "tickets.html", "Support Tickets — Arcline Portal", tickets) +} + +func (h *Handler) TicketNewPOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + subject := strings.TrimSpace(r.FormValue("subject")) + body := strings.TrimSpace(r.FormValue("body")) + if subject == "" || body == "" { + redirectFlash(w, r, "/tickets", "Subject+and+message+are+required.") + return + } + ticket, err := h.DB.CreateTicket(client.ID, subject, body) + if err != nil { + slog.Error("create ticket", "err", err) + redirectFlash(w, r, "/tickets", "Failed+to+create+ticket.") + return + } + // Notify admin of new ticket. + if h.Mail != nil && h.Mail.Configured() { + go func() { + if err := h.Mail.SendTicketCreated(client.DisplayName, subject, body, ticket.ID); err != nil { + slog.Error("send ticket created email", "err", err) + } + }() + } + redirect(w, r, fmt.Sprintf("/tickets/%d", ticket.ID)) +} + +type ticketDetailData struct { + Ticket *db.Ticket + Messages []db.TicketMessage +} + +func (h *Handler) TicketGET(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + ticket, err := h.DB.GetTicket(id) + if err != nil || ticket == nil || (!client.IsAdmin && ticket.ClientID != client.ID) { + http.NotFound(w, r) + return + } + messages, _ := h.DB.GetTicketMessages(id) + render(w, r, "ticket.html", ticket.Subject+" — Arcline Portal", ticketDetailData{ + Ticket: ticket, + Messages: messages, + }) +} + +func (h *Handler) TicketReplyPOST(w http.ResponseWriter, r *http.Request) { + client := clientFromCtx(r) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + ticket, err := h.DB.GetTicket(id) + if err != nil || ticket == nil || (!client.IsAdmin && ticket.ClientID != client.ID) { + http.NotFound(w, r) + return + } + body := strings.TrimSpace(r.FormValue("body")) + if body == "" { + redirect(w, r, fmt.Sprintf("/tickets/%d", id)) + return + } + _ = h.DB.AddTicketMessage(id, body, client.IsAdmin) + + // Close ticket if admin checked the close box. + if client.IsAdmin && r.FormValue("close") == "1" { + _ = h.DB.SetTicketStatus(id, db.TicketClosed) + } + + // Email notifications for replies. + if h.Mail != nil && h.Mail.Configured() { + go func() { + ticketOwner, err := h.DB.GetClientByID(ticket.ClientID) + if err != nil || ticketOwner == nil { + return + } + if client.IsAdmin { + // Admin replied — notify the ticket owner. + if ticketOwner.Email != "" { + if err := h.Mail.SendTicketReply( + ticketOwner.Email, ticketOwner.DisplayName, + client.DisplayName, ticket.Subject, body, id, + ); err != nil { + slog.Error("send ticket reply email to client", "err", err) + } + } + } else { + // Client replied — notify admin via SendTicketCreated re-use pattern. + if err := h.Mail.SendTicketCreated(client.DisplayName, + "Re: "+ticket.Subject, body, id); err != nil { + slog.Error("send ticket reply email to admin", "err", err) + } + } + }() + } + + redirect(w, r, fmt.Sprintf("/tickets/%d", id)) +} + +// --- Admin --- + +type adminIndexData struct { + Clients []db.Client + Tickets []db.Ticket +} + +func (h *Handler) AdminIndexGET(w http.ResponseWriter, r *http.Request) { + clients, _ := h.DB.ListClients() + tickets, _ := h.DB.ListAllTickets() + render(w, r, "admin/index.html", "Admin — Arcline Portal", adminIndexData{ + Clients: clients, + Tickets: tickets, + }) +} + +func (h *Handler) AdminClientNewPOST(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.FormValue("username")) + displayName := strings.TrimSpace(r.FormValue("display_name")) + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + password := r.FormValue("password") + isAdmin := r.FormValue("is_admin") == "1" + + if username == "" || displayName == "" || len(password) < 8 { + redirectFlash(w, r, "/admin", "Username,+display+name+required.+Password+min+8+chars.") + return + } + hash, err := auth.HashPassword(password) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if _, err := h.DB.CreateClient(username, displayName, email, hash, isAdmin); err != nil { + redirectFlash(w, r, "/admin", "Failed+to+create+client+(username+may+be+taken).") + return + } + redirect(w, r, "/admin") +} + +type adminClientData struct { + Client *db.Client + Monitors []db.Monitor + Domains []db.Domain +} + +func (h *Handler) AdminClientGET(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + client, err := h.DB.GetClientByID(id) + if err != nil || client == nil { + http.NotFound(w, r) + return + } + monitors, _ := h.DB.ListMonitors(id) + domains, _ := h.DB.ListDomains(id) + render(w, r, "admin/client.html", client.DisplayName+" — Admin", adminClientData{ + Client: client, + Monitors: monitors, + Domains: domains, + }) +} + +func (h *Handler) AdminMonitorAddPOST(w http.ResponseWriter, r *http.Request) { + clientID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + monitorName := strings.TrimSpace(r.FormValue("monitor_name")) + label := strings.TrimSpace(r.FormValue("label")) + if monitorName == "" { + redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID)) + return + } + _ = h.DB.AddMonitor(clientID, monitorName, label) + redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID)) +} + +func (h *Handler) AdminMonitorDeletePOST(w http.ResponseWriter, r *http.Request) { + clientID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + monitorID, err := strconv.ParseInt(r.FormValue("monitor_id"), 10, 64) + if err != nil { + redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID)) + return + } + _ = h.DB.RemoveMonitor(monitorID) + redirect(w, r, fmt.Sprintf("/admin/clients/%d", clientID)) +} + +func (h *Handler) AdminClientDeletePOST(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + _ = h.DB.DeleteClient(id) + redirect(w, r, "/admin") +} + +// --- 404 --- + +func (h *Handler) NotFoundHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + render(w, r, "404.html", "Not Found — Arcline Portal", nil) +} + +// RunSSLChecker runs a full pass of cert checks against all domains in the DB. +// Call this from a background goroutine on a daily ticker. +func (h *Handler) RunSSLChecker() { + domains, err := h.DB.AllDomainsForCheck() + if err != nil { + slog.Error("ssl checker: list domains", "err", err) + return + } + for _, d := range domains { + res := ssl.Check(d.Domain) + if err := h.DB.UpdateDomainStatus(d.ID, res.ExpiresAt, res.DaysRemaining, res.IsValid, res.Error); err != nil { + slog.Error("ssl checker: update domain", "domain", d.Domain, "err", err) + } + } + slog.Info("ssl checker: completed", "domains", len(domains)) +} diff --git a/internal/web/render.go b/internal/web/render.go new file mode 100644 index 0000000..1197cf4 --- /dev/null +++ b/internal/web/render.go @@ -0,0 +1,74 @@ +package web + +import ( + "embed" + "fmt" + "html/template" + "net/http" + "strings" + "time" +) + +//go:embed templates +var templateFS embed.FS + +var funcMap = template.FuncMap{ + "upper": strings.ToUpper, + "lower": strings.ToLower, + "formatDate": func(t time.Time) string { return t.Format("2006-01-02") }, + "formatTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, + "ago": func(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } + }, + "pct": func(f float64) string { return fmt.Sprintf("%.2f%%", f) }, +} + +// parse returns a template set containing base.html and the named page. +// Parsing per-request ensures each page's {{define "content"}} is isolated. +func parse(name string) (*template.Template, error) { + return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/base.html", "templates/"+name) +} + +type pageData struct { + Title string + Username string + IsAdmin bool + Flash string + Path string + Data any +} + +func render(w http.ResponseWriter, r *http.Request, name string, title string, data any) { + pd := pageData{ + Title: title, + Path: r.URL.Path, + Data: data, + } + // Inject client info from context if present. + if c := clientFromCtx(r); c != nil { + pd.Username = c.DisplayName + pd.IsAdmin = c.IsAdmin + } + // Flash message from query param (redirect-after-post pattern). + pd.Flash = r.URL.Query().Get("flash") + + t, err := parse(name) + if err != nil { + http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.ExecuteTemplate(w, "base", pd); err != nil { + http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) + } +} diff --git a/internal/web/templates/404.html b/internal/web/templates/404.html new file mode 100644 index 0000000..35b8f8c --- /dev/null +++ b/internal/web/templates/404.html @@ -0,0 +1,22 @@ +{{define "content"}} +
+
+
+
+ + + +
+ 404 +
+
+ +

404

+

+ That page doesn't exist. +

+ ← back to dashboard +
+
+
+{{end}} diff --git a/internal/web/templates/admin/client.html b/internal/web/templates/admin/client.html new file mode 100644 index 0000000..b939c37 --- /dev/null +++ b/internal/web/templates/admin/client.html @@ -0,0 +1,76 @@ +{{define "content"}} +{{with .Data}} + + +
+
+

Service Monitors

+
+

+ Monitor names must match exactly what's configured in arcline-uptime. +

+ +
+ + + +
+ + {{if .Monitors}} + + + + {{range .Monitors}} + + + + + + {{end}} + +
monitor namelabel
{{.MonitorName}}{{if .Label}}{{.Label}}{{else}}—{{end}} +
+ + +
+
+ {{else}} +

No monitors assigned.

+ {{end}} +
+ +
+

Domains

+ {{if .Domains}} + + + + {{range .Domains}} + + + + + + + {{end}} + +
domainexpiresdaysstatus
{{.Domain}}{{if .IsValid}}{{formatDate .ExpiresAt}}{{else}}—{{end}}{{if .IsValid}}{{.DaysRemaining}}d{{else}}—{{end}} + {{if .IsValid}} + {{if gt .DaysRemaining 30}}OK + {{else if ge .DaysRemaining 14}}EXPIRING + {{else}}CRITICAL{{end}} + {{else if .CheckError}}ERROR + {{else}}PENDING{{end}} +
+ {{else}} +

No domains tracked for this client.

+ {{end}} +
+{{end}} +{{end}} diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html new file mode 100644 index 0000000..fc93056 --- /dev/null +++ b/internal/web/templates/admin/index.html @@ -0,0 +1,97 @@ +{{define "content"}} + + +{{with .Data}} + +
+
+

Clients

+
+ +
+
+
+ new-client +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ + {{if .Clients}} + + + + {{range .Clients}} + + + + + + + + {{end}} + +
usernamedisplay namerolejoined
{{.Username}}{{.DisplayName}}{{if .IsAdmin}}admin{{else}}client{{end}}{{formatDate .CreatedAt}} +
+ +
+
+ {{else}} +

No clients yet.

+ {{end}} +
+ +
+

All Tickets

+ {{if .Tickets}} + + + + {{range .Tickets}} + + + + + + + + {{end}} + +
#subjectclientstatusupdated
#{{.ID}}{{.Subject}}{{.ClientName}}{{.Status}}{{ago .UpdatedAt}}
+ {{else}} +

No tickets.

+ {{end}} +
+ +{{end}} +{{end}} diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html new file mode 100644 index 0000000..21ea263 --- /dev/null +++ b/internal/web/templates/base.html @@ -0,0 +1,50 @@ +{{define "base"}} + + + + + {{.Title}} + + + + + + + + +{{if .Username}} + +{{end}} + +
+ {{if .Flash}}
{{.Flash}}
{{end}} + {{block "content" .}}{{end}} +
+ + + + +{{end}} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html new file mode 100644 index 0000000..75a20c5 --- /dev/null +++ b/internal/web/templates/dashboard.html @@ -0,0 +1,94 @@ +{{define "content"}} + + +{{with .Data}} + +{{/* --- Service Status --- */}} +
+
+
+
+ service-status.sh +
+
+ {{if .Monitors}} + {{range .Monitors}} +
+ + {{if .Up}}[OK]{{else}}[!!]{{end}} + + {{.Label}} + + + {{if .Up}}up{{else}}down{{end}} +  ·  + {{pct .Uptime30d}} 30d +  ·  + {{if .LastChecked.IsZero}}never checked{{else}}{{ago .LastChecked}}{{end}} + +
+ {{end}} + {{else}} +

No services configured. Contact support to get services added to your account.

+ {{end}} +
+
+
+ +{{/* --- SSL Summary --- */}} +
+
+

SSL Certificates

+ manage → +
+ {{if .Domains}} +
+ {{range .Domains}} +
+ {{.Domain}} + {{if .IsValid}} + {{.DaysRemaining}}d + expires {{formatDate .ExpiresAt}} + {{else if .CheckError}} + {{.CheckError}} + {{else}} + not yet checked + {{end}} +
+ {{end}} +
+ {{else}} +

Add a domain to track SSL expiry.

+ {{end}} +
+ +{{/* --- Recent Tickets --- */}} +
+
+

Support Tickets

+ view all → +
+ {{if .Tickets}} + + + + {{range .Tickets}} + + + + + + + {{end}} + +
#subjectstatusupdated
#{{.ID}}{{.Subject}}{{.Status}}{{ago .UpdatedAt}}
+ {{else}} +

No tickets. Open one if you need help.

+ {{end}} +
+ +{{end}} +{{end}} diff --git a/internal/web/templates/forgot.html b/internal/web/templates/forgot.html new file mode 100644 index 0000000..8023cfb --- /dev/null +++ b/internal/web/templates/forgot.html @@ -0,0 +1,32 @@ +{{define "content"}} +
+
+
+
+ + + +
+ reset-password +
+
+ + + {{with .Data}}{{if .Error}}{{end}} + {{if .Success}}{{end}}{{end}} + + + +
+
+
+{{end}} diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html new file mode 100644 index 0000000..d1eb58e --- /dev/null +++ b/internal/web/templates/login.html @@ -0,0 +1,42 @@ +{{define "content"}} +
+
+
+
+ arcline-portal +
+
+ + + + + {{if .Data}}{{with .Data}} + {{if .Error}}{{end}} + {{end}}{{end}} + + + +
+
+
+{{end}} diff --git a/internal/web/templates/reset.html b/internal/web/templates/reset.html new file mode 100644 index 0000000..44d45d9 --- /dev/null +++ b/internal/web/templates/reset.html @@ -0,0 +1,32 @@ +{{define "content"}} +
+
+
+
+ + + +
+ set-new-password +
+
+ {{with .Data}}{{if .Error}}{{end}}{{end}} + + +
+
+
+{{end}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html new file mode 100644 index 0000000..1419ac0 --- /dev/null +++ b/internal/web/templates/settings.html @@ -0,0 +1,56 @@ +{{define "content"}} + + +
+
+
+
+ + + +
+ account-settings +
+
+ + {{with .Data}}{{if .Error}}{{end}}{{end}} + +

Email Address

+ + +
+ +

Change Password

+ + +
+
+
+{{end}} diff --git a/internal/web/templates/ssl.html b/internal/web/templates/ssl.html new file mode 100644 index 0000000..3022caa --- /dev/null +++ b/internal/web/templates/ssl.html @@ -0,0 +1,71 @@ +{{define "content"}} + + +
+
+ + +
+
+ +
+ {{with .Data}} + {{if .}} + + + + + + + + + + + + + {{range .}} + + + + + + + + + {{end}} + +
domainstatusexpiresdayslast checked
{{.Domain}} + {{if .IsValid}} + {{if gt .DaysRemaining 30}}OK + {{else if ge .DaysRemaining 14}}EXPIRING + {{else}}CRITICAL + {{end}} + {{else if .CheckError}} + ERROR + {{else}} + PENDING + {{end}} + {{if .IsValid}}{{formatDate .ExpiresAt}}{{else}}—{{end}} + {{if .IsValid}}{{.DaysRemaining}}d + {{else if .CheckError}}error + {{else}}—{{end}} + + {{if .LastCheckedAt.IsZero}}never{{else}}{{ago .LastCheckedAt}}{{end}} + +
+ + +
+
+ {{else}} +

No domains yet. Add one above to start tracking SSL expiry.

+ {{end}} + {{end}} +
+{{end}} diff --git a/internal/web/templates/ticket.html b/internal/web/templates/ticket.html new file mode 100644 index 0000000..b9e5cd0 --- /dev/null +++ b/internal/web/templates/ticket.html @@ -0,0 +1,43 @@ +{{define "content"}} +{{with .Data}} + + +
+
+ {{range .Messages}} +
+
+ {{if .FromAdmin}}arcline support{{else}}you{{end}} + {{formatTime .CreatedAt}} +
+
{{.Body}}
+
+ {{end}} +
+ + {{if ne (print .Ticket.Status) "closed"}} +
+
+ + +
+
+ + {{if $.IsAdmin}} + + {{end}} +
+
+ {{else}} +

This ticket is closed.

+ {{end}} +
+{{end}} +{{end}} diff --git a/internal/web/templates/tickets.html b/internal/web/templates/tickets.html new file mode 100644 index 0000000..a3fd868 --- /dev/null +++ b/internal/web/templates/tickets.html @@ -0,0 +1,55 @@ +{{define "content"}} + + +
+
+
+
+ new-ticket +
+
+
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+

Your Tickets

+ {{with .Data}} + {{if .}} + + + + + + {{range .}} + + + + + + + {{end}} + +
#subjectstatusupdated
#{{.ID}}{{.Subject}}{{.Status}}{{ago .UpdatedAt}}
+ {{else}} +

No tickets yet.

+ {{end}} + {{end}} +
+{{end}} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f62f87c --- /dev/null +++ b/main.go @@ -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) + } + } +} diff --git a/static/css/portal.css b/static/css/portal.css new file mode 100644 index 0000000..7645700 --- /dev/null +++ b/static/css/portal.css @@ -0,0 +1,589 @@ +/* ============================================================ + ARCLINE PORTAL — extends the Arcline terminal design system + Tokens and base components mirror arclineit.com/css/style.css + ============================================================ */ + +/* --- Reset --- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; font-size: 16px; -webkit-text-size-adjust: 100%; } +img, svg { display: block; max-width: 100%; } +a { color: inherit; text-decoration: none; } +ul, ol { list-style: none; } +button { cursor: pointer; border: none; background: none; font: inherit; } +input, textarea, select { font: inherit; } + +/* --- Design Tokens (identical to website) --- */ +:root { + --bg: #060b10; + --surface: #0c1319; + --surface-2: #121c25; + --border: #1c2a34; + --border-bright: #27394a; + --border-dim: #111c24; + + --cyan: #00c8f0; + --cyan-dim: #0090b8; + --cyan-bg: rgba(0, 200, 240, 0.06); + --cyan-border: rgba(0, 200, 240, 0.2); + + --text: #b8cdd8; + --text-dim: #456070; + --text-bright: #e0eff8; + --text-code: #7ab8d0; + + --ok: #00c8f0; + --warn: #f0a020; + --err: #e05050; + + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace; + --font-size-xs: 0.6875rem; + --font-size-sm: 0.75rem; + --font-size-md: 0.875rem; + --font-size-base: 0.9375rem; + --font-size-lg: 1.0625rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.75rem; + + --radius: 2px; + --nav-h: 60px; + --max-w: 1100px; + --t: 0.15s ease; + --t-slow: 0.25s ease; +} + +/* --- Base --- */ +body { + font-family: var(--font-mono); + background: var(--bg); + color: var(--text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +/* ============================================================ + CURSOR + ============================================================ */ +@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } +.cursor::after { + content: '▋'; + color: var(--cyan); + animation: blink 1s step-end infinite; + font-size: 0.85em; +} + +/* ============================================================ + NAVIGATION + ============================================================ */ +.nav { + position: sticky; + top: 0; + z-index: 100; + height: var(--nav-h); + background: var(--bg); + border-bottom: 1px solid var(--border); +} + +.nav__inner { + display: flex; + align-items: center; + height: var(--nav-h); + max-width: var(--max-w); + margin: 0 auto; + padding: 0 2rem; + gap: 1.5rem; +} + +.nav__logo { + display: flex; + align-items: center; + gap: 0.625rem; + flex-shrink: 0; + font-size: var(--font-size-base); + font-weight: 700; + color: var(--text-bright); + letter-spacing: 0.02em; + transition: color var(--t); +} +.nav__logo:hover { color: var(--cyan); } +.nav__logo-bracket { color: var(--cyan); } + +.nav__links { + display: flex; + align-items: center; + gap: 0.25rem; + margin-left: 1.5rem; +} + +.nav__link { + padding: 0.375rem 0.75rem; + font-size: var(--font-size-md); + color: var(--text-dim); + border: 1px solid transparent; + border-radius: var(--radius); + transition: color var(--t), border-color var(--t), background var(--t); +} +.nav__link:hover { + color: var(--text-bright); + border-color: var(--border); + background: var(--surface); +} +.nav__link--active { color: var(--cyan); } +.nav__link--admin { color: #9060e0; } +.nav__link--admin:hover { color: #b080f8; border-color: var(--border); background: var(--surface); } + +.nav__right { + display: flex; + align-items: center; + gap: 1rem; + margin-left: auto; +} + +.nav__user { + font-size: var(--font-size-sm); + color: var(--text-dim); +} + +/* ============================================================ + BUTTONS + ============================================================ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font-family: var(--font-mono); + font-weight: 600; + border-radius: var(--radius); + transition: all var(--t); + white-space: nowrap; + cursor: pointer; + letter-spacing: 0.01em; + text-decoration: none; +} + +.btn--sm { padding: 0.375rem 0.875rem; font-size: var(--font-size-sm); } +.btn--md { padding: 0.5rem 1.25rem; font-size: var(--font-size-md); } +.btn--full { width: 100%; padding: 0.6rem; font-size: var(--font-size-md); } + +.btn--primary { + background: var(--cyan); + color: #040a0e; + border: 1px solid var(--cyan); +} +.btn--primary:hover { + background: #29d5f8; + border-color: #29d5f8; + transform: translateY(-1px); +} + +.btn--ghost { + background: transparent; + color: var(--cyan); + border: 1px solid var(--cyan-border); +} +.btn--ghost:hover { + border-color: var(--cyan); + background: var(--cyan-bg); + transform: translateY(-1px); +} + +.btn--muted { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} +.btn--muted:hover { + border-color: var(--border-bright); + background: var(--surface); + color: var(--text-bright); +} + +.btn-link { + background: none; + border: none; + font-family: var(--font-mono); + font-size: var(--font-size-sm); + cursor: pointer; + padding: 0; +} +.btn-link--danger { color: var(--err); } +.btn-link--danger:hover { text-decoration: underline; } + +/* ============================================================ + TERMINAL WINDOW + ============================================================ */ +.term-window { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface); +} +.term-window--narrow { max-width: 680px; } + +.term-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.875rem; + height: 32px; + background: var(--surface-2); + border-bottom: 1px solid var(--border); +} + +.term-controls { + display: flex; + align-items: center; + gap: 3px; +} + +.term-btn { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + color: var(--text-dim); + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: default; + font-family: var(--font-mono); + padding: 0; + user-select: none; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} +.term-btn:hover { background: var(--surface); color: var(--text); } +.term-btn--close:hover { background: #a03030; border-color: #a03030; color: #fff; } + +.term-title { + font-size: var(--font-size-xs); + color: var(--text-dim); + letter-spacing: 0.04em; +} + +.term-body { + padding: 1.25rem 1.5rem; + font-size: var(--font-size-md); + line-height: 1.7; +} + +.term-prompt { + color: var(--text-dim); + margin-bottom: 0.625rem; + font-size: var(--font-size-sm); +} + +.term-empty { + color: var(--text-dim); + font-size: var(--font-size-md); +} + +/* ============================================================ + STATUS LINES (mirrors .status-line / .sl-* from website) + ============================================================ */ +.status-line { + display: flex; + align-items: baseline; + gap: 0.75rem; + font-size: var(--font-size-md); + line-height: 2; +} +.sl-ok { color: var(--ok); flex-shrink: 0; font-weight: 700; min-width: 4.5ch; } +.sl-warn { color: var(--warn); flex-shrink: 0; font-weight: 700; min-width: 4.5ch; } +.sl-err { color: var(--err); flex-shrink: 0; font-weight: 700; min-width: 4.5ch; } +.sl-label { color: var(--text); flex-shrink: 0; } +.sl-fill { flex: 1; border-bottom: 1px dotted var(--border-bright); margin-bottom: 0.35em; min-width: 1rem; } +.sl-value { color: var(--cyan); flex-shrink: 0; font-weight: 700; } + +/* ============================================================ + LAYOUT + ============================================================ */ +.main { + max-width: var(--max-w); + margin: 0 auto; + padding: 2.5rem 2rem 5rem; +} + +.section { margin-bottom: 2.5rem; } +.section--alt { background: var(--surface); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } + +.section__header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 1rem; +} + +.section__title { + font-size: var(--font-size-base); + font-weight: 500; + color: var(--text-bright); + letter-spacing: 0.03em; +} + +/* ============================================================ + PAGE HEADER + ============================================================ */ +.page-header { + margin-bottom: 2rem; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--border); +} + +.page-header__label { + font-size: var(--font-size-xs); + color: var(--text-dim); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 0.3rem; +} + +.page-header__title { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--text-bright); + letter-spacing: -0.02em; + margin-bottom: 0.25rem; +} + +.page-header__sub { + font-size: var(--font-size-md); + color: var(--text-dim); +} + +/* ============================================================ + TABLE + ============================================================ */ +.table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-md); +} + +.table th { + text-align: left; + color: var(--text-dim); + font-weight: 400; + font-size: var(--font-size-xs); + letter-spacing: 0.08em; + text-transform: uppercase; + border-bottom: 1px solid var(--border); + padding: 0.4rem 0.75rem; +} + +.table td { + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--border-dim); + color: var(--text); + vertical-align: middle; +} +.table tr:last-child td { border-bottom: none; } +.table tr:hover td { background: var(--surface); } +.td-mono { font-family: var(--font-mono); } + +/* ============================================================ + BADGES + ============================================================ */ +.badge { + display: inline-block; + font-size: var(--font-size-xs); + font-weight: 700; + letter-spacing: 0.06em; + padding: 0.15rem 0.5rem; + border-radius: var(--radius); + text-transform: uppercase; +} + +.badge--ok { color: var(--ok); border: 1px solid rgba(0,200,240,0.25); background: rgba(0,200,240,0.08); } +.badge--warn { color: var(--warn); border: 1px solid rgba(240,160,32,0.25); background: rgba(240,160,32,0.08); } +.badge--err { color: var(--err); border: 1px solid rgba(224,80,80,0.25); background: rgba(224,80,80,0.08); } +.badge--dim { color: var(--text-dim); border: 1px solid var(--border); background: var(--surface-2); } +.badge--admin { color: #b080f8; border: 1px solid rgba(144,96,224,0.25); background: rgba(144,96,224,0.08); } +.badge--open { color: var(--cyan); border: 1px solid var(--cyan-border); background: var(--cyan-bg); } +.badge--in_progress { color: var(--warn); border: 1px solid rgba(240,160,32,0.25); background: rgba(240,160,32,0.08); } +.badge--closed { color: var(--text-dim); border: 1px solid var(--border); background: var(--surface-2); } + +/* ============================================================ + SSL CARDS + ============================================================ */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.ssl-card { + padding: 0.875rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + display: flex; + flex-direction: column; + gap: 0.2rem; +} +.ssl-card--ok { border-left: 3px solid var(--ok); } +.ssl-card--warn { border-left: 3px solid var(--warn); } +.ssl-card--crit { border-left: 3px solid var(--err); } + +.ssl-domain { color: var(--text-bright); font-size: var(--font-size-md); } +.ssl-days { font-size: var(--font-size-xl); font-weight: 700; color: var(--cyan); } +.ssl-exp { font-size: var(--font-size-xs); color: var(--text-dim); } +.ssl-err { font-size: var(--font-size-xs); color: var(--err); } + +/* ============================================================ + FORMS + ============================================================ */ +.field { display: flex; flex-direction: column; gap: 0.3rem; } + +.field__label { + font-size: var(--font-size-xs); + color: var(--text-dim); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.field__input, +.field__textarea { + background: var(--bg); + border: 1px solid var(--border-bright); + border-radius: var(--radius); + color: var(--text); + font-family: var(--font-mono); + font-size: var(--font-size-md); + padding: 0.5rem 0.75rem; + outline: none; + transition: border-color var(--t); + width: 100%; +} +.field__input:focus, +.field__textarea:focus { border-color: var(--cyan); } +.field__textarea { resize: vertical; min-height: 96px; } + +.inline-form { + display: flex; + align-items: flex-end; + gap: 0.75rem; + flex-wrap: wrap; +} +.inline-form .field__input { max-width: 340px; } + +.ticket-form, +.admin-form { display: flex; flex-direction: column; gap: 1rem; } + +.form-row { + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: flex-end; +} +.form-row .field { flex: 1; min-width: 140px; } +.field--check { justify-content: flex-end; } + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: var(--font-size-sm); + color: var(--text-dim); + cursor: pointer; +} + +/* ============================================================ + LOGIN PAGE + ============================================================ */ +.login-wrap { + min-height: calc(100vh - var(--nav-h)); + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1rem; +} + +.login-box { width: 100%; max-width: 420px; } + +.login-logo { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.login-wordmark { + font-size: var(--font-size-xl); + font-weight: 700; + color: var(--text-bright); +} + +.login-prompt { + font-size: var(--font-size-sm); + color: var(--text-dim); + margin-bottom: 1.5rem; +} + +.login-form { display: flex; flex-direction: column; gap: 1rem; } + +.login-error { + color: var(--err); + font-size: var(--font-size-sm); + margin-bottom: 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid rgba(224,80,80,0.3); + border-radius: var(--radius); + background: rgba(224,80,80,0.06); +} + +/* ============================================================ + TICKET THREAD + ============================================================ */ +.thread { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; } + +.message { + padding: 0.875rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); +} +.message--admin { border-left: 3px solid var(--cyan); background: var(--surface); } +.message--client { border-left: 3px solid var(--border-bright); background: var(--bg); } + +.message__meta { + display: flex; + justify-content: space-between; + margin-bottom: 0.4rem; +} +.message__from { + font-size: var(--font-size-xs); + color: var(--text-dim); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.message__time { font-size: var(--font-size-xs); color: var(--text-dim); } +.message__body { font-size: var(--font-size-md); white-space: pre-wrap; } + +.reply-form { display: flex; flex-direction: column; gap: 0.75rem; } +.reply-actions { display: flex; align-items: center; gap: 1rem; } + +/* ============================================================ + FLASH + ============================================================ */ +.flash { + margin-bottom: 1.25rem; + padding: 0.5rem 0.875rem; + font-size: var(--font-size-sm); + border: 1px solid rgba(240,160,32,0.25); + border-radius: var(--radius); + color: var(--warn); + background: rgba(240,160,32,0.06); +} + +/* ============================================================ + UTILITY + ============================================================ */ +.text-dim { color: var(--text-dim); } +.text-bright { color: var(--text-bright); } +.text-cyan { color: var(--cyan); } +.text-err { color: var(--err); } +.muted { color: var(--text-dim); font-size: var(--font-size-md); } +.link { color: var(--cyan); } +.link:hover { text-decoration: underline; } diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..1e8d04b --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/js/portal.js b/static/js/portal.js new file mode 100644 index 0000000..3eeab8e --- /dev/null +++ b/static/js/portal.js @@ -0,0 +1,19 @@ +'use strict'; + +// Auto-refresh the dashboard every 60 seconds if on the dashboard page. +if (window.location.pathname === '/dashboard') { + setTimeout(() => window.location.reload(), 60_000); +} + +// Decode URL-encoded flash messages (redirect-after-post pattern puts them in query string). +const flashEl = document.querySelector('.flash'); +if (flashEl) { + flashEl.textContent = decodeURIComponent(flashEl.textContent.replace(/\+/g, ' ')); +} + +// Confirm-before-submit for any element with data-confirm attribute. +document.querySelectorAll('[data-confirm]').forEach(el => { + el.addEventListener('click', e => { + if (!confirm(el.dataset.confirm)) e.preventDefault(); + }); +}); diff --git a/todo.md b/todo.md index 1f01327..ae709fe 100644 --- a/todo.md +++ b/todo.md @@ -17,10 +17,10 @@ log viewer. Sits alongside or integrates with WHMCS for billing. - Dashboard shows all domains with expiry date + days remaining - Color coding: green >30d, amber 14-30d, red <14d - Email alerts: 30d, 14d, 7d before expiry -- [ ] Domain management (add/remove/verify ownership via DNS TXT) -- [ ] Background cert checker (goroutine + ticker) -- [ ] Alert email templates -- [ ] Dashboard view +- [x] Domain management (add/remove) — ownership verify via DNS TXT not implemented +- [x] Background cert checker (goroutine + ticker) +- [ ] Alert email templates (30/14/7 day notifications not wired up) +- [x] Dashboard view ### 2. One-Click Static Deployment - Customer connects GitLab repo (OAuth) or uploads a zip @@ -47,23 +47,23 @@ log viewer. Sits alongside or integrates with WHMCS for billing. - Simple ticket system (open, in-progress, closed) - Customer creates ticket → email notification to blake@arclineit.com - Blake replies via email → reply appears in ticket thread -- [ ] Ticket CRUD +- [x] Ticket CRUD - [ ] Email-in (IMAP polling or inbound SMTP hook) -- [ ] Email-out (SMTP on ticket create/reply) -- [ ] Ticket list + thread view +- [x] Email-out (SMTP on ticket create/reply) +- [x] Ticket list + thread view ## Auth -- [ ] Register / login / logout -- [ ] Password reset (email link, 1h expiry) +- [x] Register / login / logout +- [x] Password reset (email link, 1h expiry) - [ ] TOTP 2FA (optional, QR code enrollment) -- [ ] Session management (secure cookie, server-side store) +- [x] Session management (secure cookie, server-side store) ## Tasks (phase 1 — MVP) -- [ ] Project scaffold (Go + embedded FS for templates/assets) -- [ ] Database schema (users, domains, deployments, tickets, sessions) -- [ ] Auth system (register, login, sessions, password reset) -- [ ] SSL dashboard (domain add/verify, cert check, expiry display) -- [ ] Basic ticket system -- [ ] Arcline design system applied to all views -- [ ] systemd unit + nginx reverse proxy config -- [ ] README: deployment guide, env vars reference +- [x] Project scaffold (Go + embedded FS for templates/assets) +- [x] Database schema (users, domains, deployments, tickets, sessions) +- [x] Auth system (register, login, sessions, password reset) +- [x] SSL dashboard (domain add, cert check, expiry display) — DNS TXT verify pending +- [x] Basic ticket system +- [x] Arcline design system applied to all views +- [x] systemd unit + nginx reverse proxy config +- [x] README: deployment guide, env vars reference