Compare commits

...

6 Commits

Author SHA1 Message Date
Teajey 3509cb9666
Password setting and resetting 2025-12-07 17:48:40 +09:00
Teajey f110283b8e
Lowercase errors 2025-12-07 17:47:20 +09:00
Teajey 1980e33d0f
Access http session in protected routes 2025-12-07 17:45:37 +09:00
Teajey 55e6be7239
Add text templates to endpoints 2025-12-07 12:43:55 +09:00
Teajey b57652e6d2
Fix precommit
Presumably this is because of a go version related change
2025-12-07 12:42:09 +09:00
Teajey 3cfeca65fc
Health endpoint 2025-09-18 20:30:57 +09:00
36 changed files with 574 additions and 225 deletions

View File

@ -1,9 +1,9 @@
package lishwist // import "lishwist/core" package lishwist // import "."
VARIABLES VARIABLES
var ErrorUsernameTaken = errors.New("Username is taken") var ErrorUsernameTaken = errors.New("username is taken")
FUNCTIONS FUNCTIONS
@ -31,6 +31,8 @@ func (a *Admin) RemoveUserFromGroup(userId, groupId string) error
func (u *Admin) RenameUser(userReference string, displayName string) error func (u *Admin) RenameUser(userReference string, displayName string) error
func (u *Admin) SetUserPassword(userReference string, newPassword string) error
func (u *Admin) UserSetLive(userReference string, setting bool) error func (u *Admin) UserSetLive(userReference string, setting bool) error
type ErrorInvalidCredentials error type ErrorInvalidCredentials error
@ -81,12 +83,13 @@ func (u *Session) SuggestWishForUser(otherUserReference string, wishName string)
func (s *Session) User() User func (s *Session) User() User
type User struct { type User struct {
Id string Id string
NormalName string NormalName string
Name string Name string
Reference string Reference string
IsAdmin bool IsAdmin bool
IsLive bool IsLive bool
PasswordFromAdmin bool
} }
func GetUserByReference(reference string) (*User, error) func GetUserByReference(reference string) (*User, error)
@ -95,6 +98,8 @@ func Register(username, newPassword string) (*User, error)
func (u *User) GetTodo() ([]Wish, error) func (u *User) GetTodo() ([]Wish, error)
func (u *User) SetPassword(newPassword string) error
func (u *User) WishCount() (int, error) func (u *User) WishCount() (int, error)
type Wish struct { type Wish struct {

View File

@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS "user" (
"password_hash" TEXT NOT NULL, "password_hash" TEXT NOT NULL,
"is_admin" INTEGER NOT NULL DEFAULT 0, "is_admin" INTEGER NOT NULL DEFAULT 0,
"is_live" INTEGER NOT NULL DEFAULT 1, "is_live" INTEGER NOT NULL DEFAULT 1,
"password_from_admin" INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY("id" AUTOINCREMENT) PRIMARY KEY("id" AUTOINCREMENT)
); );
CREATE TABLE IF NOT EXISTS "wish" ( CREATE TABLE IF NOT EXISTS "wish" (

View File

@ -7,7 +7,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
var ErrorUsernameTaken = errors.New("Username is taken") var ErrorUsernameTaken = errors.New("username is taken")
func Register(username, newPassword string) (*User, error) { func Register(username, newPassword string) (*User, error) {
if username == "" { if username == "" {
@ -24,17 +24,17 @@ func Register(username, newPassword string) (*User, error) {
hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost) hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost)
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to hash password: %w", err) return nil, fmt.Errorf("failed to hash password: %w", err)
} }
usersExist, err := hasUsers() usersExist, err := hasUsers()
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to count users: %w", err) return nil, fmt.Errorf("failed to count users: %w", err)
} }
user, err := createUser(username, hashedPasswordBytes, !usersExist) user, err := createUser(username, hashedPasswordBytes, !usersExist)
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to create user: %w\n", err) return nil, fmt.Errorf("failed to create user: %w", err)
} }
return user, nil return user, nil

View File

