Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY migrations ./migrations
COPY migrations ./migrations
COPY db ./db
COPY web ./web
COPY *.go ./
RUN CGO_ENABLED=0 go build -o /vehicle-positions .

Expand Down
209 changes: 209 additions & 0 deletions admin_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package main

import (
"bytes"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"os"
"path"
"strconv"
)

// adminUIEnabled reports whether the admin UI should be served, controlled by
// the ADMIN_UI_ENABLED environment variable (default false). Any value
// strconv.ParseBool accepts as true (1, t, T, TRUE, true, ...) turns it on;
// unset or unparseable values leave it off.
func adminUIEnabled() bool {
enabled, _ := strconv.ParseBool(os.Getenv("ADMIN_UI_ENABLED"))
return enabled
}

// registerAdminUI loads the embedded templates, mounts the static file server,
// and registers the admin UI routes on mux. It returns an error if the
// templates or static assets cannot be prepared from the embedded filesystem.
func registerAdminUI(mux *http.ServeMux) error {
tmpls, err := loadTemplates()
if err != nil {
return fmt.Errorf("load templates: %w", err)
}
templates = tmpls

staticFiles, err := fs.Sub(files, "web/static")
if err != nil {
return fmt.Errorf("prepare static files: %w", err)
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFiles))))

mux.HandleFunc("GET /admin/login", AdminLoginHandler)
mux.HandleFunc("GET /admin/signup", AdminSignupHandler)
mux.HandleFunc("GET /admin/map", AdminMapHandler)
mux.HandleFunc("GET /admin/dashboard", AdminDashboardHandler)
mux.HandleFunc("GET /admin/vehicles", AdminVehiclesHandler)
mux.HandleFunc("GET /admin/users", AdminUsersHandler)
mux.HandleFunc("GET /admin/trips", AdminTripsHandler)
return nil
}

// templates holds the parsed admin UI templates. It stays nil until
// loadTemplates succeeds in main(); the admin routes that use it are only
// registered when the admin UI is enabled, so handlers never run against nil.
var templates *embeddedTemplates

type embeddedTemplates struct {
public map[string]*template.Template
admin map[string]*template.Template
}

// loadTemplates parses the embedded admin UI templates once at startup. It
// returns an error rather than panicking so main() can log it with context and
// exit cleanly, consistent with the rest of the server's startup error handling.
func loadTemplates() (*embeddedTemplates, error) {
adminViews := []string{
"dashboard.html",
"map.html",
"trips.html",
"users.html",
"vehicles.html",
}

admin := make(map[string]*template.Template, len(adminViews))
for _, view := range adminViews {
tmpl, err := template.ParseFS(
files,
"web/templates/layout/*.html",
path.Join("web/templates/views", view),
)
if err != nil {
return nil, fmt.Errorf("parse admin view %q: %w", view, err)
}
admin[view] = tmpl
}

login, err := template.ParseFS(files, "web/templates/views/login.html")
if err != nil {
return nil, fmt.Errorf("parse public view %q: %w", "login.html", err)
}

return &embeddedTemplates{
public: map[string]*template.Template{"login.html": login},
admin: admin,
}, nil
}

