josie / simplegit

package web

import (
	"errors"
	"net/http"
	"time"

	"git.josie-c.com/josie/simplegit/internal/auth"
	"git.josie-c.com/josie/simplegit/internal/db"
)

// tokenView is one row of the token list, with a formatted date.
type tokenView struct {
	ID      int64
	Name    string
	Hint    string
	Created string
}

type settingsData struct {
	Username      string
	Tokens        []tokenView
	NewToken      string // plaintext, shown exactly once
	PasswordError string
	PasswordOK    bool
}

func tokenViews(tokens []db.Token) []tokenView {
	views := make([]tokenView, 0, len(tokens))
	for _, tok := range tokens {
		views = append(views, tokenView{
			ID: tok.ID, Name: tok.Name, Hint: tok.Hint,
			Created: time.Unix(tok.CreatedAt, 0).Format("2006-01-02"),
		})
	}
	return views
}

// settingsView loads the page model for a user; a missing token list is
// non-fatal (the page still renders the password form).
func (s *Server) settingsView(user *db.User) settingsData {
	data := settingsData{Username: user.Username}
	if tokens, err := db.ListTokens(s.database, user.ID); err == nil {
		data.Tokens = tokenViews(tokens)
	}
	return data
}

// requireUser redirects anonymous callers to the login page and reports
// whether the request may continue.
func requireUser(w http.ResponseWriter, r *http.Request) *db.User {
	user := currentUser(r)
	if user == nil {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return nil
	}
	return user
}

// handleSettings shows the account page: token list and password change.
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
	user := requireUser(w, r)
	if user == nil {
		return
	}
	s.render(w, "settings.html", http.StatusOK, s.settingsView(user))
}

// handleCreateToken mints a token and re-renders the page so the plaintext
// is visible once.
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
	user := requireUser(w, r)
	if user == nil {
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad form", http.StatusBadRequest)
		return
	}
	name := formText(r, "name", tokenLabelMax)
	if name == "" {
		name = "token"
	}
	plain, err := auth.NewAPIToken()
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	if _, err := db.CreateToken(s.database, user.ID, name, auth.TokenHint(plain), auth.HashToken(plain)); err != nil {
		s.internalError(w, r, err)
		return
	}
	data := s.settingsView(user)
	data.NewToken = plain
	s.render(w, "settings.html", http.StatusOK, data)
}

// handleDeleteToken revokes one of the caller's tokens.
func (s *Server) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
	user := requireUser(w, r)
	if user == nil {
		return
	}
	id, ok := pathID(r)
	if !ok {
		http.NotFound(w, r)
		return
	}
	if err := db.DeleteToken(s.database, user.ID, id); err != nil && !errors.Is(err, db.ErrNotFound) {
		s.internalError(w, r, err)
		return
	}
	http.Redirect(w, r, "/settings", http.StatusSeeOther)
}

// handleChangePassword verifies the current password, stores a new hash,
// and signs out every other browser session.
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
	user := requireUser(w, r)
	if user == nil {
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad form", http.StatusBadRequest)
		return
	}
	current := r.FormValue("current_password")
	next := r.FormValue("new_password")
	confirm := r.FormValue("confirm_password")

	fail := func(message string) {
		data := s.settingsView(user)
		data.PasswordError = message
		s.render(w, "settings.html", http.StatusUnprocessableEntity, data)
	}
	switch {
	case !auth.CheckPassword(user.PasswordHash, current):
		fail("current password is incorrect")
		return
	case next == "":
		fail("new password must not be empty")
		return
	case next != confirm:
		fail("new passwords do not match")
		return
	}

	hash, err := auth.HashPassword(next)
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	if err := db.UpdateUserPassword(s.database, user.ID, hash); err != nil {
		s.internalError(w, r, err)
		return
	}
	if cookie, err := r.Cookie(sessionCookieName); err == nil {
		_ = db.DeleteOtherSessions(s.database, user.ID, cookie.Value)
	}
	data := s.settingsView(user)
	data.PasswordOK = true
	s.render(w, "settings.html", http.StatusOK, data)
}