Skip to content
Open
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
27 changes: 20 additions & 7 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,34 @@
flake-utils.url = "github:numtide/flake-utils";
};

outputs = { self, nixpkgs, flake-utils }:
(flake-utils.lib.eachDefaultSystem (system:
let pkgs = import nixpkgs { inherit system; };
in rec {
outputs =
{
self,
nixpkgs,
flake-utils,
}:
(flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
in
rec {
packages.mineshspc = pkgs.buildGoModule {
pname = "mineshspc.com";
version = "unstable-2026-01-11";
src = self;
subPackages = [ "cmd/mineshspc" ];
vendorHash = "sha256-q4AXwm8QRedjkO3565GASsCIxnmhzMGAYHEr6p0Es+0=";
vendorHash = "sha256-ylJWr0OFXY15APTq+ozVbFkAQ1lreM/9kZhJ6qYhx8E=";
};
packages.default = packages.mineshspc;

devShells.default = pkgs.mkShell {
packages = with pkgs; [ go gotools pre-commit ];
packages = with pkgs; [
go
gotools
pre-commit
];
};
}));
}
));
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/rs/zerolog v1.35.0
github.com/sendgrid/sendgrid-go v3.16.1+incompatible
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.11.1
go.mau.fi/util v0.9.7
go.mau.fi/zeroconfig v0.2.0
gopkg.in/yaml.v3 v3.0.1
Expand Down
54 changes: 1 addition & 53 deletions internal/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package internal
import (
"context"
"encoding/csv"
"errors"
"fmt"
"net/http"
"time"
Expand All @@ -24,47 +23,7 @@ func (a *Application) createAdminLoginJWT(email string) *jwt.Token {
})
}

func (a *Application) isAdminByToken(tokenStr string) (bool, error) {
if tokenStr == "" {
return false, errors.New("no token")
}

token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}

return a.Config.ReadSecretKey(), nil
})
if err != nil {
return false, fmt.Errorf("failed to parse admin token: %w", err)
}

claims, ok := token.Claims.(*jwt.RegisteredClaims)
if !token.Valid || !ok {
return false, fmt.Errorf("failed to validate token: %w", err)
}

if claims.Issuer != string(IssuerAdminLogin) {
return false, fmt.Errorf("invalid student verify token: %w", err)
}

return true, nil
}

