Compare commits

..

No commits in common. "main" and "post-core-sep-fixes" have entirely different histories.

84 changed files with 1166 additions and 2323 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

View File

@ -1,127 +0,0 @@
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"`
}

View File

@ -1,150 +0,0 @@
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, 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"
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"
members, err := queryManyUsers(query, groupId)
if err != nil {
return members, err
@ -71,9 +71,8 @@ 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) {
@ -103,21 +102,15 @@ 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 (?, ?)"
result, err := db.Connection.Exec(stmt, userId, groupId)
_, err := db.Connection.Exec(stmt, userId, groupId)
if err != nil {
return fmt.Errorf("query execution failed: %w", err)
return 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
}
@ -133,5 +126,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.User().Id)
return queryManyGroups(stmt, u.Id)
}

View File

@ -15,12 +15,6 @@ 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) {
@ -32,7 +26,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)
@ -45,10 +39,4 @@ 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

@ -1,49 +0,0 @@
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

@ -1,62 +0,0 @@
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,7 +8,6 @@ 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" (
@ -30,29 +29,15 @@ 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,
"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')),
"value" TEXT NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT)
);

View File

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

View File

@ -1,19 +0,0 @@
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,28 +2,24 @@ 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,36 +14,21 @@ 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 {
t.Fatalf("Failed to init db: %s\n", err)
log.Fatalf("Failed to init db: %s\n", err)
}
_, err = lishwist.Register(username, password)
if err != nil {
t.Fatalf("Failed to register on login fixture: %s\n", err)
log.Fatalf("Failed to register on login fixture: %s\n", err)
}
session, err := lishwist.Login(username, password, time.Hour*24)
session, err := lishwist.Login(username, password)
if err != nil {
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)
log.Fatalf("Failed to login on fixture: %s\n", err)
}
return session

View File

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

View File

@ -2,7 +2,6 @@ package lishwist_test
import (
"testing"
"time"
lishwist "lishwist/core"
"lishwist/core/internal/fixtures"
@ -19,7 +18,7 @@ func TestLogin(t *testing.T) {
t.Fatalf("Failed to register: %s\n", err)
}
_, err = lishwist.Login("thomas", "123", time.Hour*24)
_, err = lishwist.Login("thomas", "123")
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", err)
return nil, fmt.Errorf("Failed to create user: %w\n", err)
}
return user, nil

View File