@ -23,7 +23,7 @@ func (s *Session) User() User {
func SessionFromKey(key string) (*Session, error) { func SessionFromKey(key string) (*Session, error) {
s := Session{} s := Session{}
query := "SELECT user.id, user.name, user.display_name, user.reference, user.is_admin, user.is_live, session.key, session.expiry FROM v_user as user JOIN session ON user.id = session.user_id WHERE session.key = ?" query := "SELECT user.id, user.name, user.display_name, user.reference, user.is_admin, user.is_live, user.password_from_admin, session.key, session.expiry FROM v_user as user JOIN session ON user.id = session.user_id WHERE session.key = ?"
var expiry string var expiry string
err := db.Connection.QueryRow(query, key).Scan( err := db.Connection.QueryRow(query, key).Scan(
&s.user.Id, &s.user.Id,
@ -32,6 +32,7 @@ func SessionFromKey(key string) (*Session, error) {
&s.user.Reference, &s.user.Reference,
&s.user.IsAdmin, &s.user.IsAdmin,
&s.user.IsLive, &s.user.IsLive,
&s.user.PasswordFromAdmin,
&s.Key, &s.Key,
&expiry, &expiry,
) )

View File

@ -4,19 +4,20 @@ import (
"fmt" "fmt"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"lishwist/core/internal/db" "lishwist/core/internal/db"
"lishwist/core/internal/normalize" "lishwist/core/internal/normalize"
) )
type User struct { type User struct {
Id string Id string
NormalName string NormalName string
// TODO: rename to DisplayName Name string
Name string Reference string
Reference string IsAdmin bool
IsAdmin bool IsLive bool
IsLive bool PasswordFromAdmin bool
} }
func queryManyUsers(query string, args ...any) ([]User, error) { func queryManyUsers(query string, args ...any) ([]User, error) {
@ -28,7 +29,7 @@ func queryManyUsers(query string, args ...any) ([]User, error) {
users := []User{} users := []User{}
for rows.Next() { for rows.Next() {
var u User var u User
err = rows.Scan(&u.Id, &u.NormalName, &u.Name, &u.Reference, &u.IsAdmin, &u.IsLive) err = rows.Scan(&u.Id, &u.NormalName, &u.Name, &u.Reference, &u.IsAdmin, &u.IsLive, &u.PasswordFromAdmin)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -54,7 +55,7 @@ func queryOneUser(query string, args ...any) (*User, error) {
func getUserByName(username string) (*User, error) { func getUserByName(username string) (*User, error) {
username = normalize.Name(username) username = normalize.Name(username)
stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE name = ?" stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM v_user WHERE name = ?"
return queryOneUser(stmt, username) return queryOneUser(stmt, username)
} }
@ -91,12 +92,12 @@ func (u *User) getPassHash() ([]byte, error) {
} }
func getUserByReference(reference string) (*User, error) { func getUserByReference(reference string) (*User, error) {
stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE reference = ?" stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM v_user WHERE reference = ?"
return queryOneUser(stmt, reference) return queryOneUser(stmt, reference)
} }
func getUserById(id string) (*User, error) { func getUserById(id string) (*User, error) {
stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE id = ?" stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM v_user WHERE id = ?"
return queryOneUser(stmt, id) return queryOneUser(stmt, id)
} }
@ -111,7 +112,7 @@ func hasUsers() (bool, error) {
} }
func (*Admin) ListUsers() ([]User, error) { func (*Admin) ListUsers() ([]User, error) {
stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM user" stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM user"
return queryManyUsers(stmt) return queryManyUsers(stmt)
} }
@ -172,3 +173,29 @@ func (u *Admin) RenameUser(userReference string, displayName string) error {
} }
return err return err
} }
func (u *Admin) SetUserPassword(userReference string, newPassword string) error {
hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost)
if err != nil {
return fmt.Errorf("Failed to hash password: %w", err)
}
query := "UPDATE user SET password_hash = ?, password_from_admin = 1 WHERE reference = ?"
_, err = db.Connection.Exec(query, hashedPasswordBytes, userReference)
if err != nil {
return err
}
return err
}
func (u *User) SetPassword(newPassword string) error {
hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost)
if err != nil {
return fmt.Errorf("Failed to hash password: %w", err)
}
query := "UPDATE user SET password_hash = ?, password_from_admin = 0 WHERE id = ?"
_, err = db.Connection.Exec(query, hashedPasswordBytes, u.Id)
if err != nil {
return err
}
return err
}

View File

@ -7,6 +7,7 @@ import (
type LoginProps struct { type LoginProps struct {
GeneralError string `json:",omitempty"` GeneralError string `json:",omitempty"`
SuccessfulRegistration bool `json:",omitempty"` SuccessfulRegistration bool `json:",omitempty"`
SuccessfulSetPassword bool `json:",omitempty"`
Username templates.InputProps Username templates.InputProps
Password templates.InputProps Password templates.InputProps
} }

4
http/dev.sh Executable file
View File

@ -0,0 +1,4 @@
top_level=$(git rev-parse --show-toplevel)
git_version=$($top_level/scripts/git-version)
go run -ldflags=-X=lishwist/http/env.GitVersion=$git_version main.go

48
http/env/env.go vendored
View File

@ -14,20 +14,34 @@ func GuaranteeEnv(key string) string {
return variable return variable
} }
var DatabaseFile = GuaranteeEnv("LISHWIST_DATABASE_FILE") type Config struct {
var SessionSecret = GuaranteeEnv("LISHWIST_SESSION_SECRET") DatabaseFile string
var HostRootUrl = GuaranteeEnv("LISHWIST_HOST_ROOT_URL") SessionSecret string
var HostPort = os.Getenv("LISHWIST_HOST_PORT") HostRootUrl string
var ServePort = GuaranteeEnv("LISHWIST_SERVE_PORT") HostPort string
var InDev = os.Getenv("LISHWIST_IN_DEV") != "" ServePort string
var HostUrl = func() *url.URL { InDev bool
rawUrl := HostRootUrl HostUrl string
if HostPort != "" { }
rawUrl += ":" + HostPort
} var Configuration Config
u, err := url.Parse(rawUrl)
if err != nil { func init() {
log.Fatalln("Couldn't parse host url:", err) Configuration.DatabaseFile = GuaranteeEnv("LISHWIST_DATABASE_FILE")
} Configuration.SessionSecret = GuaranteeEnv("LISHWIST_SESSION_SECRET")
return u Configuration.HostRootUrl = GuaranteeEnv("LISHWIST_HOST_ROOT_URL")
}() Configuration.HostPort = os.Getenv("LISHWIST_HOST_PORT")
Configuration.ServePort = GuaranteeEnv("LISHWIST_SERVE_PORT")
Configuration.InDev = os.Getenv("LISHWIST_IN_DEV") != ""
Configuration.HostUrl = func() string {
rawUrl := Configuration.HostRootUrl
if Configuration.HostPort != "" {
rawUrl += ":" + Configuration.HostPort
}
u, err := url.Parse(rawUrl)
if err != nil {
log.Fatalln("Couldn't parse host url:", err)
}
return u.String()
}()
}

3
http/env/version.go vendored Normal file
View File

@ -0,0 +1,3 @@
package env
var GitVersion string

View File

@ -10,16 +10,16 @@ import (
) )
func main() { func main() {
err := lishwist.Init(env.DatabaseFile) err := lishwist.Init(env.Configuration.DatabaseFile)
if err != nil { if err != nil {
log.Fatalf("Failed to init Lishwist: %s\n", err) log.Fatalf("Failed to init Lishwist: %s\n", err)
} }
useSecureCookies := !env.InDev useSecureCookies := !env.Configuration.InDev
r := server.Create(useSecureCookies) r := server.Create(useSecureCookies)
log.Printf("Running at http://127.0.0.1:%s\n", env.ServePort) log.Printf("Running at http://127.0.0.1:%s\n", env.Configuration.ServePort)
err = http.ListenAndServe(":"+env.ServePort, r) err = http.ListenAndServe(":"+env.Configuration.ServePort, r)
if err != nil { if err != nil {
log.Fatalln("Failed to listen and server:", err) log.Fatalln("Failed to listen and server:", err)
} }

View File

@ -6,6 +6,7 @@ import (
"lishwist/http/session" "lishwist/http/session"
"lishwist/http/templates" "lishwist/http/templates"
"lishwist/http/templates/text"
"github.com/Teajey/rsvp" "github.com/Teajey/rsvp"
) )
@ -18,6 +19,7 @@ type ServeMux struct {
func NewServeMux(store *session.Store) *ServeMux { func NewServeMux(store *session.Store) *ServeMux {
mux := rsvp.NewServeMux() mux := rsvp.NewServeMux()
mux.Config.HtmlTemplate = templates.Template mux.Config.HtmlTemplate = templates.Template
mux.Config.TextTemplate = text.Template
return &ServeMux{ return &ServeMux{
inner: mux, inner: mux,
store: store, store: store,

View File

@ -9,31 +9,28 @@ type Session struct {
written bool written bool
} }
func (s *Session) FlashGet() any { const flashKey = "_flash"
list := s.inner.Flashes()
if len(list) < 1 {
return nil
} else {
s.written = true
return list[0]
}
}
func (s *Session) FlashPeek() any { func (s *Session) FlashGet() any {
flash, ok := s.inner.Values["_flash"] val, ok := s.inner.Values[flashKey]
if !ok { if !ok {
return nil return nil
} }
list := flash.([]any) delete(s.inner.Values, flashKey)
if len(list) < 1 { s.written = true
return val
}
func (s *Session) FlashPeek() any {
val, ok := s.inner.Values[flashKey]
if !ok {
return nil return nil
} else {
return list[0]
} }
return val
} }
func (s *Session) FlashSet(value any) { func (s *Session) FlashSet(value any) {
s.inner.AddFlash(value) s.inner.Values[flashKey] = value
s.written = true s.written = true
} }
@ -47,12 +44,17 @@ func (s *Session) SetValue(key any, value any) {
s.written = true s.written = true
} }
func (s *Session) RemoveValue(key any) {
delete(s.inner.Values, key)
s.written = true
}
func (s *Session) GetValue(key any) any { func (s *Session) GetValue(key any) any {
return s.inner.Values[key] return s.inner.Values[key]
} }
func (s *Session) ClearValues() { func (s *Session) ClearValues() {
s.inner.Values = nil s.inner.Values = make(map[any]any)
s.written = true s.written = true
} }

116
http/routing/account.go Normal file
View File

@ -0,0 +1,116 @@
package routing
import (
"log"
"net/http"
lishwist "lishwist/core"
"lishwist/http/api"
"lishwist/http/response"
"lishwist/http/templates"
"github.com/Teajey/rsvp"
)
type AccountProps struct {
CurrentUsername string
GeneralError string `json:",omitempty"`
PasswordFromAdmin bool `json:",omitempty"`
Password templates.InputProps
ConfirmPassword templates.InputProps
}
func (p *AccountProps) Validate() (valid bool) {
valid = true
if p.Password.Value != p.ConfirmPassword.Value {
p.ConfirmPassword.Error = "Passwords didn't match"
valid = false
}
if !p.Password.Validate() {
valid = false
}
if !p.ConfirmPassword.Validate() {
valid = false
}
return
}
func NewAccountProps(username string, passwordFromAdmin bool, passwordVal, confirmPassVal string) *AccountProps {
return &AccountProps{
CurrentUsername: username,
PasswordFromAdmin: passwordFromAdmin,
Password: templates.InputProps{
Type: "password",
Name: "new_password",
Required: true,
MinLength: 5,
Value: passwordVal,
},
ConfirmPassword: templates.InputProps{
Type: "password",
Name: "confirm_password",
Required: true,
Value: confirmPassVal,
},
}
}
func Account(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
user := app.User()
props := NewAccountProps(user.Name, user.PasswordFromAdmin, "", "")
flash := session.FlashGet()
flashProps, _ := flash.(*AccountProps)
if flashProps != nil {
props.GeneralError = flashProps.GeneralError
props.ConfirmPassword.Error = flashProps.ConfirmPassword.Error
}
return response.Data("account.gotmpl", props)
}
func AccountPost(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
user := app.User()
err := r.ParseForm()
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to parse form")
}
intent := r.Form.Get("intent")
if intent != "set_password" {
return response.Error(http.StatusBadRequest, "Invalid intent %q", intent)
}
newPassword := r.Form.Get("new_password")
confirmPassword := r.Form.Get("confirm_password")
props := NewAccountProps(user.Name, user.PasswordFromAdmin, newPassword, confirmPassword)
valid := props.Validate()
props.Password.Value = ""
props.ConfirmPassword.Value = ""
if !valid {
log.Printf("Invalid account props: %#v\n", props)
session.FlashSet(&props)
return rsvp.SeeOther("/account", props)
}
err = user.SetPassword(newPassword)
if err != nil {
props.GeneralError = "Something went wrong."
log.Printf("Set password failed: %s\n", err)
session.FlashSet(&props)
return rsvp.SeeOther("/account", props)
}
session.RemoveValue("sessionKey")
session.FlashSet(&api.LoginProps{SuccessfulSetPassword: true})
return rsvp.SeeOther("/", "Set password successful!")
}

26
http/routing/config.go Normal file
View File

@ -0,0 +1,26 @@
package routing
import (
lishwist "lishwist/core"
"lishwist/http/env"
"lishwist/http/response"
"net/http"
"github.com/Teajey/rsvp"
)
type HealthProps struct {
GitVersion string
Config env.Config
}
func Health(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
if app.Admin() == nil {
return rsvp.Ok()
}
return rsvp.Response{Body: HealthProps{
GitVersion: env.GitVersion,
Config: env.Configuration,
}}
}

View File

@ -11,7 +11,7 @@ import (
"github.com/Teajey/rsvp" "github.com/Teajey/rsvp"
) )
func ExpectAppSession(next func(*lishwist.Session, http.Header, *http.Request) rsvp.Response) response.HandlerFunc { func ExpectAppSession(next func(*lishwist.Session, *response.Session, http.Header, *http.Request) rsvp.Response) response.HandlerFunc {
return func(session *response.Session, h http.Header, r *http.Request) rsvp.Response { return func(session *response.Session, h http.Header, r *http.Request) rsvp.Response {
sessionKey, ok := session.GetValue("sessionKey").(string) sessionKey, ok := session.GetValue("sessionKey").(string)
if !ok { if !ok {
@ -29,6 +29,6 @@ func ExpectAppSession(next func(*lishwist.Session, http.Header, *http.Request) r
return response.Error(http.StatusInternalServerError, "Something went wrong.") return response.Error(http.StatusInternalServerError, "Something went wrong.")
} }
return next(appSession, h, r) return next(appSession, session, h, r)
} }
} }

View File

@ -16,7 +16,7 @@ type foreignWishlistProps struct {
Gifts []lishwist.Wish Gifts []lishwist.Wish
} }
func ForeignWishlist(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response { func ForeignWishlist(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
userReference := r.PathValue("userReference") userReference := r.PathValue("userReference")
user := app.User() user := app.User()
if user.Reference == userReference { if user.Reference == userReference {

View File

@ -37,7 +37,7 @@ func AdminGroup(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Resp
return response.Data("group_page.gotmpl", p) return response.Data("group_page.gotmpl", p)
} }
func Group(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response { func Group(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
user := app.User() user := app.User()
if user.IsAdmin { if user.IsAdmin {
return AdminGroup(app, h, r) return AdminGroup(app, h, r)
@ -73,7 +73,7 @@ func PublicGroup(s *response.Session, h http.Header, r *http.Request) rsvp.Respo
return response.Data("public_group_page.gotmpl", p) return response.Data("public_group_page.gotmpl", p)
} }
func GroupPost(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response { func GroupPost(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
admin := app.Admin() admin := app.Admin()
if admin == nil { if admin == nil {
return response.NotFound() return response.NotFound()
@ -138,7 +138,7 @@ func GroupPost(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Respo
return response.Data("", group) return response.Data("", group)
} }
func Groups(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response { func Groups(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
admin := app.Admin() admin := app.Admin()
if admin == nil { if admin == nil {
return response.NotFound() return response.NotFound()

View File

@ -12,15 +12,16 @@ import (
) )
type HomeProps struct { type HomeProps struct {
Username string Username string
Gifts []lishwist.Wish Gifts []lishwist.Wish
Todo []lishwist.Wish Todo []lishwist.Wish
Reference string Reference string
HostUrl string HostUrl string
Groups []lishwist.Group Groups []lishwist.Group
AccountAlert bool
} }
func Home(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response { func Home(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
gifts, err := app.GetWishes() gifts, err := app.GetWishes()
if err != nil { if err != nil {
log.Printf("Failed to get gifts: %s\n", err) log.Printf("Failed to get gifts: %s\n", err)
@ -37,11 +38,11 @@ func Home(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response {
log.Printf("Failed to get groups: %s\n", err) log.Printf("Failed to get groups: %s\n", err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(") return response.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(")
} }
p := HomeProps{Username: user.Name, Gifts: gifts, Todo: todo, Reference: user.Reference, HostUrl: env.HostUrl.String(), Groups: groups} p := HomeProps{Username: user.Name, Gifts: gifts, Todo: todo, Reference: user.Reference, HostUrl: env.Configuration.HostUrl, Groups: groups, AccountAlert: user.PasswordFromAdmin}
return response.Data("home.gotmpl", p) return response.Data("home.gotmpl", p)
} }
func HomePost(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response { func HomePost(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
return response.Error(http.StatusBadRequest, "Failed to parse form") return response.Error(http.StatusBadRequest, "Failed to parse form")

View File

@ -24,12 +24,9 @@ func Login(s *response.Session, h http.Header, r *http.Request) rsvp.Response {
props.GeneralError = flashProps.GeneralError props.GeneralError = flashProps.GeneralError
props.Username.Error = flashProps.Username.Error props.Username.Error = flashProps.Username.Error
props.Password.Error = flashProps.Password.Error props.Password.Error = flashProps.Password.Error
}
flash = s.FlashGet() props.SuccessfulRegistration = flashProps.SuccessfulRegistration
successfulReg, _ := flash.(bool) props.SuccessfulSetPassword = flashProps.SuccessfulSetPassword
if successfulReg {
props.SuccessfulRegistration = true
} }
return rsvp.Response{TemplateName: "login.gotmpl", Body: props} return rsvp.Response{TemplateName: "login.gotmpl", Body: props}
@ -61,7 +58,7 @@ func LoginPost(session *response.Session, h http.Header, r *http.Request) rsvp.R
var targ lishwist.ErrorInvalidCredentials var targ lishwist.ErrorInvalidCredentials
switch { switch {
case errors.As(err, &targ): case errors.As(err, &targ):
props.GeneralError = "Username or password invalid" props.GeneralError = "Username or password invalid. If you're having trouble accessing your account, you may want to consider asking the System Admin (Thomas) to reset your password"
session.FlashSet(&props) session.FlashSet(&props)
log.Printf("Invalid credentials: %s: %#v\n", err, props) log.Printf("Invalid credentials: %s: %#v\n", err, props)
return resp return resp

View File

@ -61,6 +61,6 @@ func RegisterPost(s *response.Session, h http.Header, r *http.Request) rsvp.Resp
return rsvp.SeeOther(r.URL.Path, props) return rsvp.SeeOther(r.URL.Path, props)
} }
s.FlashSet(true) s.FlashSet(&api.LoginProps{SuccessfulRegistration: true})
return rsvp.SeeOther("/", "Registration successful!") return rsvp.SeeOther("/", "Registration successful!")
} }

View File

@ -8,7 +8,7 @@ import (
"github.com/Teajey/rsvp" "github.com/Teajey/rsvp"
) )
func Users(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response { func Users(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
admin := app.Admin() admin := app.Admin()
if admin == nil { if admin == nil {
return response.NotFound() return response.NotFound()
@ -22,7 +22,7 @@ func Users(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response
return response.Data("", users) return response.Data("", users)
} }
func User(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response { func User(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
admin := app.Admin() admin := app.Admin()
if admin == nil { if admin == nil {
return response.NotFound() return response.NotFound()
@ -41,7 +41,7 @@ func User(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response {
return response.Data("", user) return response.Data("", user)
} }
func UserPost(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response { func UserPost(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
admin := app.Admin() admin := app.Admin()
if admin == nil { if admin == nil {
return response.NotFound() return response.NotFound()
@ -53,23 +53,33 @@ func UserPost(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Respon
} }
reference := r.PathValue("userReference") reference := r.PathValue("userReference")
if reference == app.User().Reference {
return response.Error(http.StatusForbidden, "You cannot delete yourself.")
}
intent := r.Form.Get("intent") intent := r.Form.Get("intent")
switch intent { if intent != "" {
case "delete": switch intent {
err = admin.UserSetLive(reference, false) case "delete":
if err != nil { if reference == app.User().Reference {
return response.Error(http.StatusInternalServerError, "Failed to delete user: %s", err) return response.Error(http.StatusForbidden, "You cannot delete yourself.")
} }
case "rename": err = admin.UserSetLive(reference, false)
name := r.Form.Get("display_name") if err != nil {
err = admin.RenameUser(reference, name) return response.Error(http.StatusInternalServerError, "Failed to delete user: %s", err)
if err != nil { }
return response.Error(http.StatusInternalServerError, "Failed to rename user: %s", err) case "rename":
name := r.Form.Get("display_name")
err = admin.RenameUser(reference, name)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to rename user: %s", err)
}
case "set_password":
newPassword := r.Form.Get("new_password")
err = admin.SetUserPassword(reference, newPassword)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to set new password: %s", err)
}
default:
return response.Error(http.StatusBadRequest, "Invalid intent %q", intent)
} }
} }

View File

@ -40,7 +40,7 @@ func WishlistDelete(app *lishwist.Session, h http.Header, r *http.Request) rsvp.
return rsvp.SeeOther("/", "Wish deleted") return rsvp.SeeOther("/", "Wish deleted")
} }
func ForeignWishlistPost(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response { func ForeignWishlistPost(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
return response.Error(http.StatusBadRequest, "Failed to parse form") return response.Error(http.StatusBadRequest, "Failed to parse form")

View File

@ -32,8 +32,9 @@ func prefixPermanentRedirect(before, after string) response.HandlerFunc {
func Create(useSecureCookies bool) *router.VisibilityRouter { func Create(useSecureCookies bool) *router.VisibilityRouter {
gob.Register(&api.RegisterProps{}) gob.Register(&api.RegisterProps{})
gob.Register(&api.LoginProps{}) gob.Register(&api.LoginProps{})
gob.Register(&routing.AccountProps{})
store := session.NewInMemoryStore([]byte(env.SessionSecret)) store := session.NewInMemoryStore([]byte(env.Configuration.SessionSecret))
store.Options.MaxAge = 86_400 // 24 hours in seconds store.Options.MaxAge = 86_400 // 24 hours in seconds
store.Options.Secure = useSecureCookies store.Options.Secure = useSecureCookies
store.Options.HttpOnly = true store.Options.HttpOnly = true
@ -49,6 +50,8 @@ func Create(useSecureCookies bool) *router.VisibilityRouter {
r.Public.HandleFunc("POST /", routing.LoginPost) r.Public.HandleFunc("POST /", routing.LoginPost)
r.Public.HandleFunc("POST /register", routing.RegisterPost) r.Public.HandleFunc("POST /register", routing.RegisterPost)
r.Private.HandleFunc("GET /account", routing.ExpectAppSession(routing.Account))
r.Private.HandleFunc("GET /health", routing.ExpectAppSession(routing.Health))
r.Private.HandleFunc("GET /", routing.NotFound) r.Private.HandleFunc("GET /", routing.NotFound)
r.Private.HandleFunc("GET /groups", routing.ExpectAppSession(routing.Groups)) r.Private.HandleFunc("GET /groups", routing.ExpectAppSession(routing.Groups))
r.Private.HandleFunc("GET /groups/{groupReference}", routing.ExpectAppSession(routing.Group)) r.Private.HandleFunc("GET /groups/{groupReference}", routing.ExpectAppSession(routing.Group))
@ -56,6 +59,7 @@ func Create(useSecureCookies bool) *router.VisibilityRouter {
r.Private.HandleFunc("GET /users", routing.ExpectAppSession(routing.Users)) r.Private.HandleFunc("GET /users", routing.ExpectAppSession(routing.Users))
r.Private.HandleFunc("GET /users/{userReference}", routing.ExpectAppSession(routing.User)) r.Private.HandleFunc("GET /users/{userReference}", routing.ExpectAppSession(routing.User))
r.Private.HandleFunc("GET /{$}", routing.ExpectAppSession(routing.Home)) r.Private.HandleFunc("GET /{$}", routing.ExpectAppSession(routing.Home))
r.Private.HandleFunc("POST /account", routing.ExpectAppSession(routing.AccountPost))
r.Private.HandleFunc("POST /groups/{groupReference}", routing.ExpectAppSession(routing.GroupPost)) r.Private.HandleFunc("POST /groups/{groupReference}", routing.ExpectAppSession(routing.GroupPost))
r.Private.HandleFunc("POST /lists/{userReference}", routing.ExpectAppSession(routing.ForeignWishlistPost)) r.Private.HandleFunc("POST /lists/{userReference}", routing.ExpectAppSession(routing.ForeignWishlistPost))
r.Private.HandleFunc("POST /logout", routing.LogoutPost) r.Private.HandleFunc("POST /logout", routing.LogoutPost)

View File

@ -0,0 +1,83 @@
<!doctype html>
<html>
<head>
{{template "head" .}}
</head>
<body>
<div style="height: 100svh;" class="d-flex flex-column">
<div class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<div class="navbar-brand">Lishwist</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarToggle"
aria-controls="navbarToggle" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarToggle">
<nav>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
</ul>
</nav>
<div class="flex-grow-1"></div>
<ul class="navbar-nav">
<li class="nav-item">
<div class="dropdown">
<button class="nav-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Logged in as '{{.CurrentUsername}}'
</button>
<ul class="dropdown-menu">
<li>
<form class="d-contents" method="post" action="/logout">
<button class="dropdown-item" type="submit">Logout</button>
</form>
</li>
</ul>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
{{with .GeneralError}}
<div class="alert alert-danger" role="alert">
<p class="mb-0">{{.}}</p>
</div>
{{end}}
<section class="card mb-4">
<div class="card-body">
<h2>Submit new password</h2>
<div class="form-text">You can set a new password by submitting this form.</div>
{{with .PasswordFromAdmin}}
<div class="alert alert-warning" role="alert">
<p class="mb-0"><span class="badge text-bg-danger">!</span> This is recommended, because your password has
been set by the admin. Change it to
something they don't know!</p>
</div>
{{end}}
<form method="post">
<div class="d-flex flex-column">
<label>
New Password
{{template "input" .Password}}
</label>
<label>
Confirm password
{{template "input" .ConfirmPassword}}
</label>
<button class="btn btn-primary" type="submit" name="intent" value="set_password">Submit</button>
</div>
</form>
</div>
</section>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,130 +1,138 @@
<!doctype html> <!doctype html>
<html> <html>
<head>
{{template "head" .}}
</head>
<body> <head>
<div style="height: 100svh;" class="d-flex flex-column"> {{template "head" .}}
<div class="navbar navbar-expand-lg bg-body-tertiary"> </head>
<div class="container-fluid">
<div class="navbar-brand">Lishwist</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarToggle"
aria-controls="navbarToggle" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarToggle">
<div class="flex-grow-1"></div>
<ul class="navbar-nav">
<li class="nav-item"><button class="btn btn-success"
onclick="navigator.clipboard.writeText('{{.HostUrl}}/lists/{{.Reference}}'); alert('The share link to your wishlist has been copied to your clipboard. Anyone with the link will be able to claim gifts for you. Share it with someone!');">Copy
share link</button></li>
<li class="nav-item">
<div class="dropdown">
<button class="nav-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Logged in as '{{.Username}}'
</button>
<ul class="dropdown-menu">
<li>
<form class="d-contents" method="post" action="/logout">
<button class="dropdown-item" type="submit">Logout</button>
</form>
</li>
</ul>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card mb-4">
<div class="card-body">
<h2>Your wishlist</h2>
{{with .Gifts}}
<form method="post" onchange="acceptNames(this, 'deleteSubmit', 'gift')" autocomplete="off">
<ul class="list-group mb-3">
{{range .}}
<li class="list-group-item">
<input id="wishlist_select_{{.Id}}" class="form-check-input" type="checkbox" name="gift" value="{{.Id}}">
<label class="form-check-label stretched-link" for="wishlist_select_{{.Id}}">
{{.Name}}
</label>
</li>
{{end}}
</ul>
<button id="deleteSubmit" class="btn btn-danger mb-3" type="submit" name="intent" value="delete_idea"
disabled>Delete</button>
</form>
{{else}}
<p>Your list is empty. Think of some things to add!</p>
{{end}}
<form method="post">
<div class="input-group">
<input class="form-control" name="gift_name" required placeholder="Write a gift idea here" autofocus>
<button class="btn btn-primary" type="submit" name="intent" value="add_idea">Add gift idea</button>
</div>
</form>
</div>
</section>
<section class="card mb-4"> <body>
<div class="card-body"> <div style="height: 100svh;" class="d-flex flex-column">
<h2>Your todo list</h2> <div class="navbar navbar-expand-lg bg-body-tertiary">
{{with .Todo}} <div class="container-fluid">
<form method="post" <div class="navbar-brand">Lishwist</div>
onchange="acceptNames(this, 'unclaimSubmit', 'gift'); acceptNames(this, 'completeSubmit', 'gift')" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarToggle"
autocomplete="off"> aria-controls="navbarToggle" aria-expanded="false" aria-label="Toggle navigation">
<ul class="list-group mb-3"> <span class="navbar-toggler-icon"></span>{{if .AccountAlert}} <span
{{range .}} class="badge text-bg-danger">!</span>{{end}}
<li class="list-group-item{{if .Sent}} list-group-item-light{{end}}"> </button>
<input id="todo_select_{{.Id}}" class="form-check-input" type="checkbox" {{if .Sent}} <div class="collapse navbar-collapse" id="navbarToggle">
aria-describedby="todo_detail_{{.Id}}" disabled{{else}} name="gift" value="{{.Id}}" {{end}}> <div class="flex-grow-1"></div>
<label for="todo_select_{{.Id}}" class="form-check-label"> <ul class="navbar-nav">
<em> <li class="nav-item"><button class="btn btn-success"
{{if .Sent}} onclick="navigator.clipboard.writeText('{{.HostUrl}}/lists/{{.Reference}}'); alert('The share link to your wishlist has been copied to your clipboard. Anyone with the link will be able to claim gifts for you. Share it with someone!');">Copy
<s>{{.Name}}</s> share link</button></li>
{{else}} <li class="nav-item">
{{.Name}} <div class="dropdown">
{{end}} <button class="nav-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
</em> Logged in as '{{.Username}}'{{if .AccountAlert}} <span class="badge text-bg-danger">!</span>{{end}}
</label> </button>
<span id="todo_detail_{{.Id}}"> <ul class="dropdown-menu">
for <a href="/lists/{{.RecipientRef}}">{{.RecipientName}}</a> <li>
</span> <a class="dropdown-item" href="/account">Account{{if .AccountAlert}} <span
class="badge text-bg-danger">!</span>{{end}}</a>
</li> </li>
{{end}} <li>
</ul> <form class="d-contents" method="post" action="/logout">
<button id="unclaimSubmit" class="btn btn-warning" type="submit" name="intent" value="unclaim_todo" <button class="dropdown-item" type="submit">Logout</button>
disabled>Unclaim</button> </form>
<button id="completeSubmit" class="btn btn-success" type="submit" name="intent" value="complete_todo"
disabled>Complete</button>
</form>
{{else}}
<p class="mb-0">When you claim gifts for others, they will appear here.</p>
{{end}}
</div>
</section>
<section class="card">
<div class="card-body">
<h2>Your groups</h2>
{{with .Groups}}
<ul class="list-group">
{{range .}}
<li class="list-group-item">
<a href="/groups/{{.Reference}}">{{.Name}}</a>
</li> </li>
{{end}}
</ul> </ul>
{{else}} </div>
<p>You don't belong to any groups</p> </li>
{{end}} </ul>
</div>
</section>
</div> </div>
</div> </div>
</div> </div>
</body> <div class="overflow-y-scroll flex-grow-1">
</html> <div class="container py-5">
<section class="card mb-4">
<div class="card-body">
<h2>Your wishlist</h2>
{{with .Gifts}}
<form method="post" onchange="acceptNames(this, 'deleteSubmit', 'gift')" autocomplete="off">
<ul class="list-group mb-3">
{{range .}}
<li class="list-group-item">
<input id="wishlist_select_{{.Id}}" class="form-check-input" type="checkbox" name="gift"
value="{{.Id}}">
<label class="form-check-label stretched-link" for="wishlist_select_{{.Id}}">
{{.Name}}
</label>
</li>
{{end}}
</ul>
<button id="deleteSubmit" class="btn btn-danger mb-3" type="submit" name="intent" value="delete_idea"
disabled>Delete</button>
</form>
{{else}}
<p>Your list is empty. Think of some things to add!</p>
{{end}}
<form method="post">
<div class="input-group">
<input class="form-control" name="gift_name" required placeholder="Write a gift idea here" autofocus>
<button class="btn btn-primary" type="submit" name="intent" value="add_idea">Add gift idea</button>
</div>
</form>
</div>
</section>
<section class="card mb-4">
<div class="card-body">
<h2>Your todo list</h2>
{{with .Todo}}
<form method="post"
onchange="acceptNames(this, 'unclaimSubmit', 'gift'); acceptNames(this, 'completeSubmit', 'gift')"
autocomplete="off">
<ul class="list-group mb-3">
{{range .}}
<li class="list-group-item{{if .Sent}} list-group-item-light{{end}}">
<input id="todo_select_{{.Id}}" class="form-check-input" type="checkbox" {{if .Sent}}
aria-describedby="todo_detail_{{.Id}}" disabled{{else}} name="gift" value="{{.Id}}" {{end}}>
<label for="todo_select_{{.Id}}" class="form-check-label">
<em>
{{if .Sent}}
<s>{{.Name}}</s>
{{else}}
{{.Name}}
{{end}}
</em>
</label>
<span id="todo_detail_{{.Id}}">
for <a href="/lists/{{.RecipientRef}}">{{.RecipientName}}</a>
</span>
</li>
{{end}}
</ul>
<button id="unclaimSubmit" class="btn btn-warning" type="submit" name="intent" value="unclaim_todo"
disabled>Unclaim</button>
<button id="completeSubmit" class="btn btn-success" type="submit" name="intent" value="complete_todo"
disabled>Complete</button>
</form>
{{else}}
<p class="mb-0">When you claim gifts for others, they will appear here.</p>
{{end}}
</div>
</section>
<section class="card">
<div class="card-body">
<h2>Your groups</h2>
{{with .Groups}}
<ul class="list-group">
{{range .}}
<li class="list-group-item">
<a href="/groups/{{.Reference}}">{{.Name}}</a>
</li>
{{end}}
</ul>
{{else}}
<p>You don't belong to any groups</p>
{{end}}
</div>
</section>
</div>
</div>
</div>
</body>
</html>

View File

@ -23,6 +23,11 @@
<p class="mb-0">Registration successful. Now you can login.</p> <p class="mb-0">Registration successful. Now you can login.</p>
</div> </div>
{{end}} {{end}}
{{if .SuccessfulSetPassword}}
<div class="alert alert-success" role="alert">
<p class="mb-0">Set password successfully. You can now login back in.</p>
</div>
{{end}}
{{with .GeneralError}} {{with .GeneralError}}
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
<p class="mb-0">{{.}}</p> <p class="mb-0">{{.}}</p>

View File

View File

View File

@ -0,0 +1,8 @@
Just a normal login form. Takes the following form values:
username string
password string
Upon successful login, a redirect to the same URL on the protected router is issued.
A registration page is also available at /register

View File

@ -0,0 +1,11 @@
Register with the following form values:
username string
newPassword string
confirmPassword string
All must be provided, username must be unique, newPassword and confirmPassword must be identical.
It's worth considering that having two password form parameters isn't very helpful on the command line, it's only really useful to the browser.
Therefore, it might be a good idea to have Javascript check these two fields are identical, and submit, rather than having this
checked by the server. Meaning only one password field needs to be submitted to the server. That does block non-Javascript browsers tho :^P

View File

@ -0,0 +1,17 @@
package text
import (
"text/template"
)
var Template *template.Template
func init() {
Template = load()
}
func load() *template.Template {
t := template.Must(template.ParseGlob("templates/text/*.gotmpl"))
return t
}

3
scripts/git-version Executable file
View File

@ -0,0 +1,3 @@
#!/bin/sh
echo $(git rev-parse HEAD)$(test -n "$(git status --porcelain)" && echo "*")

View File

@ -2,7 +2,7 @@
APISNAP=api.snap.txt APISNAP=api.snap.txt
./scripts/api_snapshot lishwist/core > core/$APISNAP ./scripts/api_snapshot ./core > core/$APISNAP
git diff --quiet core/$APISNAP git diff --quiet core/$APISNAP
if [[ $? -ne 0 ]]; then if [[ $? -ne 0 ]]; then