feat: MVP phase 1 complete
This commit is contained in:
27
.env.example
Normal file
27
.env.example
Normal file
@@ -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
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
arcline-portal
|
||||
arcline-portal-linux-amd64
|
||||
arcline-portal-linux-arm64
|
||||
*.db
|
||||
.env
|
||||
*.test
|
||||
*.out
|
||||
26
Makefile
Normal file
26
Makefile
Normal file
@@ -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
|
||||
111
README.md
111
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
|
||||
|
||||
|
||||
23
deploy/arcline-portal.service
Normal file
23
deploy/arcline-portal.service
Normal file
@@ -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
|
||||
29
deploy/nginx-portal.conf
Normal file
29
deploy/nginx-portal.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
20
go.mod
Normal file
20
go.mod
Normal file
@@ -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
|
||||
)
|
||||
53
go.sum
Normal file
53
go.sum
Normal file
@@ -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=
|
||||
107
internal/auth/auth.go
Normal file
107
internal/auth/auth.go
Normal file
@@ -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
|
||||
}
|
||||
605
internal/db/db.go
Normal file
605
internal/db/db.go
Normal file
@@ -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
|
||||
}
|
||||
124
internal/mail/mail.go
Normal file
124
internal/mail/mail.go
Normal file
@@ -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())
|
||||
}
|
||||
78
internal/ssl/checker.go
Normal file
78
internal/ssl/checker.go
Normal file
@@ -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
|
||||
}
|
||||
107
internal/uptime/reader.go
Normal file
107
internal/uptime/reader.go
Normal file
@@ -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
|
||||
}
|
||||
575
internal/web/handler.go
Normal file
575
internal/web/handler.go
Normal file
@@ -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))
|
||||
}
|
||||
74
internal/web/render.go
Normal file
74
internal/web/render.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
22
internal/web/templates/404.html
Normal file
22
internal/web/templates/404.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{{define "content"}}
|
||||
<div class="login-wrap">
|
||||
<div class="term-window login-box">
|
||||
<div class="term-header">
|
||||
<div class="term-controls">
|
||||
<button class="term-btn term-btn--close">✕</button>
|
||||
<button class="term-btn">−</button>
|
||||
<button class="term-btn">□</button>
|
||||
</div>
|
||||
<span class="term-title">404</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<p class="login-prompt">$ find / -name "{{"{{"}}/* path not found */}}"</p>
|
||||
<p style="font-size:var(--font-size-2xl);font-weight:700;color:var(--text-bright);margin:.5rem 0">404</p>
|
||||
<p style="color:var(--text-dim);font-size:var(--font-size-md);margin-bottom:1.5rem">
|
||||
That page doesn't exist.
|
||||
</p>
|
||||
<a href="/dashboard" class="btn btn--ghost btn--sm">← back to dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
76
internal/web/templates/admin/client.html
Normal file
76
internal/web/templates/admin/client.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label"><a href="/admin" class="link">admin</a> / clients</p>
|
||||
<h1 class="page-header__title">{{.Client.DisplayName}}</h1>
|
||||
<p class="text-dim td-mono">@{{.Client.Username}}</p>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<div class="section__header">
|
||||
<h2 class="section__title">Service Monitors</h2>
|
||||
</div>
|
||||
<p class="muted" style="margin-bottom:1rem">
|
||||
Monitor names must match exactly what's configured in arcline-uptime.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="/admin/clients/{{.Client.ID}}/monitors/add" class="inline-form">
|
||||
<input class="field__input" type="text" name="monitor_name"
|
||||
placeholder="monitor name (from arcline-uptime)" required>
|
||||
<input class="field__input" type="text" name="label"
|
||||
placeholder="display label (optional)">
|
||||
<button type="submit" class="btn btn--primary btn--sm">+ add monitor</button>
|
||||
</form>
|
||||
|
||||
{{if .Monitors}}
|
||||
<table class="table" style="margin-top:1rem">
|
||||
<thead><tr><th>monitor name</th><th>label</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Monitors}}
|
||||
<tr>
|
||||
<td class="td-mono">{{.MonitorName}}</td>
|
||||
<td class="text-dim">{{if .Label}}{{.Label}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/clients/{{$.Data.Client.ID}}/monitors/delete" style="display:inline">
|
||||
<input type="hidden" name="monitor_id" value="{{.ID}}">
|
||||
<button type="submit" class="btn-link btn-link--danger">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No monitors assigned.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section__title">Domains</h2>
|
||||
{{if .Domains}}
|
||||
<table class="table">
|
||||
<thead><tr><th>domain</th><th>expires</th><th>days</th><th>status</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Domains}}
|
||||
<tr>
|
||||
<td class="td-mono">{{.Domain}}</td>
|
||||
<td class="text-dim">{{if .IsValid}}{{formatDate .ExpiresAt}}{{else}}—{{end}}</td>
|
||||
<td class="td-mono">{{if .IsValid}}{{.DaysRemaining}}d{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{if .IsValid}}
|
||||
{{if gt .DaysRemaining 30}}<span class="badge badge--ok">OK</span>
|
||||
{{else if ge .DaysRemaining 14}}<span class="badge badge--warn">EXPIRING</span>
|
||||
{{else}}<span class="badge badge--err">CRITICAL</span>{{end}}
|
||||
{{else if .CheckError}}<span class="badge badge--err">ERROR</span>
|
||||
{{else}}<span class="badge badge--dim">PENDING</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No domains tracked for this client.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
97
internal/web/templates/admin/index.html
Normal file
97
internal/web/templates/admin/index.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label">admin</p>
|
||||
<h1 class="page-header__title">Admin Overview</h1>
|
||||
</div>
|
||||
|
||||
{{with .Data}}
|
||||
|
||||
<section class="section">
|
||||
<div class="section__header">
|
||||
<h2 class="section__title">Clients</h2>
|
||||
</div>
|
||||
|
||||
<div class="term-window term-window--narrow">
|
||||
<div class="term-header">
|
||||
<div class="term-controls"><button class="term-btn term-btn--close">✕</button><button class="term-btn">−</button><button class="term-btn">□</button></div>
|
||||
<span class="term-title">new-client</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<form method="POST" action="/admin/clients/new" class="admin-form">
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">username</label>
|
||||
<input class="field__input" type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="display_name">display name</label>
|
||||
<input class="field__input" type="text" id="display_name" name="display_name" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">email</label>
|
||||
<input class="field__input" type="email" id="email" name="email">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">password</label>
|
||||
<input class="field__input" type="password" id="password" name="password" minlength="8" required>
|
||||
</div>
|
||||
<div class="field field--check">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="is_admin" value="1"> admin
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--sm">create client</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Clients}}
|
||||
<table class="table">
|
||||
<thead><tr><th>username</th><th>display name</th><th>role</th><th>joined</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Clients}}
|
||||
<tr>
|
||||
<td class="td-mono">{{.Username}}</td>
|
||||
<td><a href="/admin/clients/{{.ID}}" class="link">{{.DisplayName}}</a></td>
|
||||
<td>{{if .IsAdmin}}<span class="badge badge--admin">admin</span>{{else}}<span class="badge badge--dim">client</span>{{end}}</td>
|
||||
<td class="text-dim">{{formatDate .CreatedAt}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/clients/{{.ID}}/delete" style="display:inline">
|
||||
<button type="submit" class="btn-link btn-link--danger"
|
||||
onclick="return confirm('Delete {{.DisplayName}}? This cannot be undone.')">delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No clients yet.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section__title">All Tickets</h2>
|
||||
{{if .Tickets}}
|
||||
<table class="table">
|
||||
<thead><tr><th>#</th><th>subject</th><th>client</th><th>status</th><th>updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Tickets}}
|
||||
<tr>
|
||||
<td class="text-dim td-mono">#{{.ID}}</td>
|
||||
<td><a href="/tickets/{{.ID}}" class="link">{{.Subject}}</a></td>
|
||||
<td class="text-dim">{{.ClientName}}</td>
|
||||
<td><span class="badge badge--{{.Status}}">{{.Status}}</span></td>
|
||||
<td class="text-dim">{{ago .UpdatedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No tickets.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{end}}
|
||||
{{end}}
|
||||
50
internal/web/templates/base.html
Normal file
50
internal/web/templates/base.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{{define "base"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/portal.css">
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{{if .Username}}
|
||||
<nav class="nav">
|
||||
<div class="nav__inner">
|
||||
<a href="/dashboard" class="nav__logo">
|
||||
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<path d="M5 27L16 5L27 27" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 20H23" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span><span class="nav__logo-bracket">[</span>arcline<span class="nav__logo-bracket">]</span> portal</span>
|
||||
</a>
|
||||
<ul class="nav__links">
|
||||
<li><a href="/dashboard" class="nav__link{{if eq .Path "/dashboard"}} nav__link--active{{end}}">dashboard</a></li>
|
||||
<li><a href="/ssl" class="nav__link{{if eq .Path "/ssl"}} nav__link--active{{end}}">ssl</a></li>
|
||||
<li><a href="/tickets" class="nav__link{{if eq .Path "/tickets"}} nav__link--active{{end}}">tickets</a></li>
|
||||
{{if .IsAdmin}}<li><a href="/admin" class="nav__link nav__link--admin{{if eq .Path "/admin"}} nav__link--active{{end}}">admin</a></li>{{end}}
|
||||
</ul>
|
||||
<div class="nav__right">
|
||||
<a href="/settings" class="nav__link nav__link--settings{{if eq .Path "/settings"}} nav__link--active{{end}}">settings</a>
|
||||
<span class="nav__user">{{.Username}}</span>
|
||||
<form method="POST" action="/logout" style="display:inline">
|
||||
<button type="submit" class="btn btn--muted btn--sm">log out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
<main class="main">
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
|
||||
<script src="/static/js/portal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
94
internal/web/templates/dashboard.html
Normal file
94
internal/web/templates/dashboard.html
Normal file
@@ -0,0 +1,94 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label">overview</p>
|
||||
<h1 class="page-header__title">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
{{with .Data}}
|
||||
|
||||
{{/* --- Service Status --- */}}
|
||||
<section class="section">
|
||||
<div class="term-window">
|
||||
<div class="term-header">
|
||||
<div class="term-controls"><button class="term-btn term-btn--close">✕</button><button class="term-btn">−</button><button class="term-btn">□</button></div>
|
||||
<span class="term-title">service-status.sh</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
{{if .Monitors}}
|
||||
{{range .Monitors}}
|
||||
<div class="status-row">
|
||||
<span class="status-tag {{if .Up}}status-tag--ok{{else}}status-tag--err{{end}}">
|
||||
{{if .Up}}[OK]{{else}}[!!]{{end}}
|
||||
</span>
|
||||
<span class="status-name">{{.Label}}</span>
|
||||
<span class="status-dots"></span>
|
||||
<span class="status-meta">
|
||||
{{if .Up}}up{{else}}down{{end}}
|
||||
·
|
||||
{{pct .Uptime30d}} 30d
|
||||
·
|
||||
{{if .LastChecked.IsZero}}never checked{{else}}{{ago .LastChecked}}{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="term-empty">No services configured. Contact support to get services added to your account.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{/* --- SSL Summary --- */}}
|
||||
<section class="section">
|
||||
<div class="section__header">
|
||||
<h2 class="section__title">SSL Certificates</h2>
|
||||
<a href="/ssl" class="btn btn--ghost btn--sm">manage →</a>
|
||||
</div>
|
||||
{{if .Domains}}
|
||||
<div class="card-grid">
|
||||
{{range .Domains}}
|
||||
<div class="ssl-card {{if .IsValid}}{{if gt .DaysRemaining 30}}ssl-card--ok{{else if ge .DaysRemaining 14}}ssl-card--warn{{else}}ssl-card--crit{{end}}{{else}}ssl-card--crit{{end}}">
|
||||
<span class="ssl-domain">{{.Domain}}</span>
|
||||
{{if .IsValid}}
|
||||
<span class="ssl-days">{{.DaysRemaining}}d</span>
|
||||
<span class="ssl-exp">expires {{formatDate .ExpiresAt}}</span>
|
||||
{{else if .CheckError}}
|
||||
<span class="ssl-err">{{.CheckError}}</span>
|
||||
{{else}}
|
||||
<span class="ssl-err">not yet checked</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="muted"><a href="/ssl">Add a domain</a> to track SSL expiry.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{/* --- Recent Tickets --- */}}
|
||||
<section class="section">
|
||||
<div class="section__header">
|
||||
<h2 class="section__title">Support Tickets</h2>
|
||||
<a href="/tickets" class="btn btn--ghost btn--sm">view all →</a>
|
||||
</div>
|
||||
{{if .Tickets}}
|
||||
<table class="table">
|
||||
<thead><tr><th>#</th><th>subject</th><th>status</th><th>updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Tickets}}
|
||||
<tr>
|
||||
<td class="text-dim">#{{.ID}}</td>
|
||||
<td><a href="/tickets/{{.ID}}" class="link">{{.Subject}}</a></td>
|
||||
<td><span class="badge badge--{{.Status}}">{{.Status}}</span></td>
|
||||
<td class="text-dim">{{ago .UpdatedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No tickets. <a href="/tickets" class="link">Open one</a> if you need help.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{end}}
|
||||
{{end}}
|
||||
32
internal/web/templates/forgot.html
Normal file
32
internal/web/templates/forgot.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{define "content"}}
|
||||
<div class="login-wrap">
|
||||
<div class="term-window login-box">
|
||||
<div class="term-header">
|
||||
<div class="term-controls">
|
||||
<button class="term-btn term-btn--close">✕</button>
|
||||
<button class="term-btn">−</button>
|
||||
<button class="term-btn">□</button>
|
||||
</div>
|
||||
<span class="term-title">reset-password</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<p class="login-prompt">Enter the email address on your account and we'll send a reset link.</p>
|
||||
|
||||
{{with .Data}}{{if .Error}}<p class="login-error">{{.Error}}</p>{{end}}
|
||||
{{if .Success}}<p class="login-success">{{.Success}}</p>{{end}}{{end}}
|
||||
|
||||
<form method="POST" action="/forgot" class="login-form">
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">email address</label>
|
||||
<input class="field__input" type="email" id="email" name="email"
|
||||
autocomplete="email" autofocus required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--full">send reset link</button>
|
||||
</form>
|
||||
<p class="login-prompt" style="margin-top:1rem">
|
||||
<a href="/login" class="link">← back to login</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
42
internal/web/templates/login.html
Normal file
42
internal/web/templates/login.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{{define "content"}}
|
||||
<div class="login-wrap">
|
||||
<div class="term-window login-box">
|
||||
<div class="term-header">
|
||||
<div class="term-controls"><button class="term-btn term-btn--close">✕</button><button class="term-btn">−</button><button class="term-btn">□</button></div>
|
||||
<span class="term-title">arcline-portal</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<div class="login-logo">
|
||||
<svg width="40" height="40" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<path d="M5 27L16 5L27 27" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 20H23" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span class="login-wordmark"><span class="text-dim">[</span>arcline<span class="text-dim">]</span></span>
|
||||
</div>
|
||||
|
||||
<p class="login-prompt">$ ssh client@portal.arclineit.com</p>
|
||||
|
||||
{{if .Data}}{{with .Data}}
|
||||
{{if .Error}}<p class="login-error">{{.Error}}</p>{{end}}
|
||||
{{end}}{{end}}
|
||||
|
||||
<form method="POST" action="/login" class="login-form">
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">username</label>
|
||||
<input class="field__input" type="text" id="username" name="username"
|
||||
autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">password</label>
|
||||
<input class="field__input" type="password" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--full">authenticate<span class="cursor">▋</span></button>
|
||||
</form>
|
||||
<p class="login-prompt" style="margin-top:1rem">
|
||||
<a href="/forgot" class="link">forgot password?</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
32
internal/web/templates/reset.html
Normal file
32
internal/web/templates/reset.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{define "content"}}
|
||||
<div class="login-wrap">
|
||||
<div class="term-window login-box">
|
||||
<div class="term-header">
|
||||
<div class="term-controls">
|
||||
<button class="term-btn term-btn--close">✕</button>
|
||||
<button class="term-btn">−</button>
|
||||
<button class="term-btn">□</button>
|
||||
</div>
|
||||
<span class="term-title">set-new-password</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
{{with .Data}}{{if .Error}}<p class="login-error">{{.Error}}</p>{{end}}{{end}}
|
||||
|
||||
<form method="POST" action="/reset" class="login-form">
|
||||
<input type="hidden" name="token" value="{{with .Data}}{{.Token}}{{end}}">
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">new password</label>
|
||||
<input class="field__input" type="password" id="password" name="password"
|
||||
autocomplete="new-password" minlength="8" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="confirm">confirm password</label>
|
||||
<input class="field__input" type="password" id="confirm" name="confirm"
|
||||
autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--full">set new password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
56
internal/web/templates/settings.html
Normal file
56
internal/web/templates/settings.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label">account</p>
|
||||
<h1 class="page-header__title">Settings</h1>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<div class="term-window term-window--narrow">
|
||||
<div class="term-header">
|
||||
<div class="term-controls">
|
||||
<button class="term-btn term-btn--close">✕</button>
|
||||
<button class="term-btn">−</button>
|
||||
<button class="term-btn">□</button>
|
||||
</div>
|
||||
<span class="term-title">account-settings</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
|
||||
{{with .Data}}{{if .Error}}<p class="login-error" style="margin-bottom:1rem">{{.Error}}</p>{{end}}{{end}}
|
||||
|
||||
<p class="section__title" style="margin-bottom:1rem">Email Address</p>
|
||||
<form method="POST" action="/settings/email" class="login-form" style="margin-bottom:2rem">
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">email</label>
|
||||
<input class="field__input" type="email" id="email" name="email"
|
||||
value="{{with .Data}}{{.Email}}{{end}}" autocomplete="email" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--sm">update email</button>
|
||||
</form>
|
||||
|
||||
<hr style="border:none;border-top:1px solid var(--border);margin-bottom:1.5rem">
|
||||
|
||||
<p class="section__title" style="margin-bottom:1rem">Change Password</p>
|
||||
<form method="POST" action="/settings/password" class="login-form">
|
||||
<div class="field">
|
||||
<label class="field__label" for="current">current password</label>
|
||||
<input class="field__input" type="password" id="current" name="current"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">new password</label>
|
||||
<input class="field__input" type="password" id="password" name="password"
|
||||
autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="confirm">confirm new password</label>
|
||||
<input class="field__input" type="password" id="confirm" name="confirm"
|
||||
autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--sm">change password</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
71
internal/web/templates/ssl.html
Normal file
71
internal/web/templates/ssl.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label">monitoring</p>
|
||||
<h1 class="page-header__title">SSL Certificates</h1>
|
||||
<p class="page-header__sub">Cert expiry is checked daily. Add a domain to start tracking.</p>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<form method="POST" action="/ssl/add" class="inline-form">
|
||||
<input class="field__input" type="text" name="domain"
|
||||
placeholder="example.com" autocomplete="off" required>
|
||||
<button type="submit" class="btn btn--primary btn--sm">+ add domain</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
{{with .Data}}
|
||||
{{if .}}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>domain</th>
|
||||
<th>status</th>
|
||||
<th>expires</th>
|
||||
<th>days</th>
|
||||
<th>last checked</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .}}
|
||||
<tr>
|
||||
<td class="td-mono">{{.Domain}}</td>
|
||||
<td>
|
||||
{{if .IsValid}}
|
||||
{{if gt .DaysRemaining 30}}<span class="badge badge--ok">OK</span>
|
||||
{{else if ge .DaysRemaining 14}}<span class="badge badge--warn">EXPIRING</span>
|
||||
{{else}}<span class="badge badge--err">CRITICAL</span>
|
||||
{{end}}
|
||||
{{else if .CheckError}}
|
||||
<span class="badge badge--err">ERROR</span>
|
||||
{{else}}
|
||||
<span class="badge badge--dim">PENDING</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="td-mono">{{if .IsValid}}{{formatDate .ExpiresAt}}{{else}}—{{end}}</td>
|
||||
<td class="td-mono">
|
||||
{{if .IsValid}}{{.DaysRemaining}}d
|
||||
{{else if .CheckError}}<span class="text-err" title="{{.CheckError}}">error</span>
|
||||
{{else}}—{{end}}
|
||||
</td>
|
||||
<td class="text-dim">
|
||||
{{if .LastCheckedAt.IsZero}}never{{else}}{{ago .LastCheckedAt}}{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/ssl/delete" style="display:inline">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="btn-link btn-link--danger"
|
||||
onclick="return confirm('Remove {{.Domain}}?')">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No domains yet. Add one above to start tracking SSL expiry.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
43
internal/web/templates/ticket.html
Normal file
43
internal/web/templates/ticket.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label"><a href="/tickets" class="link">tickets</a> / #{{.Ticket.ID}}</p>
|
||||
<h1 class="page-header__title">{{.Ticket.Subject}}</h1>
|
||||
<span class="badge badge--{{.Ticket.Status}}">{{.Ticket.Status}}</span>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<div class="thread">
|
||||
{{range .Messages}}
|
||||
<div class="message {{if .FromAdmin}}message--admin{{else}}message--client{{end}}">
|
||||
<div class="message__meta">
|
||||
<span class="message__from">{{if .FromAdmin}}arcline support{{else}}you{{end}}</span>
|
||||
<span class="message__time text-dim">{{formatTime .CreatedAt}}</span>
|
||||
</div>
|
||||
<div class="message__body">{{.Body}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if ne (print .Ticket.Status) "closed"}}
|
||||
<form method="POST" action="/tickets/{{.Ticket.ID}}/reply" class="reply-form">
|
||||
<div class="field">
|
||||
<label class="field__label" for="body">reply</label>
|
||||
<textarea class="field__textarea" id="body" name="body" rows="4"
|
||||
placeholder="Add a message..." required></textarea>
|
||||
</div>
|
||||
<div class="reply-actions">
|
||||
<button type="submit" class="btn btn--primary btn--sm">send reply</button>
|
||||
{{if $.IsAdmin}}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="close" value="1"> close ticket after reply
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="muted">This ticket is closed.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
55
internal/web/templates/tickets.html
Normal file
55
internal/web/templates/tickets.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<p class="page-header__label">support</p>
|
||||
<h1 class="page-header__title">Tickets</h1>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<div class="term-window term-window--narrow">
|
||||
<div class="term-header">
|
||||
<div class="term-controls"><button class="term-btn term-btn--close">✕</button><button class="term-btn">−</button><button class="term-btn">□</button></div>
|
||||
<span class="term-title">new-ticket</span>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<form method="POST" action="/tickets/new" class="ticket-form">
|
||||
<div class="field">
|
||||
<label class="field__label" for="subject">subject</label>
|
||||
<input class="field__input" type="text" id="subject" name="subject"
|
||||
placeholder="Brief description of the issue" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="body">message</label>
|
||||
<textarea class="field__textarea" id="body" name="body" rows="5"
|
||||
placeholder="Describe the issue in detail..." required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--sm">open ticket</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section__title">Your Tickets</h2>
|
||||
{{with .Data}}
|
||||
{{if .}}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>subject</th><th>status</th><th>updated</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .}}
|
||||
<tr>
|
||||
<td class="text-dim td-mono">#{{.ID}}</td>
|
||||
<td><a href="/tickets/{{.ID}}" class="link">{{.Subject}}</a></td>
|
||||
<td><span class="badge badge--{{.Status}}">{{.Status}}</span></td>
|
||||
<td class="text-dim">{{ago .UpdatedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No tickets yet.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
257
main.go
Normal file
257
main.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arclineit/arcline-portal/internal/auth"
|
||||
"arclineit/arcline-portal/internal/db"
|
||||
"arclineit/arcline-portal/internal/mail"
|
||||
"arclineit/arcline-portal/internal/uptime"
|
||||
"arclineit/arcline-portal/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
loadEnv(".env")
|
||||
|
||||
// --- CLI flags ---
|
||||
seedFlag := flag.Bool("seed", false, "create the initial admin account and exit")
|
||||
seedUser := flag.String("username", "", "admin username (used with -seed)")
|
||||
seedName := flag.String("name", "", "admin display name (used with -seed)")
|
||||
seedPass := flag.String("password", "", "admin password — min 8 chars (used with -seed)")
|
||||
flag.Parse()
|
||||
|
||||
port := envOr("PORT", "8082")
|
||||
dbPath := envOr("DB_PATH", "./portal.db")
|
||||
uptimeDBPath := envOr("UPTIME_DB_PATH", "../arcline-uptime/uptime.db")
|
||||
|
||||
// --- Database ---
|
||||
database, err := db.Open(dbPath)
|
||||
if err != nil {
|
||||
slog.Error("open portal db", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// --- Seed mode ---
|
||||
if *seedFlag {
|
||||
username := strings.TrimSpace(*seedUser)
|
||||
name := strings.TrimSpace(*seedName)
|
||||
password := *seedPass
|
||||
if username == "" || name == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: -username and -name are required with -seed")
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(password) < 8 {
|
||||
fmt.Fprintln(os.Stderr, "error: -password must be at least 8 characters")
|
||||
os.Exit(1)
|
||||
}
|
||||
// Check if an admin already exists.
|
||||
existing, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
slog.Error("seed: lookup failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if existing != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: username %q already exists\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
slog.Error("seed: hash password", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
client, err := database.CreateClient(username, name, "", hash, true)
|
||||
if err != nil {
|
||||
slog.Error("seed: create client", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Admin account created.\n id: %d\n username: %s\n name: %s\n",
|
||||
client.ID, client.Username, client.DisplayName)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Uptime reader (optional) ---
|
||||
var uptimeReader *uptime.Reader
|
||||
if uptime.Available(uptimeDBPath) {
|
||||
uptimeReader, err = uptime.Open(uptimeDBPath)
|
||||
if err != nil {
|
||||
slog.Warn("uptime db unavailable — service status will not be shown", "err", err)
|
||||
} else {
|
||||
defer uptimeReader.Close()
|
||||
slog.Info("uptime db connected", "path", uptimeDBPath)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("uptime db not found — service status disabled", "path", uptimeDBPath)
|
||||
}
|
||||
|
||||
// --- Mail ---
|
||||
var mailer *mail.Mailer
|
||||
mailCfg := mail.Config{
|
||||
Host: envOr("SMTP_HOST", ""),
|
||||
Port: envOr("SMTP_PORT", "587"),
|
||||
Username: envOr("SMTP_USER", ""),
|
||||
Password: envOr("SMTP_PASS", ""),
|
||||
From: envOr("SMTP_FROM", ""),
|
||||
AdminEmail: envOr("ADMIN_EMAIL", ""),
|
||||
BaseURL: envOr("BASE_URL", "https://portal.arclineit.com"),
|
||||
}
|
||||
if mailCfg.Host != "" && mailCfg.From != "" {
|
||||
mailer, err = mail.New(mailCfg)
|
||||
if err != nil {
|
||||
slog.Warn("mail not configured", "err", err)
|
||||
} else {
|
||||
slog.Info("mail configured", "host", mailCfg.Host, "from", mailCfg.From)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("mail not configured — set SMTP_HOST and SMTP_FROM to enable email")
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
h := &web.Handler{
|
||||
DB: database,
|
||||
Uptime: uptimeReader,
|
||||
Mail: mailer,
|
||||
}
|
||||
|
||||
// --- Background jobs ---
|
||||
go func() {
|
||||
// SSL checker: run immediately, then daily.
|
||||
h.RunSSLChecker()
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
h.RunSSLChecker()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
// Prune expired sessions every hour.
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if err := database.PruneSessions(); err != nil {
|
||||
slog.Error("prune sessions", "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// --- Routes ---
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Public
|
||||
mux.HandleFunc("GET /login", h.LoginGET)
|
||||
mux.HandleFunc("POST /login", h.LoginPOST)
|
||||
mux.HandleFunc("POST /logout", h.LogoutPOST)
|
||||
mux.HandleFunc("GET /forgot", h.ForgotGET)
|
||||
mux.HandleFunc("POST /forgot", h.ForgotPOST)
|
||||
mux.HandleFunc("GET /reset", h.ResetGET)
|
||||
mux.HandleFunc("POST /reset", h.ResetPOST)
|
||||
|
||||
// Static assets
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
|
||||
// Authenticated — client
|
||||
authed := auth.Middleware(database)
|
||||
mux.Handle("GET /dashboard", authed(http.HandlerFunc(h.DashboardGET)))
|
||||
|
||||
mux.Handle("GET /ssl", authed(http.HandlerFunc(h.SSLGet)))
|
||||
mux.Handle("POST /ssl/add", authed(http.HandlerFunc(h.SSLAddPOST)))
|
||||
mux.Handle("POST /ssl/delete", authed(http.HandlerFunc(h.SSLDeletePOST)))
|
||||
|
||||
mux.Handle("GET /tickets", authed(http.HandlerFunc(h.TicketsGET)))
|
||||
mux.Handle("POST /tickets/new", authed(http.HandlerFunc(h.TicketNewPOST)))
|
||||
mux.Handle("GET /tickets/{id}", authed(http.HandlerFunc(h.TicketGET)))
|
||||
mux.Handle("POST /tickets/{id}/reply", authed(http.HandlerFunc(h.TicketReplyPOST)))
|
||||
|
||||
mux.Handle("GET /settings", authed(http.HandlerFunc(h.SettingsGET)))
|
||||
mux.Handle("POST /settings/email", authed(http.HandlerFunc(h.SettingsEmailPOST)))
|
||||
mux.Handle("POST /settings/password", authed(http.HandlerFunc(h.SettingsPasswordPOST)))
|
||||
|
||||
// Authenticated — admin only
|
||||
admin := func(hf http.HandlerFunc) http.Handler {
|
||||
return authed(auth.AdminMiddleware(http.HandlerFunc(hf)))
|
||||
}
|
||||
mux.Handle("GET /admin", admin(h.AdminIndexGET))
|
||||
mux.Handle("POST /admin/clients/new", admin(h.AdminClientNewPOST))
|
||||
mux.Handle("GET /admin/clients/{id}", admin(h.AdminClientGET))
|
||||
mux.Handle("POST /admin/clients/{id}/monitors/add", admin(h.AdminMonitorAddPOST))
|
||||
mux.Handle("POST /admin/clients/{id}/monitors/delete", admin(h.AdminMonitorDeletePOST))
|
||||
mux.Handle("POST /admin/clients/{id}/delete", admin(h.AdminClientDeletePOST))
|
||||
|
||||
// Root redirect / catch-all 404
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
h.NotFoundHandler(w, r)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: secHeaders(mux),
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
slog.Info("arcline-portal starting", "addr", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Error("server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func secHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// loadEnv reads a .env file and sets any key=value pairs as environment
|
||||
// variables, skipping blank lines and lines beginning with #.
|
||||
// Already-set variables (e.g. from the real environment) are not overwritten.
|
||||
func loadEnv(path string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return // no .env is fine
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
v = strings.TrimSpace(v)
|
||||
// Strip surrounding quotes if present
|
||||
if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) {
|
||||
v = v[1 : len(v)-1]
|
||||
}
|
||||
// Don't overwrite values already set in the environment
|
||||
if os.Getenv(k) == "" {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
589
static/css/portal.css
Normal file
589
static/css/portal.css
Normal file
@@ -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; }
|
||||
5
static/favicon.svg
Normal file
5
static/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="6" ry="6" fill="#060b10"/>
|
||||
<path d="M5 27L16 5L27 27" fill="none" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 20H23" fill="none" stroke="#00c8f0" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
19
static/js/portal.js
Normal file
19
static/js/portal.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
36
todo.md
36
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
|
||||
|
||||
Reference in New Issue
Block a user