@ -1,67 +1,15 @@
package lishwist
import (
"database/sql"
"errors"
"fmt"
"time"
"lishwist/core/internal/db"
"lishwist/core/internal/id"
)
import "fmt"
type Session struct {
user User
Key string
Expiry time.Time
User
}
// 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
}
func SessionFromUsername(username string) (*Session, error) {
user, err := getUserByName(username)
if err != nil {
return nil, fmt.Errorf("failed to fetch session: %w", err)
return nil, fmt.Errorf("Failed to get user: %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
return &Session{*user}, nil
}

39
core/session/store.go Normal file
View File

@ -0,0 +1,39 @@
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,7 +4,6 @@ import (
"fmt"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"lishwist/core/internal/db"
"lishwist/core/internal/normalize"
@ -12,12 +11,12 @@ import (
type User struct {
Id string
// TODO: rename to DisplayName
NormalName string
Name string
Reference string
IsAdmin bool
IsLive bool
PasswordFromAdmin bool
}
func queryManyUsers(query string, args ...any) ([]User, error) {
@ -29,7 +28,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, &u.PasswordFromAdmin)
err = rows.Scan(&u.Id, &u.NormalName, &u.Name, &u.Reference, &u.IsAdmin, &u.IsLive)
if err != nil {
return nil, err
}
@ -55,7 +54,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, password_from_admin FROM v_user WHERE name = ?"
stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE name = ?"
return queryOneUser(stmt, username)
}
@ -78,7 +77,6 @@ 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
}
@ -93,12 +91,12 @@ func (u *User) getPassHash() ([]byte, error) {
}
func getUserByReference(reference string) (*User, error) {
stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM v_user WHERE reference = ?"
stmt := "SELECT id, name, display_name, reference, is_admin, is_live 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, password_from_admin FROM v_user WHERE id = ?"
stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE id = ?"
return queryOneUser(stmt, id)
}
@ -113,7 +111,7 @@ func hasUsers() (bool, error) {
}
func (*Admin) ListUsers() ([]User, error) {
stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM user"
stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM user"
return queryManyUsers(stmt)
}
@ -126,7 +124,7 @@ func GetUserByReference(reference string) (*User, error) {
}
func (u *User) GetTodo() ([]Wish, error) {
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"
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"
rows, err := db.Connection.Query(stmt, u.Id)
if err != nil {
return nil, err
@ -162,41 +160,6 @@ func (u *Admin) UserSetLive(userReference string, setting bool) error {
if err != nil {
return err
}
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
}
// u.IsLive = setting
return err
}

View File

@ -4,7 +4,6 @@ import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"lishwist/core/internal/db"
@ -24,8 +23,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 ORDER BY wish.sent"
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"
rows, err := db.Connection.Query(stmt, s.User.Id)
if err != nil {
return nil, fmt.Errorf("Query execution failed: %w", err)
}
@ -53,26 +52,19 @@ func (s *Session) GetWishes() ([]Wish, error) {
return wishs, nil
}
// May return the id of the wish
func (s *Session) MakeWish(name string) (string, error) {
func (s *Session) MakeWish(name string) error {
stmt := "INSERT INTO wish (name, recipient_id, creator_id) VALUES (?, ?, ?)"
result, err := db.Connection.Exec(stmt, strings.TrimSpace(name), s.User().Id, s.User().Id)
_, 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)
}
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
return nil
}
func (s *Session) deleteWishes(tx *sql.Tx, ids []string) error {
func (u *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, s.User().Id, id)
r, err := tx.Exec(stmt, u.Id, id)
if err != nil {
return err
}
@ -115,10 +107,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.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"
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 = ?"
rows, err := db.Connection.Query(stmt, otherUser.Id)
if err != nil {
return nil, fmt.Errorf("Failed to execute query: %w", err)
@ -172,7 +164,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.User().Id, id)
r, err := tx.Exec(claimStmt, s.Id, id)
if err != nil {
return err
}
@ -202,10 +194,7 @@ 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 {
lenClaims := len(claims)
lenUnclaims := len(unclaims)
// TODO: Would be nice if this used a request builder
if lenClaims < 1 && lenUnclaims < 1 {
if len(claims) < 1 && len(unclaims) < 1 {
return fmt.Errorf("Attempt to claim/unclaim zero wishes")
}
@ -224,18 +213,8 @@ func (s *Session) ClaimWishes(claims, unclaims []string) error {
}
err = tx.Commit()
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 {
claimStmt := "UPDATE wish SET sent = 1 WHERE id = ?"
@ -255,8 +234,8 @@ func executeCompletions(tx *sql.Tx, claims []string) error {
return nil
}
func (s *Session) CompleteWishes(claims []string) error {
// TODO: User ought not be able to interact with wishes outside their group network
func (s *Session) CompleteWishes(claims []string) error {
if len(claims) < 1 {
return fmt.Errorf("Attempt to complete zero wishes")
}
@ -276,22 +255,16 @@ func (s *Session) CompleteWishes(claims []string) error {
}
err = tx.Commit()
if err != nil {
return err
}
recordEventCompleteWishes(s.user.Id, claims...)
return nil
}
func (u *Session) SuggestWishForUser(otherUserReference string, wishName string) error {
otherUser, err := GetUserByReference(otherUserReference)
if err != nil {
return err
}
stmt := "INSERT INTO wish (name, recipient_id, creator_id) VALUES (?, ?, ?)"
_, err = db.Connection.Exec(stmt, wishName, otherUser.Id, u.User().Id)
_, err = db.Connection.Exec(stmt, wishName, otherUser.Id, u.Id)
if err != nil {
return err
}
@ -318,8 +291,5 @@ func (s *Session) RecindWishesForUser(ids ...string) error {
}
err = tx.Commit()
if err != nil {
return nil
}
return nil
return err
}

View File

@ -3,18 +3,17 @@ 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)
}
@ -27,35 +26,3 @@ 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.24.5
go 1.23.3
use (
./core

View File

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

View File

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

View File

@ -1,5 +0,0 @@
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

40
http/env/env.go vendored
View File

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

3
http/env/version.go vendored
View File

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

View File

@ -1,18 +1,25 @@
module lishwist/http
go 1.23.3
go 1.23
toolchain go1.24.5
toolchain go1.23.3
require (
github.com/Teajey/rsvp v0.13.1
github.com/Teajey/sqlstore v0.0.6
github.com/glebarez/go-sqlite v1.22.0
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.4.0
golang.org/x/crypto v0.39.0
golang.org/x/crypto v0.22.0
)
require github.com/gorilla/securecookie v1.1.2
require (
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
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
)

View File

@ -1,22 +1,39 @@
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/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/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/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=
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=

View File

@ -1,14 +0,0 @@
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,25 +1,67 @@
package main
import (
"encoding/gob"
"log"
"net/http"
lishwist "lishwist/core"
"lishwist/core/session"
"lishwist/http/api"
"lishwist/http/env"
"lishwist/http/server"
"lishwist/http/router"
"lishwist/http/routing"
)
func main() {
err := lishwist.Init(env.Configuration.DatabaseFile)
gob.Register(&api.RegisterProps{})
gob.Register(&api.LoginProps{})
err := lishwist.Init(env.DatabaseFile)
if err != nil {
log.Fatalf("Failed to init Lishwist: %s\n", err)
}
useSecureCookies := !env.Configuration.InDev
r := server.Create(useSecureCookies)
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
log.Printf("Running at http://127.0.0.1:%s\n", env.Configuration.ServePort)
err = http.ListenAndServe(":"+env.Configuration.ServePort, r)
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)
if err != nil {
log.Fatalln("Failed to listen and server:", err)
}

View File

@ -1,58 +0,0 @@
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)
}

View File

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

View File

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

View File

@ -1,119 +0,0 @@
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!")
}

View File

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

17
http/routing/error.go Normal file
View File

@ -0,0 +1,17 @@
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)))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

