Compare commits

..

46 Commits

Author SHA1 Message Date
Teajey 86c1e4fea3
Add basic event logging 2025-12-09 20:03:38 +09:00
Thomas Williams 192da1a74f Merge pull request 'UserList and GroupList for XML' (#28) from gitea-24 into main
Reviewed-on: #28
2025-12-09 04:23:12 +13:00
Teajey 0d0aeffef1
UserList and GroupList for XML 2025-12-09 00:20:54 +09:00
Teajey 7bd73b4ddc
Rephrasing 2025-12-08 01:19:54 +09:00
Teajey b9d1d1c1e1
Remove account link from account page 2025-12-08 01:16:38 +09:00
Teajey 2291a338e6
'Logged in as' show alert 2025-12-08 01:16:18 +09:00
Teajey 609ccfc0c4
Navbar template 2025-12-08 00:48:47 +09:00
Teajey d6de403880
Remove GO* settings 2025-12-07 22:18:55 +09:00
Teajey 0376cecb82
Use error page template for errors 2025-12-07 22:13:43 +09:00
Teajey a2cd38617a
Not found error for public group 2025-12-07 22:12:44 +09:00
Teajey 1ad2b3e097
Support basic auth for login and registration 2025-12-07 21:40:03 +09:00
Teajey 9c9e0ebaff
Build script 2025-12-07 18:11:11 +09:00
Teajey c997908d3c
Add third migration script 2025-12-07 18:00:34 +09:00
Teajey fd2cd3b358
Add missing query value 2025-12-07 18:00:11 +09:00
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
Thomas Williams a2932d7b1c Merge pull request 'Link fixes' (#21) from link-fixes into main
Reviewed-on: #21
2025-09-14 15:24:38 +12:00
Teajey 69b7f9717a
Update HTML links 2025-09-14 12:23:06 +09:00
Teajey cc7c0d0834
Resource fixes 2025-09-14 12:21:42 +09:00
Thomas Williams 24bc67a8e2 Merge pull request 'Fix username display mixups' (#20) from username-display-mixups into main
Reviewed-on: #20
2025-09-13 04:39:08 +12:00
Teajey af13dc4558
Fix username display mixups 2025-09-13 01:37:54 +09:00
Thomas Williams 4783ee7fe9 Merge pull request 'Get user after edit' (#19) from 16-allow-admin-to-rename-users into main
Reviewed-on: #19
2025-09-13 04:10:59 +12:00
Thomas Williams a4b166e4f0 Merge branch 'main' into 16-allow-admin-to-rename-users 2025-09-13 04:10:50 +12:00
Teajey 189719ec02
Get user after edit 2025-09-13 01:10:10 +09:00
Thomas Williams 24697f40dd Merge pull request 'Allow admin to rename user' (#18) from 16-allow-admin-to-rename-users into main
Reviewed-on: #18
2025-09-13 04:07:47 +12:00
Teajey 73a91be228
Allow admin to rename user 2025-09-13 01:06:16 +09:00
Thomas Williams 7147a4378d Merge pull request 'Use RSVP' (#15) from rsvp-lib into main
Reviewed-on: #15
2025-09-13 03:06:22 +12:00
Teajey 92ad5f5e90
Sort wishes by sent 2025-09-13 00:01:36 +09:00
Teajey 935d6c7a28
feat: remove client package 2025-09-12 23:54:41 +09:00
Teajey cd41c55c02
Use RSVP 0.13.1 2025-09-08 19:51:36 +09:00
Teajey cffeede0dc
Separate handler declaration 2025-09-06 13:02:34 +09:00
Teajey d909adb6fa
Moved permanently redirects 2025-08-26 20:44:56 +09:00
Teajey a1ac719229
go work sync 2025-08-26 20:19:18 +09:00
Teajey 98853e4efd
Add lishwist/client to workspace 2025-08-26 20:18:19 +09:00
Teajey eae0a7e0e3
Move all scripts to root 2025-08-26 20:11:26 +09:00
Teajey abb9c54036
feat: internally managed session 2025-08-26 20:02:15 +09:00
Teajey 57e18ae0ce
Make session user inaccessible 2025-08-25 21:40:47 +09:00
Teajey a826417745
feat: api snapshot pre-commit 2025-08-25 21:18:04 +09:00
Teajey c763ff40d4
RSVP 0.13.0 and fix templates 2025-08-22 20:29:47 +09:00
Teajey dfa2525714
Use RSVP 0.11.0 2025-08-20 14:04:33 +09:00
Teajey d33c02a5ac
Rename internal rsvp package
To prevent conflicts when github.com/Teajey/rsvp moves in
2025-08-19 16:43:28 +09:00
Thomas Williams 0603386f4c Merge pull request 'fix: migrate display_name' (#14) from post-core-sep-fixes into main
Reviewed-on: #14
2025-06-27 03:27:41 +12:00
84 changed files with 2314 additions and 1157 deletions

View File

@ -5,7 +5,7 @@ type Admin struct {
}
func (s *Session) Admin() *Admin {
if s.User.IsAdmin {
if s.User().IsAdmin {
return &Admin{s}
} else {
return nil

127
core/api.snap.txt Normal file
View File

@ -0,0 +1,127 @@
package lishwist // import "."
VARIABLES
var ErrorUsernameTaken = errors.New("username is taken")
FUNCTIONS
func Init(dataSourceName string) error
func PrintTables(d *sql.DB)
func PrintViews(d *sql.DB)
TYPES
type Admin struct {
// Has unexported fields.
}
func (a *Admin) AddUserToGroup(userId, groupId string) error
func (a *Admin) CreateGroup(name string, reference string) (*Group, error)
func (*Admin) GetUser(id string) (*User, error)
func (a *Admin) ListEvents() ([]Event, error)
func (a *Admin) ListGroups() ([]Group, error)
func (*Admin) ListUsers() ([]User, error)
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
type Event struct {
Id string
ActorId string
ActionType string
TargetType string
TargetId string
CreatedAt time.Time
}
type Group struct {
Id string
Name string
Reference string
Members []User
}
func GetGroupByReference(reference string) (*Group, error)
func (g *Group) MemberIndex(userId string) int
type Session struct {
Key string
Expiry time.Time
// Has unexported fields.
}
func Login(username, password string, sessionMaxAge time.Duration) (*Session, error)
func SessionFromKey(key string) (*Session, error)
func (s *Session) Admin() *Admin
func (s *Session) ClaimWishes(claims, unclaims []string) error
func (s *Session) CompleteWishes(claims []string) error
func (s *Session) GetGroupByReference(reference string) (*Group, error)
func (u *Session) GetGroups() ([]Group, error)
func (s *Session) GetOthersWishes(userReference string) ([]Wish, error)
func (s *Session) GetWishes() ([]Wish, error)
func (s *Session) MakeWish(name string) (string, error)
func (s *Session) RecindWishesForUser(ids ...string) error
func (s *Session) RevokeWishes(ids ...string) error
func (u *Session) SuggestWishForUser(otherUserReference string, wishName string) error
func (s *Session) User() User
type User struct {
Id string
NormalName string
Name string
Reference string
IsAdmin bool
IsLive bool
PasswordFromAdmin bool
}
func GetUserByReference(reference string) (*User, error)
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 {
Id string
Name string
ClaimantId string `json:",omitempty"`
ClaimantName string `json:",omitempty"`
Sent bool
RecipientId string `json:",omitempty"`
RecipientName string `json:",omitempty"`
RecipientRef string `json:",omitempty"`
CreatorId string `json:",omitempty"`
CreatorName string `json:",omitempty"`
}

150
core/event.go Normal file
View File

@ -0,0 +1,150 @@
package lishwist
import (
"fmt"
"log"
"strings"
"time"
"lishwist/core/internal/db"
)
const (
eventActionCreate = "CREATE"
eventActionHide = "HIDE"
eventActionUnhide = "UNHIDE"
eventActionClaim = "CLAIM"
eventActionUnclaim = "UNCLAIM"
eventActionComplete = "COMPLETE"
// eventActionDelete = "DELETE" NOTE: We can't have this, because there'll be no target to reference
)
const (
eventTargetGroup = "GROUP"
eventTargetUser = "USER"
eventTargetWish = "WISH"
eventTargetGroupMember = "GROUP_MEMBER"
)
type Event struct {
Id string
ActorId string
ActionType string
TargetType string
TargetId string
CreatedAt time.Time
}
// type EventCreateGroupMember struct {
// Event
// Actor User
// User
// Group
// }
func queryManyEvents(query string, args ...any) ([]Event, error) {
rows, err := db.Connection.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
events := []Event{}
for rows.Next() {
var g Event
var createdAt string
err = rows.Scan(&g.Id, &g.ActorId, &g.ActionType, &g.TargetType, &g.TargetId, &createdAt)
if err != nil {
return nil, err
}
g.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return nil, fmt.Errorf("failed to parse created_at: %w", err)
}
events = append(events, g)
}
err = rows.Err()
if err != nil {
return nil, err
}
return events, nil
}
func queryOneEvent(query string, args ...any) (*Event, error) {
events, err := queryManyEvents(query, args...)
if err != nil {
return nil, err
}
if len(events) < 1 {
return nil, nil
}
return &events[0], nil
}
func (a *Admin) ListEvents() ([]Event, error) {
query := "SELECT id, actor_id, action_type, target_type, target_id, created_at FROM event;"
return queryManyEvents(query)
}
func recordEvent(actorId, actionType, targetType string, targetIds ...string) {
// TODO: If this were to accept sql.Tx it could be used in atomic transactions
numTargets := len(targetIds)
if numTargets < 1 {
log.Println("Warning: recordEvent called with no target IDs. Skipping.")
return
}
stmt := "INSERT INTO event (actor_id, action_type, target_type, target_id) VALUES (?, ?, ?, ?)"
extraValuePlaceholders := strings.Repeat(", (?, ?, ?, ?)", numTargets-1)
args := make([]any, numTargets*4)
for i, id := range targetIds {
args[i*4] = actorId
args[i*4+1] = actionType
args[i*4+2] = targetType
args[i*4+3] = id
}
_, err := db.Connection.Exec(stmt+extraValuePlaceholders, args...)
if err == nil {
return
}
if numTargets == 1 {
log.Printf("Failed to record %s %s event: failed to execute query: %s\n", actionType, targetType, err)
} else {
log.Printf("Failed to record %d %s %s events: failed to execute query: %s\n", numTargets, actionType, targetType, err)
}
}
func recordEventCreateGroup(actorId, groupId string) {
recordEvent(actorId, eventActionCreate, eventTargetGroup, groupId)
}
func recordEventCreateUser(actorId, userId string) {
recordEvent(actorId, eventActionCreate, eventTargetUser, userId)
}
func recordEventCreateWish(actorId, wishId string) {
recordEvent(actorId, eventActionCreate, eventTargetWish, wishId)
}
func recordEventCreateGroupMember(actorId, groupMemberId string) {
recordEvent(actorId, eventActionCreate, eventTargetGroupMember, groupMemberId)
}
// FIXME: I can't use these yet because the associated actions use reference
// func recordEventHideUser(actorId, userId string) {
// recordEvent(actorId, eventActionHide, eventTargetUser, userId)
// }
// func recordEventUnhideUser(actorId, userId string) {
// recordEvent(actorId, eventActionUnhide, eventTargetUser, userId)
// }
func recordEventClaimWishes(actorId string, wishIds ...string) {
recordEvent(actorId, eventActionClaim, eventTargetWish, wishIds...)
}
func recordEventUnclaimWishes(actorId string, wishIds ...string) {
recordEvent(actorId, eventActionUnclaim, eventTargetWish, wishIds...)
}
func recordEventCompleteWishes(actorId string, wishIds ...string) {
recordEvent(actorId, eventActionComplete, eventTargetWish, wishIds...)
}

View File

@ -62,7 +62,7 @@ func queryOneGroup(query string, args ...any) (*Group, error) {
}
func queryManyGroupMembers(groupId string) ([]User, error) {
query := "SELECT user.id, user.name, user.display_name, user.reference, user.is_admin, user.is_live FROM v_user AS user JOIN group_member ON group_member.user_id = user.id JOIN [group] ON [group].id = group_member.group_id WHERE [group].id = ? ORDER BY group_member.user_id"
query := "SELECT user.id, user.name, user.display_name, user.reference, user.is_admin, user.is_live, user.password_from_admin FROM v_user AS user JOIN group_member ON group_member.user_id = user.id JOIN [group] ON [group].id = group_member.group_id WHERE [group].id = ? ORDER BY group_member.user_id"
members, err := queryManyUsers(query, groupId)
if err != nil {
return members, err
@ -71,8 +71,9 @@ func queryManyGroupMembers(groupId string) ([]User, error) {
}
func (s *Session) GetGroupByReference(reference string) (*Group, error) {
// FIXME: This function doesn't make much sense when there's already a public function to fetch any group, below
stmt := "SELECT [group].id, [group].name, [group].reference FROM [group] JOIN group_member ON [group].id == group_member.group_id WHERE [group].reference = ? AND group_member.user_id = ?;"
return queryOneGroup(stmt, reference, s.User.Id)
return queryOneGroup(stmt, reference, s.User().Id)
}
func GetGroupByReference(reference string) (*Group, error) {
@ -102,15 +103,21 @@ func (a *Admin) CreateGroup(name string, reference string) (*Group, error) {
Name: name,
Reference: reference,
}
recordEventCreateGroup(a.session.user.Id, group.Id)
return &group, nil
}
func (a *Admin) AddUserToGroup(userId, groupId string) error {
stmt := "INSERT INTO group_member (user_id, group_id) VALUES (?, ?)"
_, err := db.Connection.Exec(stmt, userId, groupId)
result, err := db.Connection.Exec(stmt, userId, groupId)
if err != nil {
return err
return fmt.Errorf("query execution failed: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
recordEventCreateGroupMember(a.session.user.Id, strconv.FormatInt(id, 10))
return nil
}
@ -126,5 +133,5 @@ func (a *Admin) RemoveUserFromGroup(userId, groupId string) error {
// Get the groups the session user belongs to
func (u *Session) GetGroups() ([]Group, error) {
stmt := "SELECT [group].id, [group].name, [group].reference FROM [group] JOIN group_member ON group_member.group_id = [group].id JOIN v_user AS user ON user.id = group_member.user_id WHERE user.id = ?"
return queryManyGroups(stmt, u.Id)
return queryManyGroups(stmt, u.User().Id)
}

View File

@ -15,6 +15,12 @@ func TestCreateGroup(t *testing.T) {
fixtures.AssertEq(t, "Number of users", "My Friends", group.Name)
fixtures.AssertEq(t, "Number of users", "my-friends", group.Reference)
// FIXME: disabled for now because datetimes break this
// events, err := s.Admin().ListEvents()
// assert.FatalErr(t, "listing events", err)
// assert.JsonSnapshot(t, "TestCreateGroup.snap.txt", events)
}
func TestCantSeeSelfInGroup(t *testing.T) {
@ -26,7 +32,7 @@ func TestCantSeeSelfInGroup(t *testing.T) {
group, err := s.Admin().CreateGroup(" My Friends ", " my-friends ")
fixtures.FailIfErr(t, err, "Failed to create group")
err = s.Admin().AddUserToGroup(s.User.Id, group.Id)
err = s.Admin().AddUserToGroup(s.User().Id, group.Id)
fixtures.FailIfErr(t, err, "Failed to add self to group")
err = s.Admin().AddUserToGroup(caleb.Id, group.Id)
@ -39,4 +45,10 @@ func TestCantSeeSelfInGroup(t *testing.T) {
fixtures.AssertEq(t, "Group contains 2 users", 2, len(group.Members))
fixtures.AssertEq(t, "Group user 1 is thomas", "thomas", group.Members[0].Name)
fixtures.AssertEq(t, "Group user 2 is caleb", "caleb", group.Members[1].Name)
// FIXME: disabled for now because datetimes break this
// events, err := s.Admin().ListEvents()
// assert.FatalErr(t, "listing events", err)
// assert.JsonSnapshot(t, "TestCantSeeSelfInGroup.snap.txt", events)
}

View File

@ -0,0 +1,49 @@
package assert
import (
"errors"
"slices"
"testing"
)
func Eq[C comparable](t *testing.T, context string, expected, actual C) {
if expected != actual {
t.Errorf("%s: %#v != %#v", context, expected, actual)
}
}
func True(t *testing.T, context string, condition bool) {
if !condition {
t.Errorf("false: %s", context)
}
}
func FatalTrue(t *testing.T, context string, condition bool) {
if !condition {
t.Fatalf("%s", context)
}
}
func FatalErr(t *testing.T, context string, err error) {
if err != nil {
t.Fatalf("%s: %s", context, err)
}
}
func FatalErrIs(t *testing.T, context string, err, target error) {
if !errors.Is(err, target) {
t.Fatalf("%s: encountered unexpected error: %s", context, err)
}
}
func FatalErrAs(t *testing.T, context string, err error, target any) {
if !errors.As(err, target) {
t.Fatalf("%s: encountered unexpected error: %s", context, err)
}
}
func SlicesEq[S ~[]E, E comparable](t *testing.T, context string, expected, actual S) {
if !slices.Equal(expected, actual) {
t.Errorf("%s: %#v != %#v", context, expected, actual)
}
}

View File

@ -0,0 +1,62 @@
package assert
import (
"encoding/json"
"errors"
"fmt"
"os"
"testing"
)
func pathExists(path string) bool {
_, err := os.Stat(path)
return !errors.Is(err, os.ErrNotExist)
}
func writeSnapshot(t *testing.T, path, actual string) {
f, err := os.Create(path)
if err != nil {
t.Fatalf("Failed to create snapshot file: %s", err)
}
_, err = f.Write([]byte(actual))
if err != nil {
t.Fatalf("Failed to write to snapshot file: %s", err)
}
err = f.Close()
if err != nil {
t.Fatalf("Failed to close snapshot file: %s", err)
}
}
func TextSnapshot(t *testing.T, path, actual string) {
if !pathExists(path) {
writeSnapshot(t, path, actual)
t.Errorf("Snapshot file created: %s", path)
return
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read snapshot file: %s", err)
}
expected := string(content)
if expected != actual {
t.Errorf("Value doesn't match snapshot %s:\n%s", path, actual)
}
if os.Getenv("UPDATE_SNAPSHOTS") == "" {
return
}
writeSnapshot(t, path, actual)
fmt.Printf("Snapshot file %s updated\n", path)
}
func JsonSnapshot(t *testing.T, path string, actual any) {
data, err := json.MarshalIndent(actual, "", " ")
if err != nil {
t.Fatalf("Snapshot failed to serialize actual to JSON: %s", err)
}
TextSnapshot(t, path, string(data))
}

View File

@ -15,12 +15,12 @@ var Connection *sql.DB
func Init(dataSourceName string) error {
db, err := sql.Open("sqlite3", dataSourceName)
if err != nil {
return fmt.Errorf("Failed to open db connection: %w", err)
return fmt.Errorf("failed to open db connection: %w", err)
}
_, err = db.Exec(initQuery)
if err != nil {
return fmt.Errorf("Failed to initialize db: %w", err)
return fmt.Errorf("failed to initialize db: %w", err)
}
Connection = db

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" (
@ -29,15 +30,29 @@ CREATE TABLE IF NOT EXISTS "group" (
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE IF NOT EXISTS "group_member" (
"id" INTEGER NOT NULL UNIQUE,
"group_id" INTEGER NOT NULL,
"user_id" INTEGER NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT),
UNIQUE("user_id","group_id"),
FOREIGN KEY("group_id") REFERENCES "group"("id"),
FOREIGN KEY("user_id") REFERENCES "user"("id")
);
CREATE TABLE IF NOT EXISTS "session" (
"id" INTEGER NOT NULL UNIQUE,
"value" TEXT NOT NULL,
"key" TEXT NOT NULL UNIQUE,
"user_id" INTEGER NOT NULL,
"expiry" TEXT NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT),
FOREIGN KEY("user_id") REFERENCES "user"("id")
);
CREATE TABLE IF NOT EXISTS "event" (
"id" INTEGER NOT NULL UNIQUE,
"actor_id" INTEGER NOT NULL,
"action_type" TEXT NOT NULL,
"target_type" TEXT NOT NULL,
"target_id" INTEGER NOT NULL,
"created_at" TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ')),
PRIMARY KEY("id" AUTOINCREMENT)
);

View File

@ -0,0 +1 @@
ALTER TABLE user ADD COLUMN "password_from_admin" INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,19 @@
BEGIN TRANSACTION;
ALTER TABLE group_member RENAME TO old_group_member;
CREATE TABLE "group_member" (
"id" INTEGER NOT NULL UNIQUE,
"group_id" INTEGER NOT NULL,
"user_id" INTEGER NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT),
UNIQUE("user_id","group_id"),
FOREIGN KEY("group_id") REFERENCES "group"("id"),
FOREIGN KEY("user_id") REFERENCES "user"("id")
);
INSERT INTO group_member (group_id, user_id) SELECT group_id, user_id FROM old_group_member;
DROP TABLE "old_group_member";
COMMIT;

View File

@ -2,24 +2,28 @@ package fixtures
import "testing"
// Deprecated: use internal/assert
func AssertEq[C comparable](t *testing.T, context string, expected, actual C) {
if expected != actual {
t.Errorf("%s: %#v != %#v", context, expected, actual)
}
}
// Deprecated: use internal/assert
func Assert(t *testing.T, context string, condition bool) {
if !condition {
t.Errorf("%s", context)
}
}
// Deprecated: use internal/assert
func FatalAssert(t *testing.T, context string, condition bool) {
if !condition {
t.Fatalf("%s", context)
}
}
// Deprecated: use internal/assert
func FailIfErr(t *testing.T, err error, context string) {
if err != nil {
t.Fatalf("%s: %s\n", context, err)

View File

@ -1,8 +1,8 @@
package fixtures
import (
"log"
"testing"
"time"
lishwist "lishwist/core"
@ -14,21 +14,36 @@ func TestInit(t *testing.T) error {
return lishwist.Init(uri)
}
// Deprecated: This function also inits the test, which prevents it from being used more than once per test
func Login(t *testing.T, username, password string) *lishwist.Session {
uri := memdb.TestDB(t)
err := lishwist.Init(uri)
if err != nil {
log.Fatalf("Failed to init db: %s\n", err)
t.Fatalf("Failed to init db: %s\n", err)
}
_, err = lishwist.Register(username, password)
if err != nil {
log.Fatalf("Failed to register on login fixture: %s\n", err)
t.Fatalf("Failed to register on login fixture: %s\n", err)
}
session, err := lishwist.Login(username, password)
session, err := lishwist.Login(username, password, time.Hour*24)
if err != nil {
log.Fatalf("Failed to login on fixture: %s\n", err)
t.Fatalf("Failed to login on fixture: %s\n", err)
}
return session
}
func Login2(t *testing.T, username, password string) *lishwist.Session {
_, err := lishwist.Register(username, password)
if err != nil {
t.Fatalf("Failed to register on login fixture: %s\n", err)
}
session, err := lishwist.Login(username, password, time.Hour*24)
if err != nil {
t.Fatalf("Failed to login on fixture: %s\n", err)
}
return session

View File

@ -0,0 +1,14 @@
package id
import (
"crypto/rand"
"encoding/hex"
)
func Generate() string {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
panic(err)
}
return hex.EncodeToString(bytes)
}

View File

@ -2,13 +2,14 @@ package lishwist
import (
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
)
type ErrorInvalidCredentials error
func Login(username, password string) (*Session, error) {
func Login(username, password string, sessionMaxAge time.Duration) (*Session, error) {
user, err := getUserByName(username)
if err != nil {
return nil, ErrorInvalidCredentials(fmt.Errorf("Failed to fetch user: %w", err))
@ -27,5 +28,10 @@ func Login(username, password string) (*Session, error) {
return nil, ErrorInvalidCredentials(fmt.Errorf("Password compare failed: %w", err))
}
return &Session{*user}, nil
session, err := insertSession(*user, sessionMaxAge)
if err != nil {
return nil, fmt.Errorf("failed to insert session: %w", err)
}
return session, nil
}

View File

@ -2,6 +2,7 @@ package lishwist_test
import (
"testing"
"time"
lishwist "lishwist/core"
"lishwist/core/internal/fixtures"
@ -18,7 +19,7 @@ func TestLogin(t *testing.T) {
t.Fatalf("Failed to register: %s\n", err)
}
_, err = lishwist.Login("thomas", "123")
_, err = lishwist.Login("thomas", "123", time.Hour*24)
if err != nil {
t.Fatalf("Failed to login: %s\n", err)
}

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

@ -1,15 +1,67 @@
package lishwist
import "fmt"
import (
"database/sql"
"errors"
"fmt"
"time"
"lishwist/core/internal/db"
"lishwist/core/internal/id"
)
type Session struct {
User
user User
Key string
Expiry time.Time
}
func SessionFromUsername(username string) (*Session, error) {
user, err := getUserByName(username)
if err != nil {
return nil, fmt.Errorf("Failed to get user: %w", err)
}
return &Session{*user}, nil
// Returns a copy of the user associated with this session
func (s *Session) User() User {
return s.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, 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,
&s.user.NormalName,
&s.user.Name,
&s.user.Reference,
&s.user.IsAdmin,
&s.user.IsLive,
&s.user.PasswordFromAdmin,
&s.Key,
&expiry,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to fetch session: %w", err)
}
s.Expiry, err = time.Parse(time.RFC3339Nano, expiry)
if err != nil {
return nil, fmt.Errorf("failed to parse session expiry: %w", err)
}
if time.Now().After(s.Expiry) {
return nil, nil
}
return &s, err
}
func insertSession(user User, maxAge time.Duration) (*Session, error) {
s := Session{
user: user,
Key: id.Generate(),
Expiry: time.Now().Add(maxAge),
}
stmt := "INSERT INTO session (key, user_id, expiry) VALUES (?, ?, ?)"
_, err := db.Connection.Exec(stmt, &s.Key, &user.Id, &s.Expiry)
if err != nil {
return nil, fmt.Errorf("failed to execute query: %w", err)
}
return &s, nil
}

View File

@ -1,39 +0,0 @@
package session
import (
"fmt"
"lishwist/core/internal/db"
"github.com/Teajey/sqlstore"
)
func NewStore(keyPairs ...[]byte) (*sqlstore.Store, error) {
deleteStmt, err := db.Connection.Prepare("DELETE FROM session WHERE id = ?;")
if err != nil {
return nil, fmt.Errorf("Failed to prepare delete statement: %w", err)
}
insertStmt, err := db.Connection.Prepare("INSERT INTO session (value) VALUES (?);")
if err != nil {
return nil, fmt.Errorf("Failed to prepare insert statement: %w", err)
}
selectStmt, err := db.Connection.Prepare("SELECT value FROM session WHERE id = ?;")
if err != nil {
return nil, fmt.Errorf("Failed to prepare select statement: %w", err)
}
updateStmt, err := db.Connection.Prepare("UPDATE session SET value = ?2 WHERE id = ?1;")
if err != nil {
return nil, fmt.Errorf("Failed to prepare update statement: %w", err)
}
s := sqlstore.NewSqlStore(db.Connection, sqlstore.Statements{
Delete: deleteStmt,
Insert: insertStmt,
Select: selectStmt,
Update: updateStmt,
}, keyPairs...)
return s, nil
}

View File

@ -4,19 +4,20 @@ import (
"fmt"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"lishwist/core/internal/db"
"lishwist/core/internal/normalize"
)
type User struct {
Id string
// TODO: rename to DisplayName
NormalName string
Name string
Reference string
IsAdmin bool
IsLive bool
Id string
NormalName string
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)
}
@ -77,6 +78,7 @@ func createUser(name string, passHash []byte, isAdmin bool) (*User, error) {
Id: fmt.Sprintf("%d", id),
Name: name,
}
recordEventCreateUser(user.Id, user.Id)
return &user, nil
}
@ -91,12 +93,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 +113,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)
}
@ -124,7 +126,7 @@ func GetUserByReference(reference string) (*User, error) {
}
func (u *User) GetTodo() ([]Wish, error) {
stmt := "SELECT wish.id, wish.name, wish.sent, recipient.name, recipient.reference FROM wish JOIN v_user AS user ON wish.claimant_id = user.id JOIN v_user AS recipient ON wish.recipient_id = recipient.id WHERE user.id = ? ORDER BY wish.sent ASC, wish.name"
stmt := "SELECT wish.id, wish.name, wish.sent, recipient.display_name, recipient.reference FROM wish JOIN v_user AS user ON wish.claimant_id = user.id JOIN v_user AS recipient ON wish.recipient_id = recipient.id WHERE user.id = ? ORDER BY wish.sent ASC, wish.name"
rows, err := db.Connection.Query(stmt, u.Id)
if err != nil {
return nil, err
@ -160,6 +162,41 @@ func (u *Admin) UserSetLive(userReference string, setting bool) error {
if err != nil {
return err
}
// u.IsLive = setting
return err
}
func (u *Admin) RenameUser(userReference string, displayName string) error {
name := normalize.Name(displayName)
query := "UPDATE user SET name = ?, display_name = ? WHERE reference = ?"
_, err := db.Connection.Exec(query, name, displayName, userReference)
if err != nil {
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

@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"lishwist/core/internal/db"
@ -23,8 +24,8 @@ type Wish struct {
}
func (s *Session) GetWishes() ([]Wish, error) {
stmt := "SELECT wish.id, wish.name, wish.sent FROM wish WHERE wish.creator_id = ?1 AND wish.recipient_id = ?1"
rows, err := db.Connection.Query(stmt, s.User.Id)
stmt := "SELECT wish.id, wish.name, wish.sent FROM wish WHERE wish.creator_id = ?1 AND wish.recipient_id = ?1 ORDER BY wish.sent"
rows, err := db.Connection.Query(stmt, s.User().Id)
if err != nil {
return nil, fmt.Errorf("Query execution failed: %w", err)
}
@ -52,19 +53,26 @@ func (s *Session) GetWishes() ([]Wish, error) {
return wishs, nil
}
func (s *Session) MakeWish(name string) error {
// May return the id of the wish
func (s *Session) MakeWish(name string) (string, error) {
stmt := "INSERT INTO wish (name, recipient_id, creator_id) VALUES (?, ?, ?)"
_, err := db.Connection.Exec(stmt, strings.TrimSpace(name), s.User.Id, s.User.Id)
result, err := db.Connection.Exec(stmt, strings.TrimSpace(name), s.User().Id, s.User().Id)
if err != nil {
return fmt.Errorf("Query execution failed: %w", err)
return "", fmt.Errorf("Query execution failed: %w", err)
}
return nil
id, err := result.LastInsertId()
if err != nil {
return "", fmt.Errorf("failed to get last insert id: %w", err)
}
wishId := strconv.FormatInt(id, 10)
recordEventCreateWish(s.user.Id, wishId)
return wishId, nil
}
func (u *Session) deleteWishes(tx *sql.Tx, ids []string) error {
func (s *Session) deleteWishes(tx *sql.Tx, ids []string) error {
stmt := "DELETE FROM wish WHERE wish.creator_id = ? AND wish.id = ?"
for _, id := range ids {
r, err := tx.Exec(stmt, u.Id, id)
r, err := tx.Exec(stmt, s.User().Id, id)
if err != nil {
return err
}
@ -107,10 +115,10 @@ func (s *Session) GetOthersWishes(userReference string) ([]Wish, error) {
if err != nil {
return nil, fmt.Errorf("Failed to get other user: %w", err)
}
if otherUser.Id == s.User.Id {
if otherUser.Id == s.User().Id {
return nil, errors.New("Use (s *Session) GetWishes() to view your own wishes")
}
stmt := "SELECT wish.id, wish.name, claimant.id, claimant.name, wish.sent, wish.creator_id, creator.name, wish.recipient_id FROM wish JOIN v_user AS user ON wish.recipient_id = user.id LEFT JOIN v_user AS claimant ON wish.claimant_id = claimant.id LEFT JOIN v_user AS creator ON wish.creator_id = creator.id WHERE user.id = ?"
stmt := "SELECT wish.id, wish.name, claimant.id, claimant.display_name, wish.sent, wish.creator_id, creator.display_name, wish.recipient_id FROM wish JOIN v_user AS user ON wish.recipient_id = user.id LEFT JOIN v_user AS claimant ON wish.claimant_id = claimant.id LEFT JOIN v_user AS creator ON wish.creator_id = creator.id WHERE user.id = ? ORDER BY wish.sent"
rows, err := db.Connection.Query(stmt, otherUser.Id)
if err != nil {
return nil, fmt.Errorf("Failed to execute query: %w", err)
@ -164,7 +172,7 @@ func (s *Session) executeClaims(tx *sql.Tx, claims, unclaims []string) error {
claimStmt := "UPDATE wish SET claimant_id = ? WHERE id = ?"
unclaimStmt := "UPDATE wish SET claimant_id = NULL WHERE id = ?"
for _, id := range claims {
r, err := tx.Exec(claimStmt, s.Id, id)
r, err := tx.Exec(claimStmt, s.User().Id, id)
if err != nil {
return err
}
@ -194,7 +202,10 @@ func (s *Session) executeClaims(tx *sql.Tx, claims, unclaims []string) error {
// Undertake or abandon wishes made by other users
func (s *Session) ClaimWishes(claims, unclaims []string) error {
if len(claims) < 1 && len(unclaims) < 1 {
lenClaims := len(claims)
lenUnclaims := len(unclaims)
// TODO: Would be nice if this used a request builder
if lenClaims < 1 && lenUnclaims < 1 {
return fmt.Errorf("Attempt to claim/unclaim zero wishes")
}
@ -213,7 +224,17 @@ func (s *Session) ClaimWishes(claims, unclaims []string) error {
}
err = tx.Commit()
return err
if err != nil {
return err
}
// TODO: This could be atomic. See recordEvent function
if lenClaims > 0 {
recordEventClaimWishes(s.user.Id, claims...)
}
if lenUnclaims > 0 {
recordEventUnclaimWishes(s.user.Id, unclaims...)
}
return nil
}
func executeCompletions(tx *sql.Tx, claims []string) error {
@ -234,8 +255,8 @@ func executeCompletions(tx *sql.Tx, claims []string) error {
return nil
}
// TODO: User ought not be able to interact with wishes outside their group network
func (s *Session) CompleteWishes(claims []string) error {
// TODO: User ought not be able to interact with wishes outside their group network
if len(claims) < 1 {
return fmt.Errorf("Attempt to complete zero wishes")
}
@ -255,7 +276,13 @@ func (s *Session) CompleteWishes(claims []string) error {
}
err = tx.Commit()
return err
if err != nil {
return err
}
recordEventCompleteWishes(s.user.Id, claims...)
return nil
}
func (u *Session) SuggestWishForUser(otherUserReference string, wishName string) error {
@ -264,7 +291,7 @@ func (u *Session) SuggestWishForUser(otherUserReference string, wishName string)
return err
}
stmt := "INSERT INTO wish (name, recipient_id, creator_id) VALUES (?, ?, ?)"
_, err = db.Connection.Exec(stmt, wishName, otherUser.Id, u.Id)
_, err = db.Connection.Exec(stmt, wishName, otherUser.Id, u.User().Id)
if err != nil {
return err
}
@ -291,5 +318,8 @@ func (s *Session) RecindWishesForUser(ids ...string) error {
}
err = tx.Commit()
return err
if err != nil {
return nil
}
return nil
}

View File

@ -3,17 +3,18 @@ package lishwist_test
import (
"testing"
"lishwist/core/internal/assert"
"lishwist/core/internal/fixtures"
)
func TestMakeWish(t *testing.T) {
s := fixtures.Login(t, "thomas", "123")
if err := s.MakeWish("apple"); err != nil {
if _, err := s.MakeWish("apple"); err != nil {
t.Fatalf("Failed to make wish 1: %s\n", err)
}
if err := s.MakeWish(" A car "); err != nil {
if _, err := s.MakeWish(" A car "); err != nil {
t.Fatalf("Failed to make wish 2: %s\n", err)
}
@ -26,3 +27,35 @@ func TestMakeWish(t *testing.T) {
fixtures.AssertEq(t, "Wish 1 name", wishes[0].Name, "apple")
fixtures.AssertEq(t, "Wish 2 name", wishes[1].Name, "A car")
}
func TestClaimUnclaimWishes(t *testing.T) {
err := fixtures.TestInit(t)
assert.FatalErr(t, "initializing db", err)
thomas := fixtures.Login2(t, "thomas", "123")
assert.FatalErr(t, "registering thomas", err)
caleb := fixtures.Login2(t, "caleb", "123")
assert.FatalErr(t, "registering caleb", err)
food, err := caleb.MakeWish("food")
assert.FatalErr(t, "making wish 1", err)
box, err := caleb.MakeWish("box")
assert.FatalErr(t, "making wish 2", err)
drink, err := caleb.MakeWish("drink")
assert.FatalErr(t, "making wish 3", err)
err = thomas.ClaimWishes([]string{food, box, drink}, []string{})
assert.FatalErr(t, "claiming wishes", err)
err = thomas.ClaimWishes([]string{}, []string{food, box, drink})
assert.FatalErr(t, "unclaiming wishes", err)
// FIXME: disabled for now because datetimes break this
// events, err := thomas.Admin().ListEvents()
// assert.FatalErr(t, "listing events", err)
// assert.JsonSnapshot(t, "TestClaimUnclaimWishes.snap.txt", events)
}

View File

@ -1,4 +1,4 @@
go 1.23.3
go 1.24.5
use (
./core

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
}

View File

@ -5,6 +5,7 @@ import (
)
type RegisterProps struct {
Navbar templates.NavCollapse
GeneralError string `json:",omitempty"`
Username templates.InputProps
Password templates.InputProps
@ -36,6 +37,7 @@ func (p *RegisterProps) Validate() (valid bool) {
func NewRegisterProps(usernameVal, passwordVal, confirmPassVal string) *RegisterProps {
return &RegisterProps{
Navbar: templates.DefaultNavCollapse(),
GeneralError: "",
Username: templates.InputProps{
Name: "username",

4
http/build-prod.sh Executable file
View File

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

5
http/dev.sh Executable file
View File

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

52
http/env/env.go vendored
View File

@ -6,28 +6,42 @@ import (
"os"
)
func GuaranteeEnv(key string) (variable string) {
func GuaranteeEnv(key string) string {
variable, ok := os.LookupEnv(key)
if !ok || variable == "" {
log.Fatalln("Missing environment variable:", key)
}
return
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
}
u, err := url.Parse(rawUrl)
if err != nil {
log.Fatalln("Couldn't parse host url:", err)
}
return u
}()
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.String()
}()
}

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

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

View File

@ -1,25 +1,18 @@
module lishwist/http
go 1.23
go 1.23.3
toolchain go1.23.3
toolchain go1.24.5
require (
github.com/Teajey/sqlstore v0.0.6
github.com/glebarez/go-sqlite v1.22.0
github.com/google/uuid v1.6.0
github.com/Teajey/rsvp v0.13.1
github.com/gorilla/sessions v1.4.0
golang.org/x/crypto v0.22.0
golang.org/x/crypto v0.39.0
)
require github.com/gorilla/securecookie v1.1.2
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.19.0 // indirect
modernc.org/libc v1.37.6 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.28.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
)

View File

@ -1,39 +1,22 @@
github.com/Teajey/sqlstore v0.0.3 h1:6Y1jz9/yw1cj/Z/jrii0s87RAomKWr/07B1auDgw8pg=
github.com/Teajey/sqlstore v0.0.3/go.mod h1:hjk0S593/2Q4QxkEXCgpThj9w5KWGTQi9JtgfziHXXk=
github.com/Teajey/sqlstore v0.0.4 h1:ATe25BD8cV0FUw4w2qlccx5m0c5kQI0K4ksl/LnSHsc=
github.com/Teajey/sqlstore v0.0.4/go.mod h1:hjk0S593/2Q4QxkEXCgpThj9w5KWGTQi9JtgfziHXXk=
github.com/Teajey/sqlstore v0.0.5 h1:WZvu54baa8+9n1sKQe9GuxBVwSISw+xCkw4VFSwwIs8=
github.com/Teajey/sqlstore v0.0.5/go.mod h1:hjk0S593/2Q4QxkEXCgpThj9w5KWGTQi9JtgfziHXXk=
github.com/Teajey/sqlstore v0.0.6 h1:kUEpA+3BKFHZl128MuMeYY6zVcmq1QmOlNyofcFEJOA=
github.com/Teajey/sqlstore v0.0.6/go.mod h1:hjk0S593/2Q4QxkEXCgpThj9w5KWGTQi9JtgfziHXXk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/Teajey/rsvp v0.13.1 h1:0lw+JosaWmdjSmXoKQYBRS9nptSZPInm60Y5GQ3llEU=
github.com/Teajey/rsvp v0.13.1/go.mod h1:z0L20VphVg+Ec2+hnpLFTG2MZTrWYFprav1kpxDba0Q=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,14 @@
package id
import (
"crypto/rand"
"encoding/hex"
)
func Generate() string {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
panic(err)
}
return hex.EncodeToString(bytes)
}

View File

@ -1,67 +1,25 @@
package main
import (
"encoding/gob"
"log"
"net/http"
lishwist "lishwist/core"
"lishwist/core/session"
"lishwist/http/api"
"lishwist/http/env"
"lishwist/http/router"
"lishwist/http/routing"
"lishwist/http/server"
)
func main() {
gob.Register(&api.RegisterProps{})
gob.Register(&api.LoginProps{})
err := lishwist.Init(env.DatabaseFile)
err := lishwist.Init(env.Configuration.DatabaseFile)
if err != nil {
log.Fatalf("Failed to init Lishwist: %s\n", err)
}
store, err := session.NewStore([]byte(env.SessionSecret))
if err != nil {
log.Fatalf("Failed to initialize session store: %s\n", err)
}
store.Options.MaxAge = 86_400
store.Options.Secure = !env.InDev
store.Options.HttpOnly = true
useSecureCookies := !env.Configuration.InDev
r := server.Create(useSecureCookies)
r := router.New(store)
r.Public.HandleFunc("GET /", routing.Login)
r.Public.HandleFunc("GET /groups/{groupReference}", routing.PublicGroup)
r.Public.HandleFunc("GET /lists/{userReference}", routing.PublicWishlist)
r.Public.HandleFunc("GET /register", routing.Register)
r.Public.HandleFunc("POST /", routing.LoginPost)
r.Public.HandleFunc("POST /register", routing.RegisterPost)
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))
r.Private.HandleFunc("GET /lists/{userReference}", routing.ExpectAppSession(routing.ForeignWishlist))
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 /groups/{groupReference}", routing.ExpectAppSession(routing.GroupPost))
r.Private.HandleFunc("POST /list/{userReference}", routing.ExpectAppSession(routing.ForeignWishlistPost))
r.Private.HandleFunc("POST /logout", routing.LogoutPost)
r.Private.HandleFunc("POST /users/{userReference}", routing.ExpectAppSession(routing.UserPost))
r.Private.HandleFunc("POST /{$}", routing.ExpectAppSession(routing.HomePost))
// Deprecated
r.Private.HandleFunc("GET /group/{groupReference}", routing.ExpectAppSession(routing.Group))
r.Private.HandleFunc("GET /list/{userReference}", routing.ExpectAppSession(routing.ForeignWishlist))
r.Public.HandleFunc("GET /group/{groupReference}", routing.PublicGroup)
r.Public.HandleFunc("GET /list/{userReference}", routing.PublicWishlist)
http.Handle("/", r)
log.Printf("Running at http://127.0.0.1:%s\n", env.ServePort)
err = http.ListenAndServe(":"+env.ServePort, nil)
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)
}

58
http/response/handler.go Normal file
View File

@ -0,0 +1,58 @@
package response
import (
"log"
"net/http"
"lishwist/http/session"
"lishwist/http/templates"
"lishwist/http/templates/text"
"github.com/Teajey/rsvp"
)
type ServeMux struct {
inner *rsvp.ServeMux
store *session.Store
}
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,
}
}
func (m *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
m.inner.ServeHTTP(w, r)
}
type Handler interface {
ServeHTTP(*Session, http.Header, *http.Request) rsvp.Response
}
type HandlerFunc func(*Session, http.Header, *http.Request) rsvp.Response
func (m *ServeMux) HandleFunc(pattern string, handler HandlerFunc) {
m.inner.MiddleHandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) rsvp.Response {
session := m.GetSession(r)
response := handler(session, w.Header(), r)
if session.written {
err := session.inner.Save(r, w)
if err != nil {
log.Printf("Failed to save session: %s\n", err)
}
}
return response
})
}
func (m *ServeMux) Handle(pattern string, handler Handler) {
m.HandleFunc(pattern, handler.ServeHTTP)
}

10
http/response/request.go Normal file
View File

@ -0,0 +1,10 @@
package response
import (
"net/http"
)
func (m *ServeMux) GetSession(r *http.Request) *Session {
session, _ := m.store.Get(r, "lishwist_user")
return &Session{inner: session}
}

36
http/response/response.go Normal file
View File

@ -0,0 +1,36 @@
package response
import (
"fmt"
"lishwist/http/templates"
"net/http"
"github.com/Teajey/rsvp"
)
func NotFound() rsvp.Response {
return Error(http.StatusNotFound, "Page not found")
}
type errorProps struct {
Navbar templates.NavCollapse
Message string
}
func Error(status int, format string, a ...any) rsvp.Response {
return rsvp.Response{
Body: errorProps{
Message: fmt.Sprintf(format, a...),
Navbar: templates.DefaultNavCollapse(),
},
Status: status,
TemplateName: "error_page.gotmpl",
}
}
func Data(templateName string, body any) rsvp.Response {
return rsvp.Response{
Body: body,
TemplateName: templateName,
}
}

View File

@ -1,45 +1,52 @@
package rsvp
package response
import (
"github.com/gorilla/sessions"
)
type Session struct {
inner *sessions.Session
inner *sessions.Session
written bool
}
const flashKey = "_flash"
func (s *Session) FlashGet() any {
list := s.inner.Flashes()
if len(list) < 1 {
return nil
} else {
return list[0]
}
}
func (s *Session) FlashPeek() any {
flash, ok := s.inner.Values["_flash"]
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
}
func (s *Session) SetID(value string) {
s.inner.ID = value
s.written = true
}
func (s *Session) SetValue(key any, value any) {
s.inner.Values[key] = value
s.written = true
}
func (s *Session) RemoveValue(key any) {
delete(s.inner.Values, key)
s.written = true
}
func (s *Session) GetValue(key any) any {
@ -47,7 +54,8 @@ func (s *Session) GetValue(key any) any {
}
func (s *Session) ClearValues() {
s.inner.Values = nil
s.inner.Values = make(map[any]any)
s.written = true
}
func (s *Session) Options() *sessions.Options {

View File

@ -1,33 +1,38 @@
package router
import (
"lishwist/http/rsvp"
"net/http"
"github.com/Teajey/sqlstore"
"lishwist/http/response"
"lishwist/http/session"
)
type VisibilityRouter struct {
Store *sqlstore.Store
Public *rsvp.ServeMux
Private *rsvp.ServeMux
store *session.Store
Public *response.ServeMux
Private *response.ServeMux
}
func (s *VisibilityRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
session, _ := s.Store.Get(r, "lishwist_user")
authorized, _ := session.Values["authorized"].(bool)
session, _ := s.store.Get(r, "lishwist_user")
_, inSession := session.Values["sessionKey"]
if authorized {
if inSession {
s.Private.ServeHTTP(w, r)
} else {
s.Public.ServeHTTP(w, r)
}
}
func New(store *sqlstore.Store) *VisibilityRouter {
func New(store *session.Store) *VisibilityRouter {
return &VisibilityRouter{
Store: store,
Public: rsvp.NewServeMux(store),
Private: rsvp.NewServeMux(store),
store: store,
Public: response.NewServeMux(store),
Private: response.NewServeMux(store),
}
}
func (r *VisibilityRouter) HandleFunc(pattern string, handler response.HandlerFunc) {
r.Public.HandleFunc(pattern, handler)
r.Private.HandleFunc(pattern, handler)
}

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

@ -0,0 +1,119 @@
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 {
Navbar templates.NavCollapse
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{
Navbar: templates.NavCollapse{
Links: []templates.Link{{Href: "/", Name: "Home"}},
User: &templates.User{Name: 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

@ -1,24 +1,34 @@
package routing
import (
lishwist "lishwist/core"
"lishwist/http/rsvp"
"log"
"net/http"
lishwist "lishwist/core"
"lishwist/http/response"
"github.com/Teajey/rsvp"
)
func ExpectAppSession(next func(*lishwist.Session, http.Header, *rsvp.Request) rsvp.Response) rsvp.HandlerFunc {
return func(w http.Header, r *rsvp.Request) rsvp.Response {
session := r.GetSession()
username, ok := session.GetValue("username").(string)
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 {
return rsvp.Error(http.StatusInternalServerError, "Something went wrong.").Log("Failed to get username from session")
log.Printf("Failed to get key from session\n")
return response.Error(http.StatusInternalServerError, "Something went wrong.")
}
appSession, err := lishwist.SessionFromUsername(username)
appSession, err := lishwist.SessionFromKey(sessionKey)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Something went wrong.").Log("Failed to get session by username %q: %s", username, err)
log.Printf("Failed to get session by key %v: %s\n", sessionKey, err)
return response.Error(http.StatusInternalServerError, "Something went wrong.")
}
if appSession == nil {
log.Printf("Session not found under key: %s\n", sessionKey)
return response.Error(http.StatusInternalServerError, "Something went wrong.")
}
return next(appSession, w, r)
return next(appSession, session, h, r)
}
}

View File

@ -1,17 +0,0 @@
package routing
import (
"fmt"
"log"
"net/http"
"strings"
)
func writeGeneralErrorJson(w http.ResponseWriter, status int, format string, a ...any) {
msg := fmt.Sprintf(format, a...)
log.Printf("General error: %s\n", msg)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(status)
escapedMsg := strings.ReplaceAll(msg, `"`, `\"`)
_, _ = w.Write([]byte(fmt.Sprintf(`{"GeneralError":"%s"}`, escapedMsg)))
}

30
http/routing/events.go Normal file
View File

@ -0,0 +1,30 @@
package routing
import (
lishwist "lishwist/core"
"lishwist/http/response"
"log"
"net/http"
"github.com/Teajey/rsvp"
)
type Events struct {
Events []lishwist.Event `xml:"Event"`
}
func EventList(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
admin := app.Admin()
if admin == nil {
log.Println("Attempt to access EventList by non-admin. Responding 404 Not Found.")
return response.NotFound()
}
events, err := admin.ListEvents()
if err != nil {
log.Printf("Admin failed to ListEvents: %s\n", err)
return response.Error(http.StatusInternalServerError, "Failed to get events: %s", err)
}
return response.Data("", Events{Events: events})
}

View File

@ -2,55 +2,66 @@ package routing
import (
lishwist "lishwist/core"
"lishwist/http/rsvp"
"lishwist/http/response"
"lishwist/http/templates"
"log"
"net/http"
"github.com/Teajey/rsvp"
)
type foreignWishlistProps struct {
Navbar templates.NavCollapse
CurrentUserId string
CurrentUserName string
Username string
Gifts []lishwist.Wish
}
func ForeignWishlist(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response {
func ForeignWishlist(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
userReference := r.PathValue("userReference")
if app.User.Reference == userReference {
return rsvp.SeeOther("/")
user := app.User()
if user.Reference == userReference {
return rsvp.Found("/", "You're not allowed to view your own wishlist!")
}
otherUser, err := lishwist.GetUserByReference(userReference)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(").Log("Couldn't get user by reference %q: %s", userReference, err)
log.Printf("Couldn't get user by reference %q: %s\n", userReference, err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(")
}
if otherUser == nil {
return rsvp.Error(http.StatusInternalServerError, "User not found")
return response.Error(http.StatusInternalServerError, "User not found")
}
wishes, err := app.GetOthersWishes(userReference)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(").Log("%q couldn't get wishes of other user %q: %s", app.User.Name, otherUser.Name, err)
log.Printf("%q couldn't get wishes of other user %q: %s\n", user.Name, otherUser.Name, err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(")
}
p := foreignWishlistProps{CurrentUserId: app.User.Id, CurrentUserName: app.User.Name, Username: otherUser.Name, Gifts: wishes}
return rsvp.Data("foreign_wishlist.gotmpl", p)
p := foreignWishlistProps{Navbar: templates.UserNavCollapse(user.Name, user.PasswordFromAdmin), CurrentUserId: user.Id, Username: otherUser.Name, Gifts: wishes}
return response.Data("foreign_wishlist.gotmpl", p)
}
type publicWishlistProps struct {
Navbar templates.NavCollapse
Username string
GiftCount int
}
func PublicWishlist(h http.Header, r *rsvp.Request) rsvp.Response {
func PublicWishlist(s *response.Session, h http.Header, r *http.Request) rsvp.Response {
userReference := r.PathValue("userReference")
otherUser, err := lishwist.GetUserByReference(userReference)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(").Log("Couldn't get user by reference %q on public wishlist: %s", userReference, err)
log.Printf("Couldn't get user by reference %q on public wishlist: %s\n", userReference, err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(")
}
if otherUser == nil {
return rsvp.Error(http.StatusInternalServerError, "User not found")
return response.Error(http.StatusInternalServerError, "User not found")
}
giftCount, err := otherUser.WishCount()
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(").Log("Couldn't get wishes of user %q on public wishlist: %s", otherUser.Name, err)
log.Printf("Couldn't get wishes of user %q on public wishlist: %s\n", otherUser.Name, err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(")
}
p := publicWishlistProps{Username: otherUser.Name, GiftCount: giftCount}
return rsvp.Data("public_foreign_wishlist.gotmpl", p)
p := publicWishlistProps{Navbar: templates.DefaultNavCollapse(), Username: otherUser.Name, GiftCount: giftCount}
return response.Data("public_foreign_wishlist.gotmpl", p)
}

View File

@ -1,109 +1,126 @@
package routing
import (
"encoding/xml"
"log"
"net/http"
"slices"
lishwist "lishwist/core"
"lishwist/http/rsvp"
"lishwist/http/response"
"lishwist/http/templates"
"github.com/Teajey/rsvp"
)
type GroupProps struct {
Group *lishwist.Group
CurrentUsername string
Navbar templates.NavCollapse
Group *lishwist.Group
}
func AdminGroup(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response {
func AdminGroup(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response {
reference := r.PathValue("groupReference")
group, err := app.GetGroupByReference(reference)
group, err := lishwist.GetGroupByReference(reference)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Couldn't get group: %s", err)
return response.Error(http.StatusInternalServerError, "Couldn't get group: %s", err)
}
if group == nil {
return rsvp.Error(http.StatusNotFound, "Group not found")
return response.Error(http.StatusNotFound, "Group not found")
}
if !app.User.IsAdmin {
index := group.MemberIndex(app.User.Id)
user := app.User()
if !user.IsAdmin {
index := group.MemberIndex(user.Id)
group.Members = slices.Delete(group.Members, index, index+1)
}
p := GroupProps{
Group: group,
CurrentUsername: app.User.Name,
Navbar: templates.UserNavCollapse(user.Name, user.PasswordFromAdmin),
Group: group,
}
return rsvp.Data("group_page.gotmpl", p)
return response.Data("group_page.gotmpl", p)
}
func Group(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response {
if app.User.IsAdmin {
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)
}
groupReference := r.PathValue("groupReference")
group, err := app.GetGroupByReference(groupReference)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this group :(").Log("Couldn't get group: %s", err)
log.Printf("Couldn't get group: %s\n", err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching this group :(")
}
if group == nil {
return rsvp.Error(http.StatusNotFound, "Group not found. (It might be because you're not a member)")
return response.Error(http.StatusNotFound, "Group not found. (It might be because you're not a member)")
}
index := group.MemberIndex(app.User.Id)
index := group.MemberIndex(user.Id)
group.Members = slices.Delete(group.Members, index, index+1)
p := GroupProps{
Group: group,
CurrentUsername: app.User.Name,
Navbar: templates.UserNavCollapse(user.Name, user.PasswordFromAdmin),
Group: group,
}
return rsvp.Data("group_page.gotmpl", p)
return response.Data("group_page.gotmpl", p)
}
func PublicGroup(h http.Header, r *rsvp.Request) rsvp.Response {
func PublicGroup(s *response.Session, h http.Header, r *http.Request) rsvp.Response {
groupReference := r.PathValue("groupReference")
group, err := lishwist.GetGroupByReference(groupReference)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching this group :(").Log("Couldn't get group: %s", err)
log.Printf("Couldn't get group: %s\n", err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching this group :(")
}
if group == nil {
return response.Error(http.StatusNotFound, "Group not found")
}
p := GroupProps{
Group: group,
Navbar: templates.DefaultNavCollapse(),
Group: group,
}
return rsvp.Data("public_group_page.gotmpl", p)
return response.Data("public_group_page.gotmpl", p)
}
func GroupPost(app *lishwist.Session, h http.Header, r *rsvp.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 NotFound(h, r)
return response.NotFound()
}
err := r.ParseForm()
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to parse form")
}
form := r.ParseForm()
var group *lishwist.Group
reference := r.PathValue("groupReference")
name := form.Get("name")
addUsers := form["addUser"]
removeUsers := form["removeUser"]
name := r.Form.Get("name")
addUsers := r.Form["addUser"]
removeUsers := r.Form["removeUser"]
if name != "" {
createdGroup, err := admin.CreateGroup(name, reference)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to create group: %s", err)
return response.Error(http.StatusInternalServerError, "Failed to create group: %s", err)
}
group = createdGroup
} else {
existingGroup, err := lishwist.GetGroupByReference(reference)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to get group: %s", err)
return response.Error(http.StatusInternalServerError, "Failed to get group: %s", err)
}
if existingGroup == nil {
return rsvp.Error(http.StatusNotFound, "Group not found", err)
return response.Error(http.StatusNotFound, "Group not found: %s", err)
}
group = existingGroup
for _, userId := range removeUsers {
index := group.MemberIndex(userId)
if index == -1 {
return rsvp.Error(http.StatusBadRequest, "Group %q does not contain a user with id %s", reference, userId)
return response.Error(http.StatusBadRequest, "Group %q does not contain a user with id %s", reference, userId)
}
err = admin.RemoveUserFromGroup(userId, group.Id)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "On group %q failed to remove user with id %s: %s", reference, userId, err)
return response.Error(http.StatusInternalServerError, "On group %q failed to remove user with id %s: %s", reference, userId, err)
}
group.Members = slices.Delete(group.Members, index, index+1)
}
@ -112,31 +129,36 @@ func GroupPost(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Respo
for _, userId := range addUsers {
user, err := admin.GetUser(userId)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Groups exists, but a user with id %s could not be fetched: %s", userId, err)
return response.Error(http.StatusInternalServerError, "Groups exists, but a user with id %s could not be fetched: %s", userId, err)
}
if user == nil {
return rsvp.Error(http.StatusInternalServerError, "Groups exists, but a user with id %s does not exist", userId)
return response.Error(http.StatusInternalServerError, "Groups exists, but a user with id %s does not exist", userId)
}
err = admin.AddUserToGroup(user.Id, group.Id)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Groups exists, but failed to add user with id %s: %s", userId, err)
return response.Error(http.StatusInternalServerError, "Groups exists, but failed to add user with id %s: %s", userId, err)
}
group.Members = append(group.Members, *user)
}
return rsvp.Data("", group)
return response.Data("", group)
}
func Groups(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response {
type GroupList struct {
XMLName xml.Name `xml:"Groups" json:"-"`
Groups []lishwist.Group `xml:"Group"`
}
func Groups(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
admin := app.Admin()
if admin == nil {
return NotFound(h, r)
return response.NotFound()
}
groups, err := admin.ListGroups()
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to get groups: %s", err)
return response.Error(http.StatusInternalServerError, "Failed to get groups: %s", err)
}
return rsvp.Data("", groups)
return response.Data("", GroupList{Groups: groups})
}

View File

@ -1,42 +1,55 @@
package routing
import (
"log"
"net/http"
lishwist "lishwist/core"
"lishwist/http/env"
"lishwist/http/rsvp"
"lishwist/http/response"
"lishwist/http/templates"
"github.com/Teajey/rsvp"
)
type HomeProps struct {
Username string
Gifts []lishwist.Wish
Todo []lishwist.Wish
Reference string
HostUrl string
Groups []lishwist.Group
Navbar templates.NavCollapse
Gifts []lishwist.Wish
Todo []lishwist.Wish
Groups []lishwist.Group
}
func Home(app *lishwist.Session, h http.Header, r *rsvp.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 {
return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(").Log("Failed to get gifts: %s", err)
log.Printf("Failed to get gifts: %s\n", err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(")
}
todo, err := app.GetTodo()
user := app.User()
todo, err := user.GetTodo()
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(").Log("Failed to get todo: %s", err)
log.Printf("Failed to get todo: %s\n", err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(")
}
groups, err := app.GetGroups()
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(").Log("Failed to get groups: %s", err)
log.Printf("Failed to get groups: %s\n", err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(")
}
p := HomeProps{Username: app.User.Name, Gifts: gifts, Todo: todo, Reference: app.User.Reference, HostUrl: env.HostUrl.String(), Groups: groups}
return rsvp.Data("home.gotmpl", p)
p := HomeProps{Navbar: templates.NavCollapse{
User: &templates.User{Name: user.Name, CopyList: &templates.CopyList{Domain: env.Configuration.HostUrl, Reference: user.Reference}},
AccountLink: &templates.AccountLink{Alert: user.PasswordFromAdmin},
}, Gifts: gifts, Todo: todo, Groups: groups}
return response.Data("home.gotmpl", p)
}
func HomePost(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response {
form := r.ParseForm()
switch form.Get("intent") {
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")
}
switch r.Form.Get("intent") {
case "add_idea":
return WishlistAdd(app, h, r)
case "delete_idea":

View File

@ -1,18 +1,22 @@
package routing
import (
"errors"
"log"
"net/http"
"time"
lishwist "lishwist/core"
"lishwist/http/api"
"lishwist/http/rsvp"
"net/http"
"lishwist/http/response"
"github.com/Teajey/rsvp"
)
func Login(h http.Header, r *rsvp.Request) rsvp.Response {
session := r.GetSession()
func Login(s *response.Session, h http.Header, r *http.Request) rsvp.Response {
props := api.NewLoginProps("", "")
flash := session.FlashGet()
flash := s.FlashGet()
flashProps, ok := flash.(*api.LoginProps)
if ok {
props.Username.Value = flashProps.Username.Value
@ -20,50 +24,57 @@ func Login(h http.Header, r *rsvp.Request) rsvp.Response {
props.GeneralError = flashProps.GeneralError
props.Username.Error = flashProps.Username.Error
props.Password.Error = flashProps.Password.Error
props.SuccessfulRegistration = flashProps.SuccessfulRegistration
props.SuccessfulSetPassword = flashProps.SuccessfulSetPassword
}
flash = session.FlashGet()
successfulReg, _ := flash.(bool)
if successfulReg {
props.SuccessfulRegistration = true
}
return rsvp.Data("login.gotmpl", props).SaveSession(session)
return rsvp.Response{TemplateName: "login.gotmpl", Body: props}
}
func LoginPost(h http.Header, r *rsvp.Request) rsvp.Response {
form := r.ParseForm()
session := r.GetSession()
func LoginPost(session *response.Session, h http.Header, r *http.Request) rsvp.Response {
username, password, ok := r.BasicAuth()
if !ok {
err := r.ParseForm()
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to parse form")
}
username := form.Get("username")
password := form.Get("password")
username = r.Form.Get("username")
password = r.Form.Get("password")
}
props := api.NewLoginProps(username, password)
resp := rsvp.SeeOther(r.URL.Path, props)
valid := props.Validate()
props.Password.Value = ""
if !valid {
session.FlashSet(&props)
return rsvp.SeeOther("/").SaveSession(session).Log("Invalid props: %#v\n", props)
log.Printf("Invalid props: %#v\n", props)
return resp
}
app, err := lishwist.Login(username, password)
appSession, err := lishwist.Login(username, password, time.Hour*24)
if err != nil {
switch err.(type) {
case lishwist.ErrorInvalidCredentials:
props.GeneralError = "Username or password invalid"
var targ lishwist.ErrorInvalidCredentials
switch {
case errors.As(err, &targ):
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)
return rsvp.SeeOther("/").SaveSession(session).Log("Invalid credentials: %s: %#v\n", err, props)
log.Printf("Invalid credentials: %s: %#v\n", err, props)
return resp
default:
props.GeneralError = "Something went wrong."
session.FlashSet(&props)
return rsvp.SeeOther("/").SaveSession(session).Log("Login error: %s\n", err)
log.Printf("Login error: %s\n", err)
return resp
}
}
session.SetID("")
session.SetValue("authorized", true)
session.SetValue("username", app.User.Name)
session.SetValue("sessionKey", appSession.Key)
return rsvp.SeeOther(r.URL().Path).SaveSession(session)
return rsvp.SeeOther(r.URL.Path, "Login successful!")
}

View File

@ -1,15 +1,15 @@
package routing
import (
"lishwist/http/rsvp"
"lishwist/http/response"
"net/http"
"github.com/Teajey/rsvp"
)
func LogoutPost(h http.Header, r *rsvp.Request) rsvp.Response {
session := r.GetSession()
func LogoutPost(session *response.Session, h http.Header, r *http.Request) rsvp.Response {
session.Options().MaxAge = 0
session.ClearValues()
return rsvp.SeeOther("/").SaveSession(session)
return rsvp.SeeOther("/", "Logout successful")
}

View File

@ -3,9 +3,11 @@ package routing
import (
"net/http"
"lishwist/http/rsvp"
"lishwist/http/response"
"github.com/Teajey/rsvp"
)
func NotFound(h http.Header, r *rsvp.Request) rsvp.Response {
return rsvp.Error(http.StatusNotFound, "Page not found")
func NotFound(s *response.Session, h http.Header, r *http.Request) rsvp.Response {
return response.Error(http.StatusNotFound, "Page not found")
}

View File

@ -4,14 +4,16 @@ import (
"errors"
lishwist "lishwist/core"
"lishwist/http/api"
"lishwist/http/rsvp"
"lishwist/http/response"
"log"
"net/http"
"github.com/Teajey/rsvp"
)
func Register(h http.Header, r *rsvp.Request) rsvp.Response {
func Register(session *response.Session, h http.Header, r *http.Request) rsvp.Response {
props := api.NewRegisterProps("", "", "")
session := r.GetSession()
flash := session.FlashGet()
flashProps, _ := flash.(*api.RegisterProps)
@ -23,16 +25,24 @@ func Register(h http.Header, r *rsvp.Request) rsvp.Response {
props.ConfirmPassword.Error = flashProps.ConfirmPassword.Error
}
return rsvp.Data("register.gotmpl", props).SaveSession(session)
return response.Data("register.gotmpl", props)
}
func RegisterPost(h http.Header, r *rsvp.Request) rsvp.Response {
form := r.ParseForm()
s := r.GetSession()
func RegisterPost(s *response.Session, h http.Header, r *http.Request) rsvp.Response {
var confirmPassword string
username, newPassword, ok := r.BasicAuth()
if ok {
confirmPassword = newPassword
} else {
err := r.ParseForm()
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to parse form")
}
username := form.Get("username")
newPassword := form.Get("newPassword")
confirmPassword := form.Get("confirmPassword")
username = r.Form.Get("username")
newPassword = r.Form.Get("newPassword")
confirmPassword = r.Form.Get("confirmPassword")
}
props := api.NewRegisterProps(username, newPassword, confirmPassword)
@ -41,7 +51,8 @@ func RegisterPost(h http.Header, r *rsvp.Request) rsvp.Response {
props.ConfirmPassword.Value = ""
if !valid {
s.FlashSet(&props)
return rsvp.SeeOther("/").SaveSession(s).Log("Invalid props: %#v\n", props)
log.Printf("Invalid register props: %#v\n", props)
return rsvp.SeeOther(r.URL.Path, props)
}
_, err := lishwist.Register(username, newPassword)
@ -52,9 +63,10 @@ func RegisterPost(h http.Header, r *rsvp.Request) rsvp.Response {
props.GeneralError = "Something went wrong."
}
s.FlashSet(&props)
return rsvp.SeeOther("/register").SaveSession(s).Log("Registration failed: %s\n", err)
log.Printf("Registration failed: %s\n", err)
return rsvp.SeeOther(r.URL.Path, props)
}
s.FlashSet(true)
return rsvp.SeeOther("/").SaveSession(s)
s.FlashSet(&api.LoginProps{SuccessfulRegistration: true})
return rsvp.SeeOther("/", "Registration successful!")
}

View File

@ -1,29 +1,38 @@
package routing
import (
lishwist "lishwist/core"
"lishwist/http/rsvp"
"log"
"net/http"
"github.com/Teajey/rsvp"
lishwist "lishwist/core"
"lishwist/http/response"
)
func TodoUpdate(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response {
form := r.ParseForm()
func TodoUpdate(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response {
err := r.ParseForm()
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to parse form")
}
switch form.Get("intent") {
switch r.Form.Get("intent") {
case "unclaim_todo":
unclaims := form["gift"]
unclaims := r.Form["gift"]
err := app.ClaimWishes([]string{}, unclaims)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to update claim...").LogError(err)
log.Printf("%s\n", err)
return response.Error(http.StatusInternalServerError, "Failed to update claim...")
}
case "complete_todo":
claims := form["gift"]
claims := r.Form["gift"]
err := app.CompleteWishes(claims)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to complete gifts...").LogError(err)
log.Printf("%s\n", err)
return response.Error(http.StatusInternalServerError, "Failed to complete gifts...")
}
default:
return rsvp.Error(http.StatusBadRequest, "Invalid intent")
return response.Error(http.StatusBadRequest, "Invalid intent")
}
return rsvp.SeeOther("/")
return rsvp.SeeOther("/", "Update successful")
}

View File

@ -1,73 +1,98 @@
package routing
import (
"encoding/xml"
lishwist "lishwist/core"
"lishwist/http/rsvp"
"lishwist/http/response"
"net/http"
"github.com/Teajey/rsvp"
)
func Users(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response {
type UserList struct {
XMLName xml.Name `xml:"Users" json:"-"`
Users []lishwist.User `xml:"User"`
}
func Users(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response {
admin := app.Admin()
if admin == nil {
return NotFound(h, r)
return response.NotFound()
}
users, err := admin.ListUsers()
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to get users: %s", err)
return response.Error(http.StatusInternalServerError, "Failed to get users: %s", err)
}
return rsvp.Data("", users)
return response.Data("", UserList{Users: users})
}
func User(app *lishwist.Session, h http.Header, r *rsvp.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 NotFound(h, r)
return response.NotFound()
}
reference := r.PathValue("userReference")
user, err := lishwist.GetUserByReference(reference)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to get user: %s", err)
return response.Error(http.StatusInternalServerError, "Failed to get user: %s", err)
}
if user == nil {
return rsvp.Error(http.StatusNotFound, "User not found")
return response.Error(http.StatusNotFound, "User not found")
}
return rsvp.Data("", user)
return response.Data("", user)
}
func UserPost(app *lishwist.Session, h http.Header, r *rsvp.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 NotFound(h, r)
return response.NotFound()
}
form := r.ParseForm()
err := r.ParseForm()
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to parse form")
}
reference := r.PathValue("userReference")
if reference == app.User.Reference {
return rsvp.Error(http.StatusForbidden, "You cannot delete yourself.")
}
user, err := lishwist.GetUserByReference(reference)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to get user: %s", err)
}
if user == nil {
return rsvp.Error(http.StatusNotFound, "User not found")
}
intent := form.Get("intent")
intent := r.Form.Get("intent")
if intent != "" {
err = admin.UserSetLive(reference, intent != "delete")
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to delete user: %s", err)
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)
}
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)
}
}
return rsvp.Data("", user)
user, err := lishwist.GetUserByReference(reference)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to get user: %s", err)
}
return response.Data("", user)
}

View File

@ -1,68 +1,95 @@
package routing
import (
lishwist "lishwist/core"
"lishwist/http/rsvp"
"log"
"net/http"
"github.com/Teajey/rsvp"
lishwist "lishwist/core"
"lishwist/http/response"
)
func WishlistAdd(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response {
form := r.ParseForm()
newGiftName := form.Get("gift_name")
err := app.MakeWish(newGiftName)
func WishlistAdd(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response {
err := r.ParseForm()
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to add gift.").LogError(err)
return response.Error(http.StatusBadRequest, "Failed to parse form")
}
return rsvp.SeeOther("/")
newGiftName := r.Form.Get("gift_name")
_, err = app.MakeWish(newGiftName)
if err != nil {
log.Printf("%s\n", err)
return response.Error(http.StatusInternalServerError, "Failed to add gift.")
}
return rsvp.SeeOther("/", "Wish added!")
}
func WishlistDelete(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response {
form := r.ParseForm()
targets := form["gift"]
err := app.RevokeWishes(targets...)
func WishlistDelete(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response {
err := r.ParseForm()
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to remove gifts.").LogError(err)
return response.Error(http.StatusBadRequest, "Failed to parse form")
}
return rsvp.SeeOther("/")
targets := r.Form["gift"]
err = app.RevokeWishes(targets...)
if err != nil {
log.Printf("%s\n", err)
return response.Error(http.StatusInternalServerError, "Failed to remove gifts.")
}
return rsvp.SeeOther("/", "Wish deleted")
}
func ForeignWishlistPost(app *lishwist.Session, h http.Header, r *rsvp.Request) rsvp.Response {
form := r.ParseForm()
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")
}
userReference := r.PathValue("userReference")
intent := form.Get("intent")
resp := rsvp.SeeOther("/lists/"+userReference, "Update successful")
intent := r.Form.Get("intent")
switch intent {
case "claim":
claims := form["unclaimed"]
unclaims := form["claimed"]
claims := r.Form["unclaimed"]
unclaims := r.Form["claimed"]
err := app.ClaimWishes(claims, unclaims)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to update claim...").LogError(err)
log.Printf("%s\n", err)
return response.Error(http.StatusInternalServerError, "Failed to update claim...")
}
resp.Body = "Successfully claimed wishes"
case "complete":
claims := form["claimed"]
claims := r.Form["claimed"]
err := app.CompleteWishes(claims)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to complete gifts...").LogError(err)
log.Printf("%s\n", err)
return response.Error(http.StatusInternalServerError, "Failed to complete gifts...")
}
resp.Body = "Successfully completed wishes"
case "add":
wishName := form.Get("gift_name")
wishName := r.Form.Get("gift_name")
if wishName == "" {
return rsvp.Error(http.StatusBadRequest, "Gift name not provided")
return response.Error(http.StatusBadRequest, "Gift name not provided")
}
err := app.SuggestWishForUser(userReference, wishName)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to add gift idea to other user...").LogError(err)
log.Printf("%s\n", err)
return response.Error(http.StatusInternalServerError, "Failed to add gift idea to other user...")
}
resp.Body = "Successfully added wishes"
case "delete":
claims := form["unclaimed"]
unclaims := form["claimed"]
claims := r.Form["unclaimed"]
unclaims := r.Form["claimed"]
gifts := append(claims, unclaims...)
err := app.RecindWishesForUser(gifts...)
if err != nil {
return rsvp.Error(http.StatusInternalServerError, "Failed to remove gift idea for other user...").LogError(err)
log.Printf("%s\n", err)
return response.Error(http.StatusInternalServerError, "Failed to remove gift idea for other user...")
}
resp.Body = "Successfully removed wishes"
default:
return rsvp.Error(http.StatusBadRequest, "Invalid intent %q", intent)
return response.Error(http.StatusBadRequest, "Invalid intent %q", intent)
}
return rsvp.SeeOther("/list/" + userReference)
return resp
}

View File

@ -1,56 +0,0 @@
package rsvp
import (
"log"
"net/http"
"github.com/Teajey/sqlstore"
)
type ServeMux struct {
inner *http.ServeMux
store *sqlstore.Store
}
func NewServeMux(store *sqlstore.Store) *ServeMux {
return &ServeMux{
inner: http.NewServeMux(),
store: store,
}
}
func (m *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
m.inner.ServeHTTP(w, r)
}
type Handler interface {
ServeHTTP(h http.Header, r *Request) Response
}
type HandlerFunc func(h http.Header, r *Request) Response
func (m *ServeMux) HandleFunc(pattern string, handler HandlerFunc) {
m.inner.HandleFunc(pattern, func(w http.ResponseWriter, stdReq *http.Request) {
r := wrapStdRequest(m.store, stdReq)
response := handler(w.Header(), &r)
err := response.Write(w, stdReq)
if err != nil {
response.Data = struct{ Message error }{err}
response.HtmlTemplateName = "error_page.gotmpl"
response.Status = http.StatusInternalServerError
} else {
return
}
err = response.Write(w, stdReq)
if err != nil {
log.Printf("Failed to write rsvp.Response to bytes: %s\n", err)
http.Error(w, "Failed to write response", http.StatusInternalServerError)
}
})
}
func (m *ServeMux) Handle(pattern string, handler Handler) {
m.HandleFunc(pattern, handler.ServeHTTP)
}

View File

@ -1,42 +0,0 @@
package rsvp
import (
"log"
"net/http"
"net/url"
"github.com/Teajey/sqlstore"
)
type Request struct {
inner *http.Request
store *sqlstore.Store
}
func wrapStdRequest(store *sqlstore.Store, r *http.Request) Request {
return Request{
inner: r,
store: store,
}
}
func (r *Request) GetSession() Session {
session, _ := r.store.Get(r.inner, "lishwist_user")
return Session{session}
}
func (r *Request) ParseForm() url.Values {
err := r.inner.ParseForm()
if err != nil {
log.Printf("Failed to parse form: %s\n", err)
}
return r.inner.Form
}
func (r *Request) PathValue(name string) string {
return r.inner.PathValue(name)
}
func (r *Request) URL() *url.URL {
return r.inner.URL
}

View File

@ -1,119 +0,0 @@
package rsvp
import (
"bytes"
"encoding/json"
"fmt"
"lishwist/http/templates"
"log"
"net/http"
"strings"
)
type Response struct {
HtmlTemplateName string
Data any
SeeOther string
Session *Session
Status int
LogMessage string
}
func (res *Response) Write(w http.ResponseWriter, r *http.Request) error {
if res.LogMessage != "" {
log.Printf("%s --- %s\n", res.Data, res.LogMessage)
}
if res.Session != nil {
err := res.Session.inner.Save(r, w)
if err != nil {
return fmt.Errorf("Failed to write session: %w", err)
}
}
if res.SeeOther != "" {
http.Redirect(w, r, res.SeeOther, http.StatusSeeOther)
if res.Session != nil {
flash := res.Session.FlashPeek()
if flash != nil {
err := json.NewEncoder(w).Encode(flash)
if err != nil {
return err
}
}
}
return nil
}
bodyBytes := bytes.NewBuffer([]byte{})
accept := r.Header.Get("Accept")
if res.Status != 0 {
w.WriteHeader(res.Status)
}
switch {
case strings.Contains(accept, "text/html"):
if res.HtmlTemplateName == "" {
err := json.NewEncoder(bodyBytes).Encode(res.Data)
if err != nil {
return err
}
} else {
err := templates.Execute(bodyBytes, res.HtmlTemplateName, res.Data)
if err != nil {
return err
}
}
case strings.Contains(accept, "application/json"):
err := json.NewEncoder(bodyBytes).Encode(res.Data)
if err != nil {
return err
}
default:
err := json.NewEncoder(bodyBytes).Encode(res.Data)
if err != nil {
return err
}
}
_, err := w.Write(bodyBytes.Bytes())
if err != nil {
log.Printf("Failed to write rsvp.Response to HTTP: %s\n", err)
}
return nil
}
func Data(htmlTemplateName string, data any) Response {
return Response{
HtmlTemplateName: htmlTemplateName,
Data: data,
}
}
func (r Response) Log(format string, a ...any) Response {
r.LogMessage = fmt.Sprintf(format, a...)
return r
}
func (r Response) LogError(err error) Response {
r.LogMessage = fmt.Sprintf("%s", err)
return r
}
func (r Response) SaveSession(s Session) Response {
r.Session = &s
return r
}
func SeeOther(url string) Response {
return Response{SeeOther: url}
}
func Error(status int, format string, a ...any) Response {
return Response{
Status: status,
HtmlTemplateName: "error_page.gotmpl",
Data: struct{ Message string }{fmt.Sprintf(format, a...)},
}
}

77
http/server/server.go Normal file
View File

@ -0,0 +1,77 @@
package server
import (
"encoding/gob"
"net/http"
"strings"
"lishwist/http/api"
"lishwist/http/env"
"lishwist/http/response"
"lishwist/http/router"
"lishwist/http/routing"
"lishwist/http/session"
"github.com/Teajey/rsvp"
)
func prefixMovedPermanently(before, after string) response.HandlerFunc {
return func(s *response.Session, h http.Header, r *http.Request) rsvp.Response {
suffix := strings.TrimPrefix(r.RequestURI, before)
return rsvp.MovedPermanently(after + suffix)
}
}
func prefixPermanentRedirect(before, after string) response.HandlerFunc {
return func(s *response.Session, h http.Header, r *http.Request) rsvp.Response {
suffix := strings.TrimPrefix(r.RequestURI, before)
return rsvp.PermanentRedirect(after + suffix)
}
}
func Create(useSecureCookies bool) *router.VisibilityRouter {
gob.Register(&api.RegisterProps{})
gob.Register(&api.LoginProps{})
gob.Register(&routing.AccountProps{})
store := session.NewInMemoryStore([]byte(env.Configuration.SessionSecret))
store.Options.MaxAge = 86_400 // 24 hours in seconds
store.Options.Secure = useSecureCookies
store.Options.HttpOnly = true
store.Options.Path = "/"
store.Options.SameSite = http.SameSiteLaxMode
r := router.New(store)
r.Public.HandleFunc("GET /", routing.Login)
r.Public.HandleFunc("GET /groups/{groupReference}", routing.PublicGroup)
r.Public.HandleFunc("GET /lists/{userReference}", routing.PublicWishlist)
r.Public.HandleFunc("GET /register", routing.Register)
r.Public.HandleFunc("POST /", routing.LoginPost)
r.Public.HandleFunc("POST /register", routing.RegisterPost)
r.Private.HandleFunc("GET /events", routing.ExpectAppSession(routing.EventList))
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))
r.Private.HandleFunc("GET /lists/{userReference}", routing.ExpectAppSession(routing.ForeignWishlist))
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)
r.Private.HandleFunc("POST /users/{userReference}", routing.ExpectAppSession(routing.UserPost))
r.Private.HandleFunc("POST /{$}", routing.ExpectAppSession(routing.HomePost))
// Deprecated
r.HandleFunc("GET /group/{groupReference}", prefixMovedPermanently("/group/", "/groups/"))
r.HandleFunc("GET /list/{groupReference}", prefixMovedPermanently("/list/", "/lists/"))
r.HandleFunc("POST /group/{groupReference}", prefixPermanentRedirect("/group/", "/groups/"))
r.HandleFunc("POST /list/{groupReference}", prefixPermanentRedirect("/list/", "/lists/"))
return r
}

42
http/session/inmemory.go Normal file
View File

@ -0,0 +1,42 @@
package session
import (
"errors"
"lishwist/http/internal/id"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
)
var inMemStore = make(map[string]string)
var errNotFound = errors.New("not found")
func NewInMemoryStore(keyPairs ...[]byte) *Store {
return &Store{
callbacks: Callbacks{
Delete: func(key string) error {
delete(inMemStore, key)
return nil
},
Insert: func(encodedValues string) (string, error) {
key := id.Generate()
inMemStore[key] = encodedValues
return key, nil
},
Select: func(key string) (string, error) {
encodedValues, ok := inMemStore[key]
if !ok {
return "", errNotFound
}
return encodedValues, nil
},
Update: func(key string, encodedValues string) error {
inMemStore[key] = encodedValues
return nil
},
},
Codecs: securecookie.CodecsFromPairs(keyPairs...),
Options: &sessions.Options{},
}
}

View File

@ -1,25 +0,0 @@
package sesh
import (
"log"
"net/http"
"github.com/gorilla/sessions"
)
func GetFirstFlash(w http.ResponseWriter, r *http.Request, session *sessions.Session, key ...string) (any, error) {
flashes := session.Flashes(key...)
if len(flashes) < 1 {
return nil, nil
}
flash := flashes[0]
if err := session.Save(r, w); err != nil {
log.Println("Couldn't save session:", err)
return nil, err
}
return flash, nil
}

115
http/session/store.go Normal file
View File

@ -0,0 +1,115 @@
package session
import (
"fmt"
"net/http"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
)
type Callbacks struct {
Delete func(id string) error
Insert func(encodedValues string) (string, error)
Select func(id string) (string, error)
Update func(id, encodedValues string) error
}
type Store struct {
callbacks Callbacks
Codecs []securecookie.Codec
Options *sessions.Options
}
func NewGenericStore(cb Callbacks, keyPairs ...[]byte) *Store {
return &Store{
callbacks: cb,
Codecs: securecookie.CodecsFromPairs(keyPairs...),
Options: &sessions.Options{},
}
}
// Get should return a cached session.
func (m *Store) Get(r *http.Request, name string) (*sessions.Session, error) {
return sessions.GetRegistry(r).Get(m, name)
}
// New should create and return a new session.
//
// Note that New should never return a nil session, even in the case of
// an error if using the Registry infrastructure to cache the session.
func (s *Store) New(r *http.Request, name string) (*sessions.Session, error) {
session := sessions.NewSession(s, name)
opts := *s.Options
session.Options = &opts
session.IsNew = true
var err error
c, errCookie := r.Cookie(name)
if errCookie != nil {
return session, nil
}
err = securecookie.DecodeMulti(name, c.Value, &session.ID, s.Codecs...)
if err != nil {
return session, fmt.Errorf("failed to decode session id: %w", err)
}
sessionValue, err := s.callbacks.Select(session.ID)
if err != nil {
return session, fmt.Errorf("failed to get session value: %w", err)
}
err = securecookie.DecodeMulti(name, string(sessionValue), &session.Values, s.Codecs...)
if err == nil {
session.IsNew = false
} else {
err = fmt.Errorf("failed to decode session values: %w", err)
}
return session, err
}
// Save should persist session to the underlying store implementation.
func (s *Store) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
// Delete if max-age is <= 0
if session.Options.MaxAge <= 0 {
err := s.callbacks.Delete(session.ID)
if err != nil {
return fmt.Errorf("failed to delete session: %w", err)
}
http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options))
return nil
}
encodedValues, err := securecookie.EncodeMulti(session.Name(), session.Values,
s.Codecs...)
if err != nil {
return fmt.Errorf("failed to encode cookie value: %w", err)
}
if session.ID == "" {
i, err := s.callbacks.Insert(encodedValues)
if err != nil {
return fmt.Errorf("failed to insert session: %w", err)
}
session.ID = i
} else {
err := s.callbacks.Update(session.ID, encodedValues)
if err != nil {
return fmt.Errorf("failed to update session: %w", err)
}
}
encodedId, err := securecookie.EncodeMulti(session.Name(), session.ID,
s.Codecs...)
if err != nil {
return fmt.Errorf("failed to encode cookie value: %w", err)
}
http.SetCookie(w, sessions.NewCookie(session.Name(), encodedId, session.Options))
return nil
}

View File

@ -0,0 +1,49 @@
<!doctype html>
<html>
<head>
{{template "head" .}}
</head>
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
<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 the admin doesn'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

@ -12,10 +12,7 @@
{{end}}
{{end}}
<!DOCTYPE html>
<html>
<head>
{{define "head"}}
<title>Lishwist</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
@ -42,24 +39,66 @@
submitter.disabled = !accepted;
}
</script>
</head>
{{end}}
<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">
{{template "navbar" .}}
</div>
{{define "navbar"}}
<div class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<div class="navbar-brand">Lishwist</div>
{{with .}}
<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>{{if and .AccountLink .AccountLink.Alert}} <span
class="badge text-bg-danger">!</span>{{end}}
</button>
<div class="collapse navbar-collapse" id="navbarToggle">
{{with .Links}}
<nav>
<ul class="navbar-nav">
{{range .}}
<li class="nav-item">
<a class="nav-link" href="{{.Href}}">{{.Name}}</a>
</li>
{{end}}
</ul>
</nav>
{{end}}
<div class="flex-grow-1"></div>
{{with .User}}
<ul class="navbar-nav">
{{with .CopyList}}
<li class="nav-item"><button class="btn btn-success"
onclick="navigator.clipboard.writeText('{{.Domain}}/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>
{{end}}
<li class="nav-item">
<div class="dropdown">
<button class="nav-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Logged in as '{{.Name}}'{{if and $.AccountLink $.AccountLink.Alert}} <span class="badge text-bg-danger">!</span>{{end}}
</button>
<ul class="dropdown-menu">
{{with $.AccountLink}}
<li>
<a class="dropdown-item" href="/account">Account{{if .Alert}} <span
class="badge text-bg-danger">!</span>{{end}}</a>
</li>
{{end}}
<li>
<form class="d-contents" method="post" action="/logout">
<button class="dropdown-item" type="submit">Logout</button>
</form>
</li>
</ul>
</div>
</li>
</ul>
{{end}}
</div>
{{end}}
</div>
{{template "body" .}}
</div>
</body>
{{end}}
</html>
{{define "login_prompt"}}
<a href="/">Login</a> or <a href="/register">register</a>
{{end}}

View File

@ -1,17 +1,17 @@
{{define "navbar"}}
<nav>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
</ul>
</nav>
{{end}}
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "body"}}
<div class="container d-flex flex-grow-1 justify-content-center align-items-center flex-column">
<div class="alert alert-danger" role="alert">
<p class="mb-0">{{.Message}}</p>
</div>
</div>
{{end}}
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
<div class="container d-flex flex-grow-1 justify-content-center align-items-center flex-column">
<div class="alert alert-danger" role="alert">
<p class="mb-0">{{.Message}}</p>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,98 +1,81 @@
{{define "navbar"}}
<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>
{{end}}
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "body"}}
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card">
<div class="card-body">
<h2>{{.Username}}'s list</h2>
{{with .Gifts}}
<form method="post" autocomplete="off"
onchange="acceptNames(this, 'claimSubmit', 'claimed', 'unclaimed'); acceptNames(this, 'completeSubmit', 'claimed'); acceptAttribute(this, 'deleteSubmit', 'data-deletable')">
<ul class="list-group mb-3">
{{range .}}
{{$isMine := eq .ClaimantId $.CurrentUserId}}
{{$createdByMe := eq .CreatorId $.CurrentUserId}}
{{$outsideIdea := ne .RecipientId .CreatorId}}
<li class="list-group-item{{if .Sent}} list-group-item-light{{end}}">
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card">
<div class="card-body">
<h2>{{.Username}}'s list</h2>
{{with .Gifts}}
<form method="post" autocomplete="off"
onchange="acceptNames(this, 'claimSubmit', 'claimed', 'unclaimed'); acceptNames(this, 'completeSubmit', 'claimed'); acceptAttribute(this, 'deleteSubmit', 'data-deletable')">
<ul class="list-group mb-3">
{{range .}}
{{$isMine := eq .ClaimantId $.CurrentUserId}}
{{$createdByMe := eq .CreatorId $.CurrentUserId}}
{{$outsideIdea := ne .RecipientId .CreatorId}}
<li class="list-group-item{{if .Sent}} list-group-item-light{{end}}">
<input id="foreignlist_select_{{.Id}}" class="form-check-input" type="checkbox"
aria-describedby="wish_detail_{{.Id}}" {{if $isMine}} name="claimed" value="{{.Id}}" {{else if
.ClaimantId}} disabled {{else}} name="unclaimed" value="{{.Id}}" {{end}} {{if .Sent}} disabled {{end}}
{{if $createdByMe}}data-deletable{{end}}>
<input id="foreignlist_select_{{.Id}}" class="form-check-input" type="checkbox"
aria-describedby="wish_detail_{{.Id}}" {{if $isMine}} name="claimed" value="{{.Id}}" {{else if
.ClaimantId}} disabled {{else}} name="unclaimed" value="{{.Id}}" {{end}} {{if .Sent}} disabled {{end}}
{{if $createdByMe}}data-deletable{{end}}>
<label class="form-check-label stretched-link" for="foreignlist_select_{{.Id}}">
{{if .Sent}}
<s>{{.Name}}</s>
{{else}}
{{.Name}}
{{end}}
</label>
<label class="form-check-label stretched-link" for="foreignlist_select_{{.Id}}">
{{if .Sent}}
<s>{{.Name}}</s>
{{else}}
{{.Name}}
{{end}}
</label>
{{if or .ClaimantId $outsideIdea}}
<div class="d-inline" id="wish_detail_{{.Id}}">
{{if .ClaimantId}}
<span style="color: {{if $isMine}}blue{{else}}red{{end}};">{{if .Sent}}Completed{{else}}Claimed{{end}}
by
{{if $isMine}}<em>you</em>{{else}}{{.ClaimantName}}{{end}}</span>
{{end}}
{{if or .ClaimantId $outsideIdea}}
<div class="d-inline" id="wish_detail_{{.Id}}">
{{if .ClaimantId}}
<span style="color: {{if $isMine}}blue{{else}}red{{end}};">{{if .Sent}}Completed{{else}}Claimed{{end}}
by
{{if $isMine}}<em>you</em>{{else}}{{.ClaimantName}}{{end}}</span>
{{end}}
{{if $outsideIdea}}
<span style="color: green;">Added by {{if
$createdByMe}}<em>you</em>{{else}}{{.CreatorName}}{{end}}</span>
{{end}}
</div>
{{if $outsideIdea}}
<span style="color: green;">Added by {{if
$createdByMe}}<em>you</em>{{else}}{{.CreatorName}}{{end}}</span>
{{end}}
</div>
{{end}}
</li>
{{end}}
</ul>
<button id="claimSubmit" class="btn btn-warning" type="submit" name="intent" value="claim"
disabled>Claim/Unclaim</button>
<button id="completeSubmit" class="btn btn-success" type="submit" name="intent" value="complete"
disabled>Complete</button>
<button id="deleteSubmit" class="btn btn-danger" type="submit" name="intent" value="delete"
disabled>Delete</button>
</form>
{{else}}
<p>They don't have any gift ideas. Ask them to think of something, or add an idea yourself! 👇 (everyone
except them will be able to see it and claim it)</p>
{{end}}
</li>
{{end}}
</ul>
<button id="claimSubmit" class="btn btn-warning" type="submit" name="intent" value="claim"
disabled>Claim/Unclaim</button>
<button id="completeSubmit" class="btn btn-success" type="submit" name="intent" value="complete"
disabled>Complete</button>
<button id="deleteSubmit" class="btn btn-danger" type="submit" name="intent" value="delete"
disabled>Delete</button>
</form>
{{else}}
<p>They don't have any gift ideas. Ask them to think of something, or add an idea yourself! 👇 (everyone
except them will be able to see it and claim it)</p>
{{end}}
<form method="post">
<div class="input-group mt-3">
<input class="form-control" name="gift_name" required aria-describedby="gift_name_help" placeholder="Write a gift idea here" autofocus>
<button class="btn btn-primary" type="submit" name="intent" value="add">Add gift idea</button>
</div>
<div id="gift_name_help" class="form-text">This will be invisible to {{.Username}}, but everyone else will be
able to see it and
possibly claim it.</div>
</form>
<form method="post">
<div class="input-group mt-3">
<input class="form-control" name="gift_name" required aria-describedby="gift_name_help" placeholder="Write a gift idea here" autofocus>
<button class="btn btn-primary" type="submit" name="intent" value="add">Add gift idea</button>
</div>
<div id="gift_name_help" class="form-text">This will be invisible to {{.Username}}, but everyone else will be
able to see it and
possibly claim it.</div>
</form>
</div>
</section>
</div>
</div>
</section>
</div>
</div>
{{end}}
</div>
</body>
</html>

View File

@ -1,50 +1,32 @@
{{define "navbar"}}
<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>
{{end}}
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "body"}}
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card">
<div class="card-body">
<h2><em>{{.Group.Name}}</em> group members</h2>
{{with .Group.Members}}
<ul class="list-group">
{{range .}}
<li class="list-group-item">
<a href="/list/{{.Reference}}">{{.Name}}</a>
</li>
{{end}}
</ul>
{{else}}
<p>There's nobody else in this group.</p>
{{end}}
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card">
<div class="card-body">
<h2><em>{{.Group.Name}}</em> group members</h2>
{{with .Group.Members}}
<ul class="list-group">
{{range .}}
<li class="list-group-item">
<a href="/lists/{{.Reference}}">{{.Name}}</a>
</li>
{{end}}
</ul>
{{else}}
<p>There's nobody else in this group.</p>
{{end}}
</div>
</section>
</div>
</div>
</section>
</div>
</div>
{{end}}
</div>
</body>
</html>

View File

@ -1,113 +1,103 @@
{{define "navbar"}}
<div class="flex-grow-1"></div>
<ul class="navbar-nav">
<li class="nav-item"><button class="btn btn-success"
onclick="navigator.clipboard.writeText('{{.HostUrl}}/list/{{.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>
{{end}}
<!doctype html>
<html>
{{define "body"}}
<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>
<head>
{{template "head"}}
</head>
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
<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}}
</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>
<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>
</form>
</div>
</section>
</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="/list/{{.RecipientRef}}">{{.RecipientName}}</a>
</span>
</li>
<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}}
</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>
</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>
<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}}
</ul>
{{else}}
<p>You don't belong to any groups</p>
{{end}}
</div>
</section>
</div>
</section>
</div>
</div>
</div>
{{end}}
</body>
</html>

View File

@ -1,33 +1,45 @@
{{define "navbar"}}
{{end}}
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "body"}}
<div class="container d-flex flex-grow-1 justify-content-center align-items-center flex-column">
{{if .SuccessfulRegistration}}
<div class="alert alert-success" role="alert">
<p class="mb-0">Registration successful. Now you can login.</p>
</div>
{{end}}
{{with .GeneralError}}
<div class="alert alert-danger" role="alert">
<p class="mb-0">{{.}}</p>
</div>
{{end}}
<form method="post">
<div class="d-flex flex-column">
<label>
Username
{{template "input" .Username}}
</label>
<label>
Password
{{template "input" .Password}}
</label>
<div class="mb-3">
<a href="/register">Register</a>
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar"}}
<div class="container d-flex flex-grow-1 justify-content-center align-items-center flex-column">
{{if .SuccessfulRegistration}}
<div class="alert alert-success" role="alert">
<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>
</div>
{{end}}
<form method="post">
<div class="d-flex flex-column">
<label>
Username
{{template "input" .Username}}
</label>
<label>
Password
{{template "input" .Password}}
</label>
<div class="mb-3">
<a href="/register">Register</a>
</div>
<input class="btn btn-primary" type="submit" value="Login">
</div>
</form>
</div>
<input class="btn btn-primary" type="submit" value="Login">
</div>
</form>
</div>
{{end}}
</body>
</html>

View File

@ -1,37 +1,33 @@
{{define "navbar"}}
<nav>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
</ul>
</nav>
{{end}}
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "login_prompt"}}
<a href="/">Login</a> or <a href="/register">register</a>
{{end}}
{{define "body"}}
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card">
<div class="card-body">
<h2>{{.Username}}'s list</h2>
{{if eq .GiftCount 0}}
<p>{{.Username}} hasn't written any gift ideas!</p>
<p>{{template "login_prompt"}} to add some! :^)</p>
{{else}}
{{if eq .GiftCount 1}}
<p>{{.Username}} has only written one gift idea.</p>
<p>{{template "login_prompt"}} to claim it, or add more! :^)</p>
{{else}}
<p>{{.Username}} has written {{.GiftCount}} gift ideas.</p>
<p>{{template "login_prompt"}} to claim an idea, or add more! :^)</p>
{{end}}
{{end}}
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card">
<div class="card-body">
<h2>{{.Username}}'s list</h2>
{{if eq .GiftCount 0}}
<p>{{.Username}} hasn't written any gift ideas!</p>
<p>{{template "login_prompt"}} to add some! :^)</p>
{{else}}
{{if eq .GiftCount 1}}
<p>{{.Username}} has only written one gift idea.</p>
<p>{{template "login_prompt"}} to claim it, or add more! :^)</p>
{{else}}
<p>{{.Username}} has written {{.GiftCount}} gift ideas.</p>
<p>{{template "login_prompt"}} to claim an idea, or add more! :^)</p>
{{end}}
{{end}}
</div>
</section>
</div>
</div>
</section>
</div>
</div>
{{end}}
</div>
</body>
</html>

View File

@ -1,37 +1,33 @@
{{define "navbar"}}
<nav>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
</ul>
</nav>
{{end}}
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "login_prompt"}}
<a href="/">Login</a> or <a href="/register">register</a>
{{end}}
{{define "body"}}
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card">
<div class="card-body">
<h2><em>{{.Group.Name}}</em> group members</h2>
<p>{{template "login_prompt"}} to see your groups</p>
{{with .Group.Members}}
<ul class="list-group">
{{range .}}
<li class="list-group-item">
{{.Name}}
</li>
{{end}}
</ul>
{{else}}
<p>There's nobody else in this group.</p>
{{end}}
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card">
<div class="card-body">
<h2><em>{{.Group.Name}}</em> group members</h2>
<p>{{template "login_prompt"}} to see your groups</p>
{{with .Group.Members}}
<ul class="list-group">
{{range .}}
<li class="list-group-item">
{{.Name}}
</li>
{{end}}
</ul>
{{else}}
<p>There's nobody else in this group.</p>
{{end}}
</div>
</section>
</div>
</div>
</section>
</div>
</div>
{{end}}
</div>
</body>
</html>

View File

@ -1,40 +1,40 @@
{{define "navbar"}}
<nav>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
</ul>
</nav>
{{end}}
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "body"}}
<div class="container d-flex flex-grow-1 justify-content-center align-items-center flex-column">
<div class="alert alert-warning" role="alert">
<p>Your password will be stored in a safe, responsible manner; but don't trust my programming skills!</p>
<p class="mb-0">Maybe use a password here that you don't use for important things...</p>
</div>
{{with .GeneralError}}
<div class="alert alert-danger" role="alert">
<p class="mb-0">{{.}}</p>
</div>
{{end}}
<form method="post">
<div class="d-flex flex-column">
<label>
Username
{{template "input" .Username}}
</label>
<label>
Password
{{template "input" .Password}}
</label>
<label>
Confirm password
{{template "input" .ConfirmPassword}}
</label>
<input class="btn btn-primary" type="submit" value="Register">
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
<div class="container d-flex flex-grow-1 justify-content-center align-items-center flex-column">
<div class="alert alert-warning" role="alert">
<p>Your password will be stored in a safe, responsible manner; but don't trust my programming skills!</p>
<p class="mb-0">Maybe use a password here that you don't use for important things...</p>
</div>
{{with .GeneralError}}
<div class="alert alert-danger" role="alert">
<p class="mb-0">{{.}}</p>
</div>
{{end}}
<form method="post">
<div class="d-flex flex-column">
<label>
Username
{{template "input" .Username}}
</label>
<label>
Password
{{template "input" .Password}}
</label>
<label>
Confirm password
{{template "input" .ConfirmPassword}}
</label>
<input class="btn btn-primary" type="submit" value="Register">
</div>
</form>
</div>
</div>
</form>
</div>
{{end}}
</body>
</html>

View File

@ -2,10 +2,46 @@ package templates
import (
"fmt"
"io"
"text/template"
"html/template"
)
type AccountLink struct {
Alert bool
}
type Link struct {
Href string
Name string
}
type CopyList struct {
Domain string
Reference string
}
type User struct {
Name string
CopyList *CopyList
}
type NavCollapse struct {
User *User
AccountLink *AccountLink
Links []Link
}
func DefaultNavCollapse() NavCollapse {
return NavCollapse{Links: []Link{{Href: "/", Name: "Home"}}}
}
func UserNavCollapse(username string, accountAlert bool) NavCollapse {
return NavCollapse{
Links: []Link{{Href: "/", Name: "Home"}},
User: &User{Name: username},
AccountLink: &AccountLink{Alert: accountAlert},
}
}
type InputProps struct {
Type string `json:",omitempty"`
Name string
@ -30,33 +66,13 @@ func (p *InputProps) Validate() bool {
return true
}
var tmpls map[string]*template.Template = loadTemplates()
var Template *template.Template
func Execute(w io.Writer, name string, data any) error {
err := tmpls[name].Execute(w, data)
if err != nil {
return fmt.Errorf("Failed to execute '%s' template: %w\n", name, err)
}
return nil
func init() {
Template = load()
}
func loadTemplates() map[string]*template.Template {
homeTmpl := template.Must(template.ParseFiles("templates/base.gotmpl", "templates/home.gotmpl"))
loginTmpl := template.Must(template.ParseFiles("templates/base.gotmpl", "templates/login.gotmpl"))
registerTmpl := template.Must(template.ParseFiles("templates/base.gotmpl", "templates/register.gotmpl"))
foreignTmpl := template.Must(template.ParseFiles("templates/base.gotmpl", "templates/foreign_wishlist.gotmpl"))
publicWishlistTmpl := template.Must(template.ParseFiles("templates/base.gotmpl", "templates/public_foreign_wishlist.gotmpl"))
errorTmpl := template.Must(template.ParseFiles("templates/base.gotmpl", "templates/error_page.gotmpl"))
groupTmpl := template.Must(template.ParseFiles("templates/base.gotmpl", "templates/group_page.gotmpl"))
publicGroupTmpl := template.Must(template.ParseFiles("templates/base.gotmpl", "templates/public_group_page.gotmpl"))
return map[string]*template.Template{
"home.gotmpl": homeTmpl,
"login.gotmpl": loginTmpl,
"register.gotmpl": registerTmpl,
"foreign_wishlist.gotmpl": foreignTmpl,
"public_foreign_wishlist.gotmpl": publicWishlistTmpl,
"error_page.gotmpl": errorTmpl,
"group_page.gotmpl": groupTmpl,
"public_group_page.gotmpl": publicGroupTmpl,
}
func load() *template.Template {
t := template.Must(template.ParseGlob("templates/*.gotmpl"))
return t
}

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
}

5
scripts/api_changes_since Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
set -x
git diff --exit-code $1 api.snap.txt $(find . -name '*_test.go' -type f)

3
scripts/api_snapshot Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash
go doc -all $1 | ./scripts/strip_godoc_comments

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 "*")

11
scripts/pre-commit Executable file
View File

@ -0,0 +1,11 @@
#!/bin/sh
APISNAP=api.snap.txt
./scripts/api_snapshot ./core > core/$APISNAP
git diff --quiet core/$APISNAP
if [[ $? -ne 0 ]]; then
echo "There are unstaged changes to $APISNAP"
exit 1
fi

55
scripts/strip_godoc_comments Executable file
View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
import fileinput
from collections import deque
from typing import Iterator
from typing import TypeVar
T = TypeVar("T")
def window(it: Iterator[T], size: int = 2) -> Iterator[list[T]]:
if size < 2:
raise ValueError("Window size must be at least 2")
window: deque[T] = deque(maxlen=size)
for _ in range(size):
try:
window.append(next(it))
except StopIteration:
return
yield list(window)
for item in it:
window.append(item)
yield list(window)
struct = False
func = False
for line, next_line in window(fileinput.input()):
line = line.removesuffix("\n")
if line.startswith("type ") and line.endswith(" struct {"):
struct = True
elif struct and line.endswith("}"):
struct = False
elif line.startswith("func"):
func = True
elif next_line[:4].strip() != "":
func = False
if struct:
if (
line != ""
and not line.lstrip().startswith("//")
or "Has unexported fields" in line
):
print(line)
elif func:
if line != "" and not line.startswith(" "):
print(line)
else:
print(line)