func (a *Application) GetAdminTeamsTemplate(r *http.Request) map[string]any {
tok, err := r.Cookie("admin_token")
if err != nil {
a.Log.Warn().Err(err).Msg("failed to get admin token")
return nil
}

if isAdmin, err := a.isAdminByToken(tok.Value); err != nil || !isAdmin {
a.Log.Warn().Err(err).Msg("user is not admin!")
return nil
}

teamsWithTeachers, err := a.DB.GetAdminTeamsWithTeacherName(r.Context())
if err != nil {
a.Log.Err(err).Msg("failed to get teams")
Expand Down Expand Up @@ -153,17 +112,6 @@ func (a *Application) GetAdminTeamsTemplate(r *http.Request) map[string]any {
}

func (a *Application) GetAdminDietaryRestrictionsTemplate(r *http.Request) map[string]any {
tok, err := r.Cookie("admin_token")
if err != nil {
a.Log.Warn().Err(err).Msg("failed to get admin token")
return nil
}

if isAdmin, err := a.isAdminByToken(tok.Value); err != nil || !isAdmin {
a.Log.Warn().Err(err).Msg("user is not admin!")
return nil
}

dietaryRestrictions, err := a.DB.GetAllDietaryRestrictions(r.Context())
if err != nil {
a.Log.Err(err).Msg("failed to get dietary restrictions")
Expand All @@ -179,7 +127,7 @@ func (a *Application) HandleAdminEmailLogin(w http.ResponseWriter, r *http.Reque
tok := r.URL.Query().Get("tok")
log := zerolog.Ctx(r.Context())
log.Info().Str("token", tok).Msg("got token")
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

This logs the raw login JWT from the query string. JWTs are bearer credentials and should not be written to logs (they can be replayed if leaked via log aggregation/support tooling). Please remove this field from logs or replace it with a safe surrogate (e.g., token hash / last few chars) and rely on request_id for correlation.

Suggested change
log.Info().Str("token", tok).Msg("got token")
log.Info().Msg("got admin login token")

Copilot uses AI. Check for mistakes.
isAdmin, err := a.isAdminByToken(tok)
isAdmin, err := a.parseTokenByIssuer(tok, IssuerAdminLogin)
if err != nil || !isAdmin {
log.Warn().Msg("failed to get admin")
w.WriteHeader(http.StatusBadRequest)
Expand Down
79 changes: 48 additions & 31 deletions internal/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package internal
import (
"fmt"
"html/template"
"maps"
"net/http"
"regexp"
"strings"
Expand Down Expand Up @@ -66,9 +67,7 @@ func (a *Application) ServeTemplateExtra(logger *zerolog.Logger, templateName st
if data == nil {
data = map[string]any{}
}
for k, v := range extraData {
data[k] = v
}
maps.Copy(data, extraData)
user, err := a.GetLoggedInTeacher(r)
if err == nil {
data["Username"] = user.Name
Expand All @@ -91,12 +90,9 @@ type renderInfo struct {
RedirectIfLoggedIn bool
}

func (a *Application) Start() {
a.Log.Info().Msg("connecting to sendgrid")
a.SendGridClient = sendgrid.NewSendClient(a.Config.SendgridAPIKey)

a.Log.Info().Msg("Starting router")

// BuildRouter sets up all HTTP routes and returns the handler. Separated from
// Start so tests can exercise routing without starting a real server.
func (a *Application) BuildRouter() http.Handler {
router := http.NewServeMux()

noArgs := func(r *http.Request) map[string]any { return nil }
Expand Down Expand Up @@ -172,6 +168,10 @@ func (a *Application) Start() {
a.Log.Info().Err(err).
Bool("redirect_if_logged_in", rend.RedirectIfLoggedIn).
Msg("Failed to get logged in teacher")
if !rend.RedirectIfLoggedIn && strings.HasPrefix(path, "/register/teacher/") {
http.Redirect(w, r, "/register/teacher/login", http.StatusSeeOther)
return
}
if rend.RedirectIfLoggedIn && path != "/register/teacher/login" && path != "/register/teacher/createaccount" && path != "/register/teacher/confirmemail" {
http.Redirect(w, r, "/register/teacher/login", http.StatusTemporaryRedirect)
}
Expand Down Expand Up @@ -217,41 +217,58 @@ func (a *Application) Start() {
router.HandleFunc("POST "+path, renderFn(fn))
}

// Admin pages
router.HandleFunc("GET /admin", a.ServeTemplate(a.Log, "adminhome.html", noArgs))
// Admin pages (unprotected — these exact patterns beat the /admin/ prefix below)
router.HandleFunc("GET /admin/login", a.ServeTemplate(a.Log, "adminlogin.html", noArgs))
router.HandleFunc("GET /admin/dietaryrestrictions", a.ServeTemplate(a.Log, "admindietaryrestrictions.html", a.GetAdminDietaryRestrictionsTemplate))
router.HandleFunc("GET /admin/teams", a.ServeTemplate(a.Log, "adminteams.html", a.GetAdminTeamsTemplate))
router.HandleFunc("GET /admin/emaillogin", a.HandleAdminEmailLogin)
router.HandleFunc("POST /admin/emaillogin", a.HandleAdminLogin)

adminAPIRouter := http.NewServeMux()
adminAPIRouter.HandleFunc("GET /resendstudentemail", a.HandleResendStudentEmail)
adminAPIRouter.HandleFunc("GET /resendparentemail", a.HandleResendParentEmail)
adminAPIRouter.HandleFunc("GET /confirmationlink/student", a.HandleGetStudentEmailConfirmationLink)
adminAPIRouter.HandleFunc("GET /confirmationlink/parent", a.HandleGetParentEmailConfirmationLink)
adminAPIRouter.HandleFunc("GET /sendemailconfirmationreminders", a.HandleSendEmailConfirmationReminders)
adminAPIRouter.HandleFunc("GET /sendparentreminders", a.HandleSendParentReminders)
adminAPIRouter.HandleFunc("GET /sendqrcodes", a.HandleSendQRCodes)
adminAPIRouter.HandleFunc("GET /kattis/teams", a.HandleKattisTeamsExport)
adminAPIRouter.HandleFunc("GET /kattis/participants", a.HandleKattisParticipantsExport)
adminAPIRouter.HandleFunc("GET /zoom/breakout", a.HandleZoomBreakoutExport)
adminAPIRouter.HandleFunc("GET /manualcheckin", a.HandleManualCheckin)
adminAPIRouter.HandleFunc("GET /team-list", a.HandleTeamList)
router.Handle("/admin/api/", http.StripPrefix("/admin/api", a.AdminAuthMiddleware(adminAPIRouter)))

// Volunteer pages
// Admin pages (protected) — subrouter consolidates all protected admin routes
adminRouter := http.NewServeMux()
adminRouter.HandleFunc("GET /{$}", a.ServeTemplate(a.Log, "adminhome.html", noArgs))
adminRouter.HandleFunc("GET /dietaryrestrictions", a.ServeTemplate(a.Log, "admindietaryrestrictions.html", a.GetAdminDietaryRestrictionsTemplate))
adminRouter.HandleFunc("GET /teams", a.ServeTemplate(a.Log, "adminteams.html", a.GetAdminTeamsTemplate))
adminRouter.HandleFunc("GET /api/resendstudentemail", a.HandleResendStudentEmail)
adminRouter.HandleFunc("GET /api/resendparentemail", a.HandleResendParentEmail)
adminRouter.HandleFunc("GET /api/confirmationlink/student", a.HandleGetStudentEmailConfirmationLink)
adminRouter.HandleFunc("GET /api/confirmationlink/parent", a.HandleGetParentEmailConfirmationLink)
adminRouter.HandleFunc("GET /api/sendemailconfirmationreminders", a.HandleSendEmailConfirmationReminders)
adminRouter.HandleFunc("GET /api/sendparentreminders", a.HandleSendParentReminders)
adminRouter.HandleFunc("GET /api/sendqrcodes", a.HandleSendQRCodes)
adminRouter.HandleFunc("GET /api/kattis/teams", a.HandleKattisTeamsExport)
adminRouter.HandleFunc("GET /api/kattis/participants", a.HandleKattisParticipantsExport)
adminRouter.HandleFunc("GET /api/zoom/breakout", a.HandleZoomBreakoutExport)
adminRouter.HandleFunc("GET /api/manualcheckin", a.HandleManualCheckin)
adminRouter.HandleFunc("GET /api/team-list", a.HandleTeamList)
router.Handle("/admin/", http.StripPrefix("/admin", a.AdminAuthMiddleware(adminRouter)))
router.Handle("GET /admin", a.AdminAuthMiddleware(
http.HandlerFunc(a.ServeTemplate(a.Log, "adminhome.html", noArgs))))

// Volunteer pages (unprotected)
router.HandleFunc("GET /volunteer", a.ServeTemplate(a.Log, "volunteerhome.html", noArgs))
router.HandleFunc("GET /volunteer/login", a.ServeTemplate(a.Log, "volunteerlogin.html", noArgs))
router.HandleFunc("GET /volunteer/emaillogin", a.HandleVolunteerEmailLogin)
router.HandleFunc("POST /volunteer/emaillogin", a.HandleVolunteerLogin)
router.HandleFunc("GET /volunteer/scan", a.ServeTemplate(a.Log, "volunteerscan.html", a.GetVolunteerScanTemplate))
router.HandleFunc("GET /volunteer/checkin", a.HandleVolunteerCheckIn)

// Volunteer pages (protected)
router.Handle("GET /volunteer/scan", a.VolunteerAuthMiddleware(
http.HandlerFunc(a.ServeTemplate(a.Log, "volunteerscan.html", a.GetVolunteerScanTemplate))))
router.Handle("GET /volunteer/checkin", a.VolunteerAuthMiddleware(
http.HandlerFunc(a.HandleVolunteerCheckIn)))

var handler http.Handler = router
handler = hlog.RequestIDHandler("request_id", "RequestID")(handler)
handler = hlog.NewHandler(*a.Log)(handler)

return handler
}

func (a *Application) Start() {
a.Log.Info().Msg("connecting to sendgrid")
a.SendGridClient = sendgrid.NewSendClient(a.Config.SendgridAPIKey)

a.Log.Info().Msg("Starting router")
handler := a.BuildRouter()

a.Log.Info().Msg("Listening on port 8090")
http.ListenAndServe(":8090", handler)
}
57 changes: 53 additions & 4 deletions internal/middleware.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,66 @@
package internal

import "net/http"
import (
"fmt"
"net/http"

"github.com/golang-jwt/jwt/v4"
)

func (a *Application) parseTokenByIssuer(tokenStr string, issuer Issuer) (bool, error) {
if tokenStr == "" {
return false, fmt.Errorf("no token")
}

token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return a.Config.ReadSecretKey(), nil
})
if err != nil {
return false, fmt.Errorf("failed to parse token: %w", err)
}

claims, ok := token.Claims.(*jwt.RegisteredClaims)
if !token.Valid || !ok {
return false, fmt.Errorf("invalid token")
}

if claims.Issuer != string(issuer) {
return false, fmt.Errorf("wrong issuer: got %s, want %s", claims.Issuer, issuer)
}

return true, nil
}

func (a *Application) AdminAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, err := r.Cookie("admin_token")
if err != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}

if isAdmin, err := a.parseTokenByIssuer(tok.Value, IssuerAdminLogin); err != nil || !isAdmin {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}

next.ServeHTTP(w, r)
})
}

func (a *Application) VolunteerAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, err := r.Cookie("volunteer_token")
if err != nil {
http.Redirect(w, r, "/volunteer/login", http.StatusSeeOther)
return
}

if isAdmin, err := a.isAdminByToken(tok.Value); err != nil || !isAdmin {
http.Redirect(w, r, "/", http.StatusSeeOther)
if isVolunteer, err := a.parseTokenByIssuer(tok.Value, IssuerVolunteerLogin); err != nil || !isVolunteer {
http.Redirect(w, r, "/volunteer/login", http.StatusSeeOther)
return
}

Expand Down
Loading
Loading