56
http/rsvp/handler.go Normal file
View File

@ -0,0 +1,56 @@
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)
}

42
http/rsvp/request.go Normal file
View File

@ -0,0 +1,42 @@
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
}

119
http/rsvp/response.go Normal file
View File

@ -0,0 +1,119 @@
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...)},
}
}

View File

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

View File

@ -1,77 +0,0 @@
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
}

View File

@ -1,42 +0,0 @@
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{},
}
}

25
http/session/session.go Normal file
View File

@ -0,0 +1,25 @@
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
}

View File

@ -1,115 +0,0 @@
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

@ -1,49 +0,0 @@
<!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,7 +12,10 @@
{{end}}
{{end}}
{{define "head"}}
<!DOCTYPE html>
<html>
<head>
<title>Lishwist</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
@ -39,66 +42,24 @@
submitter.disabled = !accepted;
}
</script>
{{end}}
</head>
{{define "navbar"}}
<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>
{{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}}
<span class="navbar-toggler-icon"></span>
</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}}
{{template "navbar" .}}
</div>
</div>
{{end}}
</div>
{{template "body" .}}
</div>
</body>
{{define "login_prompt"}}
<a href="/">Login</a> or <a href="/register">register</a>
{{end}}
</html>

View File

@ -1,17 +1,17 @@
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "navbar"}}
<nav>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
</ul>
</nav>
{{end}}
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
{{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>
</div>
</body>
</html>
{{end}}

View File

@ -1,12 +1,31 @@
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{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}}
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
{{define "body"}}
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card">
@ -76,6 +95,4 @@
</section>
</div>
</div>
</div>
</body>
</html>
{{end}}

View File

@ -1,12 +1,31 @@
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{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}}
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
{{define "body"}}
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card">
@ -16,7 +35,7 @@
<ul class="list-group">
{{range .}}
<li class="list-group-item">
<a href="/lists/{{.Reference}}">{{.Name}}</a>
<a href="/list/{{.Reference}}">{{.Name}}</a>
</li>
{{end}}
</ul>
@ -27,6 +46,5 @@
</section>
</div>
</div>
</div>
</body>
</html>
{{end}}

View File

@ -1,13 +1,27 @@
<!doctype html>
<html>
{{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}}
<head>
{{template "head"}}
</head>
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
{{define "body"}}
<div class="overflow-y-scroll flex-grow-1">
<div class="container py-5">
<section class="card mb-4">
@ -18,8 +32,7 @@
<ul class="list-group mb-3">
{{range .}}
<li class="list-group-item">
<input id="wishlist_select_{{.Id}}" class="form-check-input" type="checkbox" name="gift"
value="{{.Id}}">
<input id="wishlist_select_{{.Id}}" class="form-check-input" type="checkbox" name="gift" value="{{.Id}}">
<label class="form-check-label stretched-link" for="wishlist_select_{{.Id}}">
{{.Name}}
</label>
@ -63,7 +76,7 @@
</em>
</label>
<span id="todo_detail_{{.Id}}">
for <a href="/lists/{{.RecipientRef}}">{{.RecipientName}}</a>
for <a href="/list/{{.RecipientRef}}">{{.RecipientName}}</a>
</span>
</li>
{{end}}
@ -97,7 +110,4 @@
</section>
</div>
</div>
</div>
</body>
</html>
{{end}}

View File

@ -1,23 +1,13 @@
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "navbar"}}
{{end}}
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar"}}
{{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}}
{{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>
@ -40,6 +30,4 @@
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@ -1,12 +1,18 @@
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "navbar"}}
<nav>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
</ul>
</nav>
{{end}}
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
{{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">
@ -28,6 +34,4 @@
</section>
</div>
</div>
</div>
</body>
</html>
{{end}}

View File

@ -1,12 +1,18 @@
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "navbar"}}
<nav>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
</ul>
</nav>
{{end}}
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
{{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">
@ -28,6 +34,4 @@
</section>
</div>
</div>
</div>
</body>
</html>
{{end}}

View File

@ -1,12 +1,14 @@
<!doctype html>
<html>
<head>
{{template "head"}}
</head>
{{define "navbar"}}
<nav>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
</ul>
</nav>
{{end}}
<body>
<div style="height: 100svh;" class="d-flex flex-column">
{{template "navbar" .Navbar}}
{{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>
@ -35,6 +37,4 @@
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@ -2,46 +2,10 @@ package templates
import (
"fmt"
"html/template"
"io"
"text/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
@ -66,13 +30,33 @@ func (p *InputProps) Validate() bool {
return true
}
var Template *template.Template
var tmpls map[string]*template.Template = loadTemplates()
func init() {
Template = load()
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 load() *template.Template {
t := template.Must(template.ParseGlob("templates/*.gotmpl"))
return t
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,
}
}

View File

@ -1,8 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,17 +0,0 @@
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
}

View File

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

View File

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

View File

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

View File

@ -1,11 +0,0 @@
#!/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

View File

@ -1,55 +0,0 @@
#!/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)