// render looks up the parsed template set by view name and executes rootName
// into a buffer first, so a mid-render failure yields a clean 500 instead of a
// half-written 200 with a corrupted body. An unknown view is a programmer error
// (the route registered it) but is still reported rather than silently ignored.
func render(w http.ResponseWriter, set map[string]*template.Template, view, rootName string, data map[string]interface{}) {
tmpl, ok := set[path.Base(view)]
if !ok {
slog.Error("template render failed", "view", view, "error", "no such template")
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}

var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, rootName, data); err != nil {
slog.Error("template render failed", "view", view, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if _, err := buf.WriteTo(w); err != nil {
// The 200 header is already committed, so we can't convert this to a
// 500 — log it so a truncated response is at least visible server-side.
slog.Error("template response write failed", "view", view, "error", err)
}
}

// renderPublic renders a standalone public page (e.g. login) by its own name.
func renderPublic(w http.ResponseWriter, view string, data map[string]interface{}) {
render(w, templates.public, view, path.Base(view), data)
}

// renderAdmin renders an admin page through the shared base.html layout, which
// pulls in the view's {{define "content"}} block.
func renderAdmin(w http.ResponseWriter, view string, data map[string]interface{}) {
render(w, templates.admin, view, "base.html", data)
}

func AdminMapHandler(w http.ResponseWriter, r *http.Request) {
renderAdmin(w, "web/templates/views/map.html", map[string]interface{}{
"Title": "Live Map",
"Page": "map",
})
}

func AdminLoginHandler(w http.ResponseWriter, r *http.Request) {
renderPublic(w, "web/templates/views/login.html", map[string]interface{}{
"Title": "Welcome",
"Mode": "login",
"LoginEndpoint": "/api/v1/auth/login",
"SignupEndpoint": "/api/v1/auth/signup",
})
}

func AdminSignupHandler(w http.ResponseWriter, r *http.Request) {
renderPublic(w, "web/templates/views/login.html", map[string]interface{}{
"Title": "Create Account",
"Mode": "signup",
"LoginEndpoint": "/api/v1/auth/login",
"SignupEndpoint": "/api/v1/auth/signup",
})
Comment on lines +148 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wire the signup page to a real backend route or remove it for now.

Line 153 submits to /api/v1/auth/signup, but main.go only registers POST /api/v1/auth/login on Line 80 and no corresponding signup route exists on this mux. With ADMIN_UI_ENABLED=true, /admin/signup renders a flow that can only fail at submit time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@admin_handlers.go` around lines 148 - 154, AdminSignupHandler renders a
signup form that posts to "/api/v1/auth/signup" but no signup route is
registered (only POST "/api/v1/auth/login" exists), so either update the handler
or add the missing route: either change the "SignupEndpoint" value in
AdminSignupHandler to an existing backend route (e.g., reuse
"/api/v1/auth/login" or another valid endpoint) or register a corresponding
signup handler in main (add a POST "/api/v1/auth/signup" route and implement the
signup logic). Locate AdminSignupHandler and the POST "/api/v1/auth/login"
registration in main.go to make the endpoints consistent.

}

func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) {
renderAdmin(w, "web/templates/views/dashboard.html", map[string]interface{}{
"Title": "Dashboard",
"Page": "dashboard",
"TotalVehicles": "24",
"ActiveVehicles": "18",
"TotalDrivers": "32",
"ActiveTrips": "15",
"RecentVehicles": []map[string]string{
{"Name": "Bus 001", "Route": "Route A", "Status": "active", "LastSeen": "2 min ago"},
{"Name": "Bus 002", "Route": "Route B", "Status": "active", "LastSeen": "5 min ago"},
{"Name": "Bus 003", "Route": "Route C", "Status": "idle", "LastSeen": "12 min ago"},
{"Name": "Bus 004", "Route": "Route A", "Status": "active", "LastSeen": "1 min ago"},
{"Name": "Bus 005", "Route": "Route D", "Status": "active", "LastSeen": "3 min ago"},
},
})
}

func AdminVehiclesHandler(w http.ResponseWriter, r *http.Request) {
renderAdmin(w, "web/templates/views/vehicles.html", map[string]interface{}{
"Title": "Vehicles",
"Page": "vehicles",
"Vehicles": []map[string]string{
{"ID": "V001", "Name": "Bus 001", "Route": "Route A", "Driver": "Chaitanya K", "Status": "active", "LastSeen": "2 min ago"},
{"ID": "V002", "Name": "Bus 002", "Route": "Route B", "Driver": "Aron", "Status": "active", "LastSeen": "5 min ago"},
{"ID": "V003", "Name": "Bus 003", "Route": "Route C", "Driver": "Brad Pitt", "Status": "idle", "LastSeen": "12 min ago"},
},
})
}

func AdminUsersHandler(w http.ResponseWriter, r *http.Request) {
renderAdmin(w, "web/templates/views/users.html", map[string]interface{}{
"Title": "Users",
"Page": "users",
"Users": []map[string]string{
{"Name": "Chaitanya K", "Email": "kbc@transit.co.ke", "Role": "driver", "LastSeen": "Today"},
{"Name": "To Holland", "Email": "tom@transit.co.ke", "Role": "driver", "LastSeen": "Today"},
{"Name": "Open transit", "Email": "brian@transit.co.ke", "Role": "driver", "LastSeen": "Yesterday"},
},
})
}

func AdminTripsHandler(w http.ResponseWriter, r *http.Request) {
renderAdmin(w, "web/templates/views/trips.html", map[string]interface{}{
"Title": "Trips",
"Page": "trips",
"Trips": []map[string]string{
{"ID": "T001", "Vehicle": "Bus 001", "Driver": "Tom Hiddlestone", "Route": "Route A", "Start": "07:00", "End": "08:45", "Status": "completed"},
{"ID": "T002", "Vehicle": "Bus 002", "Driver": "Chris Hensworth", "Route": "Route B", "Start": "07:15", "End": "\u2014", "Status": "active"},
{"ID": "T003", "Vehicle": "Bus 003", "Driver": "Bruce Wayne", "Route": "Route C", "Start": "06:45", "End": "08:30", "Status": "completed"},
},
})
}
134 changes: 134 additions & 0 deletions admin_handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package main

import (
"html/template"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// loadAdminTemplates populates the package-level templates var for handler
// tests, mirroring what registerAdminUI does at startup.
func loadAdminTemplates(t *testing.T) {
t.Helper()
tmpls, err := loadTemplates()
require.NoError(t, err)
templates = tmpls
}

func TestLoadTemplates(t *testing.T) {
tmpls, err := loadTemplates()
require.NoError(t, err)
require.NotNil(t, tmpls)

for _, view := range []string{"dashboard.html", "map.html", "trips.html", "users.html", "vehicles.html"} {
assert.Contains(t, tmpls.admin, view, "admin view %q should be parsed", view)
}
assert.Contains(t, tmpls.public, "login.html")
}

func TestAdminHandlersRenderOK(t *testing.T) {
loadAdminTemplates(t)

cases := []struct {
name string
handler http.HandlerFunc
path string
want string
}{
{"dashboard", AdminDashboardHandler, "/admin/dashboard", "Bus 001"},
{"vehicles", AdminVehiclesHandler, "/admin/vehicles", "Bus 001"},
{"users", AdminUsersHandler, "/admin/users", "Chaitanya K"},
{"trips", AdminTripsHandler, "/admin/trips", "Route A"},
{"map", AdminMapHandler, "/admin/map", "Live Map"},
{"login", AdminLoginHandler, "/admin/login", "Welcome"},
{"signup", AdminSignupHandler, "/admin/signup", "Create Account"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder()

tc.handler(rec, req)

assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), tc.want)
})
}
}

// TestRenderUnknownViewWritesCleanError verifies that rendering a view absent
// from the template set yields a clean 500 rather than silently falling back to
// another template or writing a partial 200 body.
func TestRenderUnknownViewWritesCleanError(t *testing.T) {
loadAdminTemplates(t)

for _, set := range []map[string]*template.Template{templates.admin, templates.public} {
rec := httptest.NewRecorder()
render(rec, set, "ghost.html", "base.html", map[string]interface{}{})

assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Contains(t, rec.Body.String(), "internal server error")
}
}

// TestAdminUIEnabledFlag pins the gate that keeps the unauthenticated admin UI
// off by default — the single safety mechanism behind the feature.
func TestAdminUIEnabledFlag(t *testing.T) {
cases := map[string]bool{
"true": true,
"1": true,
"TRUE": true,
"t": true,
"false": false,
"0": false,
"": false,
"nonsense": false,
}

for val, want := range cases {
t.Run("val="+val, func(t *testing.T) {
t.Setenv("ADMIN_UI_ENABLED", val)
assert.Equal(t, want, adminUIEnabled())
})
}
}

// TestRenderExecutionErrorIsCleanError verifies the buffered-write contract: a
// template that fails partway through must not leak partial output — the client
// gets a clean 500, not a half-written 200.
func TestRenderExecutionErrorIsCleanError(t *testing.T) {
tmpl := template.Must(template.New("base.html").Parse(`PARTIAL-OUTPUT{{index .Items 99}}`))
set := map[string]*template.Template{"boom.html": tmpl}

rec := httptest.NewRecorder()
render(rec, set, "boom.html", "base.html", map[string]interface{}{"Items": []int{}})

assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Contains(t, rec.Body.String(), "internal server error")
assert.NotContains(t, rec.Body.String(), "PARTIAL-OUTPUT")
}

func TestRegisterAdminUI(t *testing.T) {
mux := http.NewServeMux()
require.NoError(t, registerAdminUI(mux))

t.Run("admin route is served", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
})

t.Run("static asset is served from embedded fs", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/static/js/admin.js", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.NotEmpty(t, rec.Body.String())
})
}
16 changes: 16 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"embed"
"errors"
"log/slog"
"net/http"
Expand All @@ -11,6 +12,9 @@ import (
"time"
)

//go:embed web/templates web/static
var files embed.FS

func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))

Expand Down Expand Up @@ -102,6 +106,18 @@ func main() {
mux.Handle("GET /api/v1/admin/users/{id}/vehicles", authMiddleware(adminMiddleware(handleListUserVehicles(store))))
mux.Handle("GET /api/v1/admin/vehicles/{id}/users", authMiddleware(adminMiddleware(handleListVehicleUsers(store))))

// Admin UI (server-rendered HTML). This is a proof-of-concept with
// placeholder data and no authentication, so it is disabled by default and
// must be explicitly turned on via ADMIN_UI_ENABLED. Do not enable it in
// production until the routes are gated behind real session auth.
if adminUIEnabled() {
if err := registerAdminUI(mux); err != nil {
slog.Error("failed to enable admin UI", "error", err)
os.Exit(1)
}
slog.Warn("admin UI enabled with no authentication — for demo use only, do not expose in production")
}

srv := &http.Server{
Addr: ":" + port,
Handler: requestLogger(mux),
Expand Down
Loading
Loading