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
var ErrorUsernameTaken = errors.New("Username is taken")
var ErrorUsernameTaken = errors.New("username is taken")
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) SetUserPassword(userReference string, newPassword string) error
func (u *Admin) UserSetLive(userReference string, setting bool) error
type ErrorInvalidCredentials error
@ -87,6 +89,7 @@ type User struct {
Reference string
IsAdmin bool
IsLive bool
PasswordFromAdmin bool
}
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) SetPassword(newPassword string) error
func (u *User) WishCount() (int, error)
type Wish struct {

View File

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

View File

@ -7,7 +7,7 @@ import (
"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) {
if username == "" {
@ -24,17 +24,17 @@ func Register(username, newPassword string) (*User, error) {
hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost)
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()
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)
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

View File

@ -23,7 +23,7 @@ func (s *Session) User() User {
func SessionFromKey(key string) (*Session, error) {
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
err := db.Connection.QueryRow(query, key).Scan(
&s.user.Id,
@ -32,6 +32,7 @@ func SessionFromKey(key string) (*Session, error) {
&s.user.Reference,
&s.user.IsAdmin,
&s.user.IsLive,
&s.user.PasswordFromAdmin,
&s.Key,
&expiry,
)

View File

@ -4,6 +4,7 @@ import (
"fmt"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"lishwist/core/internal/db"
"lishwist/core/internal/normalize"
@ -12,11 +13,11 @@ import (
type User struct {
Id string
NormalName string
// TODO: rename to DisplayName
Name string
Reference string
IsAdmin bool
IsLive bool
PasswordFromAdmin bool
}
func queryManyUsers(query string, args ...any) ([]User, error) {
@ -28,7 +29,7 @@ func queryManyUsers(query string, args ...any) ([]User, error) {
users := []User{}
for rows.Next() {
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 {
return nil, err
}
@ -54,7 +55,7 @@ func queryOneUser(query string, args ...any) (*User, error) {
func getUserByName(username string) (*User, error) {
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)
}
@ -91,12 +92,12 @@ func (u *User) getPassHash() ([]byte, 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)
}
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)
}
@ -111,7 +112,7 @@ func hasUsers() (bool, 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)
}
@ -172,3 +173,29 @@ func (u *Admin) RenameUser(userReference string, displayName string) error {
}
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 {
GeneralError string `json:",omitempty"`
SuccessfulRegistration bool `json:",omitempty"`
SuccessfulSetPassword bool `json:",omitempty"`
Username 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

38
http/env/env.go vendored
View File

@ -14,20 +14,34 @@ func GuaranteeEnv(key string) string {
return variable
}
var DatabaseFile = GuaranteeEnv("LISHWIST_DATABASE_FILE")
var SessionSecret = GuaranteeEnv("LISHWIST_SESSION_SECRET")
var HostRootUrl = GuaranteeEnv("LISHWIST_HOST_ROOT_URL")
var HostPort = os.Getenv("LISHWIST_HOST_PORT")
var ServePort = GuaranteeEnv("LISHWIST_SERVE_PORT")
var InDev = os.Getenv("LISHWIST_IN_DEV") != ""
var HostUrl = func() *url.URL {
rawUrl := HostRootUrl
if HostPort != "" {
rawUrl += ":" + HostPort
type Config struct {
DatabaseFile string
SessionSecret string
HostRootUrl string
HostPort string
ServePort string
InDev bool
HostUrl string
}
var Configuration Config
func init() {
Configuration.DatabaseFile = GuaranteeEnv("LISHWIST_DATABASE_FILE")
Configuration.SessionSecret = GuaranteeEnv("LISHWIST_SESSION_SECRET")
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
}()
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() {
err := lishwist.Init(env.DatabaseFile)
err := lishwist.Init(env.Configuration.DatabaseFile)
if err != nil {
log.Fatalf("Failed to init Lishwist: %s\n", err)
}
useSecureCookies := !env.InDev
useSecureCookies := !env.Configuration.InDev
r := server.Create(useSecureCookies)
log.Printf("Running at http://127.0.0.1:%s\n", env.ServePort)
err = http.ListenAndServe(":"+env.ServePort, r)
log.Printf("Running at http://127.0.0.1:%s\n", env.Configuration.ServePort)
err = http.ListenAndServe(":"+env.Configuration.ServePort, r)
if err != nil {
log.Fatalln("Failed to listen and server:", err)
}

View File

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

View File

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

View File

@ -16,7 +16,7 @@ type foreignWishlistProps struct {
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")
user := app.User()
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)
}
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()
if user.IsAdmin {
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)
}
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()
if admin == nil {
return response.NotFound()
@ -138,7 +138,7 @@ func GroupPost(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Respo
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()
if admin == nil {
return response.NotFound()

View File

@ -18,9 +18,10 @@ type HomeProps struct {
Reference string
HostUrl string
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()
if err != nil {
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)
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)
}
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()
if err != nil {
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.Username.Error = flashProps.Username.Error
props.Password.Error = flashProps.Password.Error
}
flash = s.FlashGet()
successfulReg, _ := flash.(bool)
if successfulReg {
props.SuccessfulRegistration = true
props.SuccessfulRegistration = flashProps.SuccessfulRegistration
props.SuccessfulSetPassword = flashProps.SuccessfulSetPassword
}
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
switch {
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)
log.Printf("Invalid credentials: %s: %#v\n", err, props)
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)
}
s.FlashSet(true)
s.FlashSet(&api.LoginProps{SuccessfulRegistration: true})
return rsvp.SeeOther("/", "Registration successful!")
}

View File

@ -8,7 +8,7 @@ import (
"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()
if admin == nil {
return response.NotFound()
@ -22,7 +22,7 @@ func Users(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response
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()
if admin == nil {
return response.NotFound()
@ -41,7 +41,7 @@ func User(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response {
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()
if admin == nil {
return response.NotFound()
@ -53,14 +53,15 @@ func UserPost(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Respon
}
reference := r.PathValue("userReference")
if reference == app.User().Reference {
return response.Error(http.StatusForbidden, "You cannot delete yourself.")
}
intent := r.Form.Get("intent")
if intent != "" {
switch intent {
case "delete":
if reference == app.User().Reference {
return response.Error(http.StatusForbidden, "You cannot delete yourself.")
}
err = admin.UserSetLive(reference, false)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to delete user: %s", err)
@ -71,6 +72,15 @@ func UserPost(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Respon
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)
}
}
user, err := lishwist.GetUserByReference(reference)

View File

@ -40,7 +40,7 @@ func WishlistDelete(app *lishwist.Session, h http.Header, r *http.Request) rsvp.
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()
if err != nil {
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 {
gob.Register(&api.RegisterProps{})
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.Secure = useSecureCookies
store.Options.HttpOnly = true
@ -49,6 +50,8 @@ func Create(useSecureCookies bool) *router.VisibilityRouter {
r.Public.HandleFunc("POST /", routing.LoginPost)
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 /groups", routing.ExpectAppSession(routing.Groups))
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/{userReference}", routing.ExpectAppSession(routing.User))
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 /lists/{userReference}", routing.ExpectAppSession(routing.ForeignWishlistPost))
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,17 +1,19 @@
<!doctype html>
<html>
<head>
{{template "head" .}}
</head>
<body>
<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>
<span class="navbar-toggler-icon"></span>{{if .AccountAlert}} <span
class="badge text-bg-danger">!</span>{{end}}
</button>
<div class="collapse navbar-collapse" id="navbarToggle">
<div class="flex-grow-1"></div>
@ -22,9 +24,13 @@
<li class="nav-item">
<div class="dropdown">
<button class="nav-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Logged in as '{{.Username}}'
Logged in as '{{.Username}}'{{if .AccountAlert}} <span class="badge text-bg-danger">!</span>{{end}}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="/account">Account{{if .AccountAlert}} <span
class="badge text-bg-danger">!</span>{{end}}</a>
</li>
<li>
<form class="d-contents" method="post" action="/logout">
<button class="dropdown-item" type="submit">Logout</button>
@ -47,7 +53,8 @@
<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}}">
<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>
@ -126,5 +133,6 @@
</div>
</div>
</div>
</body>
</body>
</html>

View File

@ -23,6 +23,11 @@
<p class="mb-0">Registration successful. Now you can login.</p>
</div>
{{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}}
<div class="alert alert-danger" role="alert">
<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
./scripts/api_snapshot lishwist/core > core/$APISNAP
./scripts/api_snapshot ./core > core/$APISNAP
git diff --quiet core/$APISNAP
if [[ $? -ne 0 ]]; then