Compare commits

..

No commits in common. "main" and "response-middleware-1" have entirely different histories.

129 changed files with 2385 additions and 4076 deletions

4
.gitignore vendored
View File

@ -1,6 +1,6 @@
.DS_Store .DS_Store
gin-bin gin-bin
*lishwist.db lishwist.db
.env*.local .env*.local
init_sql.go server/db/init_sql.go
.ignored/ .ignored/

View File

@ -1,13 +0,0 @@
package lishwist
type Admin struct {
session *Session
}
func (s *Session) Admin() *Admin {
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,55 +0,0 @@
package lishwist
import (
"database/sql"
"fmt"
"log"
)
func PrintViews(d *sql.DB) {
rows, err := d.Query("SELECT name FROM sqlite_master WHERE type = 'view';")
if err != nil {
log.Println("Query failed: %w", err)
return
}
defer rows.Close()
fmt.Println("Printing view names...")
for rows.Next() {
var name string
err = rows.Scan(&name)
if err != nil {
log.Println("Scan failed: %w", err)
return
}
fmt.Printf("name: %s\n", name)
}
err = rows.Err()
if err != nil {
log.Println("Rows returned error: %w", err)
return
}
}
func PrintTables(d *sql.DB) {
rows, err := d.Query("SELECT name FROM sqlite_master WHERE type = 'table';")
if err != nil {
log.Println("Query failed: %w", err)
return
}
defer rows.Close()
fmt.Println("Printing table names...")
for rows.Next() {
var name string
err = rows.Scan(&name)
if err != nil {
fmt.Println("Scan failed: %w", err)
return
}
fmt.Printf("name: %s\n", name)
}
err = rows.Err()
if err != nil {
log.Println("Rows returned error: %w", err)
return
}
}

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

@ -1,18 +0,0 @@
module lishwist/core
go 1.23.0
toolchain go1.23.3
require golang.org/x/crypto v0.39.0
require (
github.com/google/uuid v1.6.0
github.com/ncruces/go-sqlite3 v0.26.1
)
require (
github.com/ncruces/julianday v1.0.0 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
golang.org/x/sys v0.33.0 // indirect
)

View File

@ -1,14 +0,0 @@
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/ncruces/go-sqlite3 v0.26.1 h1:lBXmbmucH1Bsj57NUQR6T84UoMN7jnNImhF+ibEITJU=
github.com/ncruces/go-sqlite3 v0.26.1/go.mod h1:XFTPtFIo1DmGCh+XVP8KGn9b/o2f+z0WZuT09x2N6eo=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
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=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=

View File

@ -1,137 +0,0 @@
package lishwist
import (
"fmt"
"lishwist/core/internal/db"
"strconv"
"strings"
)
type Group struct {
Id string
Name string
Reference string
Members []User
}
func (g *Group) MemberIndex(userId string) int {
for i, u := range g.Members {
if u.Id == userId {
return i
}
}
return -1
}
func queryManyGroups(query string, args ...any) ([]Group, error) {
groups := []Group{}
rows, err := db.Connection.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("Query failed: %w", err)
}
defer rows.Close()
for rows.Next() {
var group Group
err := rows.Scan(&group.Id, &group.Name, &group.Reference)
if err != nil {
return nil, fmt.Errorf("Failed to scan row: %w", err)
}
members, err := queryManyGroupMembers(group.Id)
if err != nil {
return nil, fmt.Errorf("Failed to query for group members: %w", err)
}
group.Members = members
groups = append(groups, group)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("Rows error: %w", err)
}
return groups, nil
}
func queryOneGroup(query string, args ...any) (*Group, error) {
groups, err := queryManyGroups(query, args...)
if err != nil {
return nil, err
}
if len(groups) < 1 {
return nil, nil
}
return &groups[0], nil
}
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"
members, err := queryManyUsers(query, groupId)
if err != nil {
return members, err
}
return members, nil
}
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)
}
func GetGroupByReference(reference string) (*Group, error) {
stmt := "SELECT [group].id, [group].name, [group].reference FROM [group] WHERE [group].reference = ?;"
return queryOneGroup(stmt, reference)
}
func (a *Admin) ListGroups() ([]Group, error) {
query := "SELECT id, name, reference FROM [group];"
return queryManyGroups(query)
}
func (a *Admin) CreateGroup(name string, reference string) (*Group, error) {
name = strings.TrimSpace(name)
reference = strings.TrimSpace(reference)
stmt := "INSERT INTO [group] (name, reference) VALUES (?, ?)"
result, err := db.Connection.Exec(stmt, name, reference)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
group := Group{
Id: strconv.FormatInt(id, 10),
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)
if err != nil {
return fmt.Errorf("query execution failed: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
recordEventCreateGroupMember(a.session.user.Id, strconv.FormatInt(id, 10))
return nil
}
func (a *Admin) RemoveUserFromGroup(userId, groupId string) error {
stmt := "DELETE FROM group_member WHERE group_id = ? AND user_id = ?"
_, err := db.Connection.Exec(stmt, userId, groupId)
if err != nil {
return err
}
return nil
}
// 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)
}

View File

@ -1,54 +0,0 @@
package lishwist_test
import (
"testing"
lishwist "lishwist/core"
"lishwist/core/internal/fixtures"
)
func TestCreateGroup(t *testing.T) {
s := fixtures.Login(t, "thomas", "123")
group, err := s.Admin().CreateGroup(" My Friends ", " my-friends ")
fixtures.FailIfErr(t, err, "Failed to create group")
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) {
s := fixtures.Login(t, "thomas", "123")
caleb, err := lishwist.Register("caleb", "123")
fixtures.FailIfErr(t, err, "Failed to register caleb")
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)
fixtures.FailIfErr(t, err, "Failed to add self to group")
err = s.Admin().AddUserToGroup(caleb.Id, group.Id)
fixtures.FailIfErr(t, err, "Failed to add caleb to group")
group, err = s.GetGroupByReference("my-friends")
fixtures.FailIfErr(t, err, "Failed to get group")
fixtures.FatalAssert(t, "Group not nil", group != nil)
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,9 +0,0 @@
package lishwist
import (
"lishwist/core/internal/db"
)
func Init(dataSourceName string) error {
return db.Init(dataSourceName)
}

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

@ -1,29 +0,0 @@
//go:generate go run gen_init_sql.go
package db
import (
"database/sql"
"fmt"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
)
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)
}
_, err = db.Exec(initQuery)
if err != nil {
return fmt.Errorf("failed to initialize db: %w", err)
}
Connection = db
return nil
}

View File

@ -1,28 +0,0 @@
BEGIN TRANSACTION;
ALTER TABLE gift RENAME TO wish;
ALTER TABLE user ADD COLUMN display_name TEXT NOT NULL DEFAULT "";
UPDATE user SET display_name = name;
ALTER TABLE user RENAME TO old_user;
CREATE TABLE "user" (
"id" INTEGER NOT NULL UNIQUE,
"name" TEXT NOT NULL UNIQUE,
"display_name" TEXT NOT NULL UNIQUE,
"reference" TEXT NOT NULL UNIQUE,
"motto" TEXT NOT NULL DEFAULT "",
"password_hash" TEXT NOT NULL,
"is_admin" INTEGER NOT NULL DEFAULT 0,
"is_live" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY("id" AUTOINCREMENT)
);
INSERT INTO user (id, name, display_name, reference, motto, password_hash, is_admin, is_live) SELECT id, name, name, reference, motto, password_hash, is_admin, is_live FROM old_user;
DROP TABLE "old_user";
COMMIT;

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

@ -1,31 +0,0 @@
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,50 +0,0 @@
package fixtures
import (
"testing"
"time"
lishwist "lishwist/core"
"github.com/ncruces/go-sqlite3/vfs/memdb"
)
func TestInit(t *testing.T) error {
uri := memdb.TestDB(t)
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)
}
_, err = lishwist.Register(username, password)
if err != nil {
t.Fatalf("Failed to register on login fixture: %s\n", err)
}
session, err := lishwist.Login(username, password, time.Hour*24)
if err != nil {
t.Fatalf("Failed to login on fixture: %s\n", err)
}
return session
}
func Login2(t *testing.T, username, password string) *lishwist.Session {
_, err := lishwist.Register(username, password)
if err != nil {
t.Fatalf("Failed to register on login fixture: %s\n", err)
}
session, err := lishwist.Login(username, password, time.Hour*24)
if err != nil {
t.Fatalf("Failed to login on fixture: %s\n", err)
}
return session
}

View File

@ -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,10 +0,0 @@
package normalize
import (
"strings"
)
func Name(name string) string {
name = strings.TrimSpace(name)
return strings.ToLower(name)
}

View File

@ -1,37 +0,0 @@
package lishwist
import (
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
)
type ErrorInvalidCredentials error
func Login(username, password string, sessionMaxAge time.Duration) (*Session, error) {
user, err := getUserByName(username)
if err != nil {
return nil, ErrorInvalidCredentials(fmt.Errorf("Failed to fetch user: %w", err))
}
if user == nil {
return nil, ErrorInvalidCredentials(fmt.Errorf("User not found by name: %s", username))
}
passHash, err := user.getPassHash()
if err != nil {
return nil, fmt.Errorf("Failed to get password hash: %w", err)
}
err = bcrypt.CompareHashAndPassword(passHash, []byte(password))
if err != nil {
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
}

View File

@ -1,26 +0,0 @@
package lishwist_test
import (
"testing"
"time"
lishwist "lishwist/core"
"lishwist/core/internal/fixtures"
)
func TestLogin(t *testing.T) {
err := fixtures.TestInit(t)
if err != nil {
t.Fatalf("Failed to init db: %s\n", err)
}
_, err = lishwist.Register("thomas", "123")
if err != nil {
t.Fatalf("Failed to register: %s\n", err)
}
_, err = lishwist.Login("thomas", "123", time.Hour*24)
if err != nil {
t.Fatalf("Failed to login: %s\n", err)
}
}

View File

@ -1,41 +0,0 @@
package lishwist
import (
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
)
var ErrorUsernameTaken = errors.New("username is taken")
func Register(username, newPassword string) (*User, error) {
if username == "" {
return nil, errors.New("Username required")
}
if newPassword == "" {
return nil, errors.New("newPassword required")
}
existingUser, _ := getUserByName(username)
if existingUser != nil {
return nil, ErrorUsernameTaken
}
hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost)
if err != nil {
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)
}
user, err := createUser(username, hashedPasswordBytes, !usersExist)
if err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
return user, nil
}

View File

@ -1,67 +0,0 @@
package lishwist
import (
"database/sql"
"errors"
"fmt"
"time"
"lishwist/core/internal/db"
"lishwist/core/internal/id"
)
type Session struct {
user User
Key string
Expiry time.Time
}
// Returns a copy of the user associated with this session
func (s *Session) User() User {
return s.user
}
func SessionFromKey(key string) (*Session, error) {
s := Session{}
query := "SELECT user.id, user.name, user.display_name, user.reference, user.is_admin, user.is_live, user.password_from_admin, session.key, session.expiry FROM v_user as user JOIN session ON user.id = session.user_id WHERE session.key = ?"
var expiry string
err := db.Connection.QueryRow(query, key).Scan(
&s.user.Id,
&s.user.NormalName,
&s.user.Name,
&s.user.Reference,
&s.user.IsAdmin,
&s.user.IsLive,
&s.user.PasswordFromAdmin,
&s.Key,
&expiry,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to fetch session: %w", err)
}
s.Expiry, err = time.Parse(time.RFC3339Nano, expiry)
if err != nil {
return nil, fmt.Errorf("failed to parse session expiry: %w", err)
}
if time.Now().After(s.Expiry) {
return nil, nil
}
return &s, err
}
func insertSession(user User, maxAge time.Duration) (*Session, error) {
s := Session{
user: user,
Key: id.Generate(),
Expiry: time.Now().Add(maxAge),
}
stmt := "INSERT INTO session (key, user_id, expiry) VALUES (?, ?, ?)"
_, err := db.Connection.Exec(stmt, &s.Key, &user.Id, &s.Expiry)
if err != nil {
return nil, fmt.Errorf("failed to execute query: %w", err)
}
return &s, nil
}

View File

@ -1,202 +0,0 @@
package lishwist
import (
"fmt"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"lishwist/core/internal/db"
"lishwist/core/internal/normalize"
)
type User struct {
Id string
NormalName string
Name string
Reference string
IsAdmin bool
IsLive bool
PasswordFromAdmin bool
}
func queryManyUsers(query string, args ...any) ([]User, error) {
rows, err := db.Connection.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
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)
if err != nil {
return nil, err
}
users = append(users, u)
}
err = rows.Err()
if err != nil {
return nil, err
}
return users, nil
}
func queryOneUser(query string, args ...any) (*User, error) {
users, err := queryManyUsers(query, args...)
if err != nil {
return nil, err
}
if len(users) < 1 {
return nil, nil
}
return &users[0], nil
}
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 = ?"
return queryOneUser(stmt, username)
}
func createUser(name string, passHash []byte, isAdmin bool) (*User, error) {
username := normalize.Name(name)
stmt := "INSERT INTO user (name, display_name, reference, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)"
reference, err := uuid.NewRandom()
if err != nil {
return nil, fmt.Errorf("Failed to generate reference: %w", err)
}
result, err := db.Connection.Exec(stmt, username, name, reference, passHash, isAdmin)
if err != nil {
return nil, fmt.Errorf("Failed to execute query: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("Failed to get last insert id: %w", err)
}
user := User{
Id: fmt.Sprintf("%d", id),
Name: name,
}
recordEventCreateUser(user.Id, user.Id)
return &user, nil
}
func (u *User) getPassHash() ([]byte, error) {
stmt := "SELECT password_hash FROM v_user WHERE id = ?"
var passHash string
err := db.Connection.QueryRow(stmt, u.Id).Scan(&passHash)
if err != nil {
return nil, err
}
return []byte(passHash), nil
}
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 = ?"
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 = ?"
return queryOneUser(stmt, id)
}
func hasUsers() (bool, error) {
stmt := "SELECT COUNT(id) FROM v_user LIMIT 1"
var userCount uint
err := db.Connection.QueryRow(stmt).Scan(&userCount)
if err != nil {
return false, err
}
return userCount > 0, nil
}
func (*Admin) ListUsers() ([]User, error) {
stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM user"
return queryManyUsers(stmt)
}
func (*Admin) GetUser(id string) (*User, error) {
return getUserById(id)
}
func GetUserByReference(reference string) (*User, error) {
return getUserByReference(reference)
}
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"
rows, err := db.Connection.Query(stmt, u.Id)
if err != nil {
return nil, err
}
defer rows.Close()
wishes := []Wish{}
for rows.Next() {
var id string
var name string
var sent bool
var recipientName string
var recipientRef string
_ = rows.Scan(&id, &name, &sent, &recipientName, &recipientRef)
wish := Wish{
Id: id,
Name: name,
Sent: sent,
RecipientName: recipientName,
RecipientRef: recipientRef,
}
wishes = append(wishes, wish)
}
err = rows.Err()
if err != nil {
return nil, err
}
return wishes, nil
}
func (u *Admin) UserSetLive(userReference string, setting bool) error {
query := "UPDATE user SET is_live = ? WHERE reference = ?"
_, err := db.Connection.Exec(query, setting, userReference)
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
}
return err
}

View File

@ -1,22 +0,0 @@
package lishwist_test
import (
"testing"
lishwist "lishwist/core"
"lishwist/core/internal/fixtures"
)
func TestFirstUserIsAdmin(t *testing.T) {
s := fixtures.Login(t, "thomas", "123")
_, err := lishwist.Register("caleb", "123")
fixtures.FailIfErr(t, err, "Failed to register caleb")
users, err := s.Admin().ListUsers()
fixtures.FailIfErr(t, err, "Failed to list users")
fixtures.AssertEq(t, "Number of users", 2, len(users))
fixtures.Assert(t, "User 1 is admin", users[0].IsAdmin)
fixtures.Assert(t, "User 2 is not admin", !users[1].IsAdmin)
}

View File

@ -1,325 +0,0 @@
package lishwist
import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"lishwist/core/internal/db"
)
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"`
}
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)
if err != nil {
return nil, fmt.Errorf("Query execution failed: %w", err)
}
defer rows.Close()
wishs := []Wish{}
for rows.Next() {
var id string
var name string
var sent bool
err = rows.Scan(&id, &name, &sent)
if err != nil {
return nil, fmt.Errorf("Failed to scan a row: %w", err)
}
wish := Wish{
Id: id,
Name: name,
Sent: sent,
}
wishs = append(wishs, wish)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("Rows returned an error: %w", err)
}
return wishs, nil
}
// May return the id of the wish
func (s *Session) MakeWish(name string) (string, error) {
stmt := "INSERT INTO wish (name, recipient_id, creator_id) VALUES (?, ?, ?)"
result, err := db.Connection.Exec(stmt, strings.TrimSpace(name), s.User().Id, s.User().Id)
if err != nil {
return "", fmt.Errorf("Query execution failed: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return "", fmt.Errorf("failed to get last insert id: %w", err)
}
wishId := strconv.FormatInt(id, 10)
recordEventCreateWish(s.user.Id, wishId)
return wishId, nil
}
func (s *Session) deleteWishes(tx *sql.Tx, ids []string) error {
stmt := "DELETE FROM wish WHERE wish.creator_id = ? AND wish.id = ?"
for _, id := range ids {
r, err := tx.Exec(stmt, s.User().Id, id)
if err != nil {
return err
}
rE, err := r.RowsAffected()
if err != nil {
return err
}
if rE < 1 {
return fmt.Errorf("Wish deletion failed for '%s'", id)
}
}
return nil
}
func (s *Session) RevokeWishes(ids ...string) error {
if len(ids) < 1 {
return fmt.Errorf("Attempt to remove zero wishes")
}
tx, err := db.Connection.Begin()
if err != nil {
return err
}
err = s.deleteWishes(tx, ids)
if err != nil {
rollBackErr := tx.Rollback()
if rollBackErr != nil {
return err
}
return err
}
err = tx.Commit()
return err
}
func (s *Session) GetOthersWishes(userReference string) ([]Wish, error) {
otherUser, err := getUserByReference(userReference)
if err != nil {
return nil, fmt.Errorf("Failed to get other user: %w", err)
}
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"
rows, err := db.Connection.Query(stmt, otherUser.Id)
if err != nil {
return nil, fmt.Errorf("Failed to execute query: %w", err)
}
defer rows.Close()
wishes := []Wish{}
for rows.Next() {
var id string
var name string
var claimantId sql.NullString
var claimantName sql.NullString
var sent bool
var creatorId string
var creatorName string
var recipientId string
err = rows.Scan(&id, &name, &claimantId, &claimantName, &sent, &creatorId, &creatorName, &recipientId)
if err != nil {
return nil, fmt.Errorf("Failed to scan a row: %w", err)
}
wish := Wish{
Id: id,
Name: name,
ClaimantId: claimantId.String,
ClaimantName: claimantName.String,
Sent: sent,
CreatorId: creatorId,
CreatorName: creatorName,
RecipientId: recipientId,
}
wishes = append(wishes, wish)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("Rows returned an error: %w", err)
}
return wishes, nil
}
// NOTE: This could just be a field on the user... but only if we get this often
func (u *User) WishCount() (int, error) {
stmt := "SELECT COUNT(wish.id) AS wish_count FROM wish WHERE wish.creator_id = ?1 AND wish.recipient_id = ?1"
var wishCount int
err := db.Connection.QueryRow(stmt, u.Id).Scan(&wishCount)
if err != nil {
return 0, err
}
return wishCount, nil
}
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)
if err != nil {
return err
}
rE, err := r.RowsAffected()
if err != nil {
return err
}
if rE < 1 {
return fmt.Errorf("Wish claim failed for '%s'", id)
}
}
for _, id := range unclaims {
r, err := tx.Exec(unclaimStmt, id)
if err != nil {
return err
}
rE, err := r.RowsAffected()
if err != nil {
return err
}
if rE < 1 {
return fmt.Errorf("Wish unclaim failed for '%s'", id)
}
}
return nil
}
// 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 {
return fmt.Errorf("Attempt to claim/unclaim zero wishes")
}
tx, err := db.Connection.Begin()
if err != nil {
return err
}
err = s.executeClaims(tx, claims, unclaims)
if err != nil {
rollBackErr := tx.Rollback()
if rollBackErr != nil {
return err
}
return err
}
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 = ?"
for _, id := range claims {
r, err := tx.Exec(claimStmt, id)
if err != nil {
return err
}
rE, err := r.RowsAffected()
if err != nil {
return err
}
if rE < 1 {
return fmt.Errorf("Wish completion failed for '%s'", id)
}
}
return nil
}
func (s *Session) CompleteWishes(claims []string) error {
// TODO: User ought not be able to interact with wishes outside their group network
if len(claims) < 1 {
return fmt.Errorf("Attempt to complete zero wishes")
}
tx, err := db.Connection.Begin()
if err != nil {
return err
}
err = executeCompletions(tx, claims)
if err != nil {
rollBackErr := tx.Rollback()
if rollBackErr != nil {
return err
}
return err
}
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)
if err != nil {
return err
}
return nil
}
func (s *Session) RecindWishesForUser(ids ...string) error {
if len(ids) < 1 {
return fmt.Errorf("Attempt to remove zero wishes")
}
tx, err := db.Connection.Begin()
if err != nil {
return err
}
err = s.deleteWishes(tx, ids)
if err != nil {
rollBackErr := tx.Rollback()
if rollBackErr != nil {
return err
}
return err
}
err = tx.Commit()
if err != nil {
return nil
}
return nil
}

View File

@ -1,61 +0,0 @@
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 {
t.Fatalf("Failed to make wish 1: %s\n", err)
}
if _, err := s.MakeWish(" A car "); err != nil {
t.Fatalf("Failed to make wish 2: %s\n", err)
}
wishes, err := s.GetWishes()
if err != nil {
t.Fatalf("Failed to get wishes: %s\n", err)
}
fixtures.AssertEq(t, "Number of wishes", 2, len(wishes))
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,6 +1,5 @@
go 1.24.5 go 1.23
use ( toolchain go1.23.3
./core
./http use ./server
)

View File

@ -1,16 +1,12 @@
github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/ncruces/sort v0.1.5/go.mod h1:obJToO4rYr6VWP0Uw5FYymgYGt3Br4RXcs/JdKaXAPk= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
lukechampine.com/adiantum v1.1.1/go.mod h1:LrAYVnTYLnUtE/yMp5bQr0HstAf060YUF8nM0B6+rUw=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=

View File

@ -1,43 +0,0 @@
package api
import (
"lishwist/http/templates"
)
type LoginProps struct {
GeneralError string `json:",omitempty"`
SuccessfulRegistration bool `json:",omitempty"`
SuccessfulSetPassword bool `json:",omitempty"`
Username templates.InputProps
Password templates.InputProps
}
func NewLoginProps(username, password string) *LoginProps {
return &LoginProps{
Username: templates.InputProps{
Name: "username",
Required: true,
Value: username,
},
Password: templates.InputProps{
Name: "password",
Type: "password",
Required: true,
Value: password,
},
}
}
func (p *LoginProps) Validate() (valid bool) {
valid = true
if !p.Username.Validate() {
valid = false
}
if !p.Password.Validate() {
valid = false
}
return
}

View File

@ -1,97 +0,0 @@
package api
import (
"lishwist/http/templates"
)
type RegisterProps struct {
Navbar templates.NavCollapse
GeneralError string `json:",omitempty"`
Username templates.InputProps
Password templates.InputProps
ConfirmPassword templates.InputProps
}
func (p *RegisterProps) Validate() (valid bool) {
valid = true
if p.Password.Value != p.ConfirmPassword.Value {
p.ConfirmPassword.Error = "Passwords didn't match"
valid = false
}
if !p.Username.Validate() {
valid = false
}
if !p.Password.Validate() {
valid = false
}
if !p.ConfirmPassword.Validate() {
valid = false
}
return
}
func NewRegisterProps(usernameVal, passwordVal, confirmPassVal string) *RegisterProps {
return &RegisterProps{
Navbar: templates.DefaultNavCollapse(),
GeneralError: "",
Username: templates.InputProps{
Name: "username",
Required: true,
MinLength: 4,
Value: usernameVal,
},
Password: templates.InputProps{
Type: "password",
Name: "newPassword",
Required: true,
MinLength: 5,
Value: passwordVal,
},
ConfirmPassword: templates.InputProps{
Type: "password",
Name: "confirmPassword",
Required: true,
Value: confirmPassVal,
},
}
}
// func Register(username, newPassword, confirmPassword string) *RegisterProps {
// props := NewRegisterProps(username, newPassword, confirmPassword)
// valid := props.Validate()
// props.Password.Value = ""
// props.ConfirmPassword.Value = ""
// if !valid {
// log.Printf("Invalid props: %#v\n", props)
// return props
// }
// existingUser, _ := db.GetUserByName(username)
// if existingUser != nil {
// log.Printf("Username is taken: %q\n", existingUser.NormalName)
// props.Username.Error = "Username is taken"
// return props
// }
// hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost)
// if err != nil {
// log.Printf("Failed to hash password: %s\n", err)
// props.GeneralError = "Something went wrong. Error code: Aang"
// return props
// }
// _, err = db.CreateUser(username, hashedPasswordBytes)
// if err != nil {
// log.Printf("Failed to create user: %s\n", err)
// props.GeneralError = "Something went wrong. Error code: Ozai"
// return props
// }
// return nil
// }

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,19 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"golang.org/x/crypto/bcrypt"
)
func main() {
passwordHash := []byte(os.Args[1])
password := []byte(os.Args[2])
err := bcrypt.CompareHashAndPassword(passwordHash, password)
if err != nil {
log.Fatalln("Failed to match: ", err)
}
fmt.Println("Match!")
}

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

47
http/env/env.go vendored
View File

@ -1,47 +0,0 @@
package env
import (
"log"
"net/url"
"os"
)
func GuaranteeEnv(key string) string {
variable, ok := os.LookupEnv(key)
if !ok || variable == "" {
log.Fatalln("Missing environment variable:", key)
}
return variable
}
type Config struct {
DatabaseFile string
SessionSecret string
HostRootUrl string
HostPort string
ServePort string
InDev bool
HostUrl string
}
var Configuration Config
func init() {
Configuration.DatabaseFile = GuaranteeEnv("LISHWIST_DATABASE_FILE")
Configuration.SessionSecret = GuaranteeEnv("LISHWIST_SESSION_SECRET")
Configuration.HostRootUrl = GuaranteeEnv("LISHWIST_HOST_ROOT_URL")
Configuration.HostPort = os.Getenv("LISHWIST_HOST_PORT")
Configuration.ServePort = GuaranteeEnv("LISHWIST_SERVE_PORT")
Configuration.InDev = os.Getenv("LISHWIST_IN_DEV") != ""
Configuration.HostUrl = func() string {
rawUrl := Configuration.HostRootUrl
if Configuration.HostPort != "" {
rawUrl += ":" + Configuration.HostPort
}
u, err := url.Parse(rawUrl)
if err != nil {
log.Fatalln("Couldn't parse host url:", err)
}
return u.String()
}()
}

3
http/env/version.go vendored
View File

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

View File

@ -1,18 +0,0 @@
module lishwist/http
go 1.23.3
toolchain go1.24.5
require (
github.com/Teajey/rsvp v0.13.1
github.com/gorilla/sessions v1.4.0
golang.org/x/crypto v0.39.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
)

View File

@ -1,22 +0,0 @@
github.com/Teajey/rsvp v0.13.1 h1:0lw+JosaWmdjSmXoKQYBRS9nptSZPInm60Y5GQ3llEU=
github.com/Teajey/rsvp v0.13.1/go.mod h1:z0L20VphVg+Ec2+hnpLFTG2MZTrWYFprav1kpxDba0Q=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/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=

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

View File

@ -1,14 +0,0 @@
package normalize
import (
"strings"
)
func Trim(s string) string {
return strings.Trim(s, " \t")
}
func Name(name string) string {
name = Trim(name)
return strings.ToLower(name)
}

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

View File

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

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 +0,0 @@
package routing
import (
"log"
"net/http"
lishwist "lishwist/core"
"lishwist/http/response"
"github.com/Teajey/rsvp"
)
func ExpectAppSession(next func(*lishwist.Session, *response.Session, http.Header, *http.Request) rsvp.Response) response.HandlerFunc {
return func(session *response.Session, h http.Header, r *http.Request) rsvp.Response {
sessionKey, ok := session.GetValue("sessionKey").(string)
if !ok {
log.Printf("Failed to get key from session\n")
return response.Error(http.StatusInternalServerError, "Something went wrong.")
}
appSession, err := lishwist.SessionFromKey(sessionKey)
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 next(appSession, session, h, r)
}
}

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

@ -1,67 +0,0 @@
package routing
import (
lishwist "lishwist/core"
"lishwist/http/response"
"lishwist/http/templates"
"log"
"net/http"
"github.com/Teajey/rsvp"
)
type foreignWishlistProps struct {
Navbar templates.NavCollapse
CurrentUserId string
CurrentUserName string
Username string
Gifts []lishwist.Wish
}
func ForeignWishlist(app *lishwist.Session, session *response.Session, h http.Header, r *http.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!")
}
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 :(")
}
if otherUser == nil {
return response.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 :(")
}
p := foreignWishlistProps{Navbar: templates.UserNavCollapse(user.Name, user.PasswordFromAdmin), CurrentUserId: user.Id, Username: otherUser.Name, Gifts: wishes}
return response.Data("foreign_wishlist.gotmpl", p)
}
type publicWishlistProps struct {
Navbar templates.NavCollapse
Username string
GiftCount int
}
func PublicWishlist(s *response.Session, h http.Header, r *http.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 :(")
}
if otherUser == nil {
return response.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 :(")
}
p := publicWishlistProps{Navbar: templates.DefaultNavCollapse(), Username: otherUser.Name, GiftCount: giftCount}
return response.Data("public_foreign_wishlist.gotmpl", p)
}

View File

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

View File

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

View File

@ -1,80 +0,0 @@
package routing
import (
"errors"
"log"
"net/http"
"time"
lishwist "lishwist/core"
"lishwist/http/api"
"lishwist/http/response"
"github.com/Teajey/rsvp"
)
func Login(s *response.Session, h http.Header, r *http.Request) rsvp.Response {
props := api.NewLoginProps("", "")
flash := s.FlashGet()
flashProps, ok := flash.(*api.LoginProps)
if ok {
props.Username.Value = flashProps.Username.Value
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}
}
func LoginPost(session *response.Session, h http.Header, r *http.Request) rsvp.Response {
username, password, ok := r.BasicAuth()
if !ok {
err := r.ParseForm()
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to parse form")
}
username = r.Form.Get("username")
password = r.Form.Get("password")
}
props := api.NewLoginProps(username, password)
resp := rsvp.SeeOther(r.URL.Path, props)
valid := props.Validate()
props.Password.Value = ""
if !valid {
session.FlashSet(&props)
log.Printf("Invalid props: %#v\n", props)
return resp
}
appSession, err := lishwist.Login(username, password, time.Hour*24)
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"
session.FlashSet(&props)
log.Printf("Invalid credentials: %s: %#v\n", err, props)
return resp
default:
props.GeneralError = "Something went wrong."
session.FlashSet(&props)
log.Printf("Login error: %s\n", err)
return resp
}
}
session.SetID("")
session.SetValue("sessionKey", appSession.Key)
return rsvp.SeeOther(r.URL.Path, "Login successful!")
}

View File

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

View File

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

View File

@ -1,72 +0,0 @@
package routing
import (
"errors"
lishwist "lishwist/core"
"lishwist/http/api"
"lishwist/http/response"
"log"
"net/http"
"github.com/Teajey/rsvp"
)
func Register(session *response.Session, h http.Header, r *http.Request) rsvp.Response {
props := api.NewRegisterProps("", "", "")
flash := session.FlashGet()
flashProps, _ := flash.(*api.RegisterProps)
if flashProps != nil {
props.Username.Value = flashProps.Username.Value
props.GeneralError = flashProps.GeneralError
props.Username.Error = flashProps.Username.Error
props.ConfirmPassword.Error = flashProps.ConfirmPassword.Error
}
return response.Data("register.gotmpl", props)
}
func RegisterPost(s *response.Session, h http.Header, r *http.Request) rsvp.Response {
var confirmPassword string
username, newPassword, ok := r.BasicAuth()
if ok {
confirmPassword = newPassword
} else {
err := r.ParseForm()
if err != nil {
return response.Error(http.StatusBadRequest, "Failed to parse form")
}
username = r.Form.Get("username")
newPassword = r.Form.Get("newPassword")
confirmPassword = r.Form.Get("confirmPassword")
}
props := api.NewRegisterProps(username, newPassword, confirmPassword)
valid := props.Validate()
props.Password.Value = ""
props.ConfirmPassword.Value = ""
if !valid {
s.FlashSet(&props)
log.Printf("Invalid register props: %#v\n", props)
return rsvp.SeeOther(r.URL.Path, props)
}
_, err := lishwist.Register(username, newPassword)
if err != nil {
if errors.Is(err, lishwist.ErrorUsernameTaken) {
props.Username.Error = "Username is taken"
} else {
props.GeneralError = "Something went wrong."
}
s.FlashSet(&props)
log.Printf("Registration failed: %s\n", err)
return rsvp.SeeOther(r.URL.Path, props)
}
s.FlashSet(&api.LoginProps{SuccessfulRegistration: true})
return rsvp.SeeOther("/", "Registration successful!")
}

View File

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

View File

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

View File

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

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{},
}
}

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

@ -1,104 +0,0 @@
{{define "input"}}
<input style="padding: .375rem .75rem;" class="form-control{{if .Error}} is-invalid{{end}}" {{if .Type}}type="{{.Type}}" {{end}}name="{{.Name}}"
value="{{.Value}}" {{if .Required}}required {{end}}{{if .MinLength}}minlength="{{.MinLength}}" {{end}} aria-describedby="{{if .Error}}{{.Name}}Error{{end}}">
{{with .Error}}
<div id="{{$.Name}}Error" class="invalid-feedback">
{{.}}
</div>
{{else}}
<div style="margin-top: .25rem; font-size: .875em;">
</div>
{{end}}
{{end}}
{{define "head"}}
<title>Lishwist</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<script>
function getSubmissionNames(form) {
return Array.from(new FormData(form).keys());
}
function acceptNames(form, submitId, ...acceptedNames) {
const submissionNames = getSubmissionNames(form);
const submitter = document.getElementById(submitId);
const accepted = submissionNames.length > 0 && submissionNames.every((name) => acceptedNames.includes(name));
submitter.disabled = !accepted;
}
function acceptAttribute(form, submitId, acceptedAttribute) {
const checkedInputs = Array.from(form.querySelectorAll("input")).filter((i) => i.checked);
const submitter = document.getElementById(submitId);
const accepted = checkedInputs.length > 0 && checkedInputs.every((i) => i.hasAttribute(acceptedAttribute));
submitter.disabled = !accepted;
}
</script>
{{end}}
{{define "navbar"}}
<div class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<div class="navbar-brand">Lishwist</div>
{{with .}}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarToggle"
aria-controls="navbarToggle" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>{{if and .AccountLink .AccountLink.Alert}} <span
class="badge text-bg-danger">!</span>{{end}}
</button>
<div class="collapse navbar-collapse" id="navbarToggle">
{{with .Links}}
<nav>
<ul class="navbar-nav">
{{range .}}
<li class="nav-item">
<a class="nav-link" href="{{.Href}}">{{.Name}}</a>
</li>
{{end}}
</ul>
</nav>
{{end}}
<div class="flex-grow-1"></div>
{{with .User}}
<ul class="navbar-nav">
{{with .CopyList}}
<li class="nav-item"><button class="btn btn-success"
onclick="navigator.clipboard.writeText('{{.Domain}}/lists/{{.Reference}}'); alert('The share link to your wishlist has been copied to your clipboard. Anyone with the link will be able to claim gifts for you. Share it with someone!');">Copy
share link</button></li>
{{end}}
<li class="nav-item">
<div class="dropdown">
<button class="nav-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Logged in as '{{.Name}}'{{if and $.AccountLink $.AccountLink.Alert}} <span class="badge text-bg-danger">!</span>{{end}}
</button>
<ul class="dropdown-menu">
{{with $.AccountLink}}
<li>
<a class="dropdown-item" href="/account">Account{{if .Alert}} <span
class="badge text-bg-danger">!</span>{{end}}</a>
</li>
{{end}}
<li>
<form class="d-contents" method="post" action="/logout">
<button class="dropdown-item" type="submit">Logout</button>
</form>
</li>
</ul>
</div>
</li>
</ul>
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
{{define "login_prompt"}}
<a href="/">Login</a> or <a href="/register">register</a>
{{end}}

View File

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

View File

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

View File

@ -1,32 +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">
<section class="card">
<div class="card-body">
<h2><em>{{.Group.Name}}</em> group members</h2>
{{with .Group.Members}}
<ul class="list-group">
{{range .}}
<li class="list-group-item">
<a href="/lists/{{.Reference}}">{{.Name}}</a>
</li>
{{end}}
</ul>
{{else}}
<p>There's nobody else in this group.</p>
{{end}}
</div>
</section>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,103 +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">
<section class="card mb-4">
<div class="card-body">
<h2>Your wishlist</h2>
{{with .Gifts}}
<form method="post" onchange="acceptNames(this, 'deleteSubmit', 'gift')" autocomplete="off">
<ul class="list-group mb-3">
{{range .}}
<li class="list-group-item">
<input id="wishlist_select_{{.Id}}" class="form-check-input" type="checkbox" name="gift"
value="{{.Id}}">
<label class="form-check-label stretched-link" for="wishlist_select_{{.Id}}">
{{.Name}}
</label>
</li>
{{end}}
</ul>
<button id="deleteSubmit" class="btn btn-danger mb-3" type="submit" name="intent" value="delete_idea"
disabled>Delete</button>
</form>
{{else}}
<p>Your list is empty. Think of some things to add!</p>
{{end}}
<form method="post">
<div class="input-group">
<input class="form-control" name="gift_name" required placeholder="Write a gift idea here" autofocus>
<button class="btn btn-primary" type="submit" name="intent" value="add_idea">Add gift idea</button>
</div>
</form>
</div>
</section>
<section class="card mb-4">
<div class="card-body">
<h2>Your todo list</h2>
{{with .Todo}}
<form method="post"
onchange="acceptNames(this, 'unclaimSubmit', 'gift'); acceptNames(this, 'completeSubmit', 'gift')"
autocomplete="off">
<ul class="list-group mb-3">
{{range .}}
<li class="list-group-item{{if .Sent}} list-group-item-light{{end}}">
<input id="todo_select_{{.Id}}" class="form-check-input" type="checkbox" {{if .Sent}}
aria-describedby="todo_detail_{{.Id}}" disabled{{else}} name="gift" value="{{.Id}}" {{end}}>
<label for="todo_select_{{.Id}}" class="form-check-label">
<em>
{{if .Sent}}
<s>{{.Name}}</s>
{{else}}
{{.Name}}
{{end}}
</em>
</label>
<span id="todo_detail_{{.Id}}">
for <a href="/lists/{{.RecipientRef}}">{{.RecipientName}}</a>
</span>
</li>
{{end}}
</ul>
<button id="unclaimSubmit" class="btn btn-warning" type="submit" name="intent" value="unclaim_todo"
disabled>Unclaim</button>
<button id="completeSubmit" class="btn btn-success" type="submit" name="intent" value="complete_todo"
disabled>Complete</button>
</form>
{{else}}
<p class="mb-0">When you claim gifts for others, they will appear here.</p>
{{end}}
</div>
</section>
<section class="card">
<div class="card-body">
<h2>Your groups</h2>
{{with .Groups}}
<ul class="list-group">
{{range .}}
<li class="list-group-item">
<a href="/groups/{{.Reference}}">{{.Name}}</a>
</li>
{{end}}
</ul>
{{else}}
<p>You don't belong to any groups</p>
{{end}}
</div>
</section>
</div>
</div>
</div>
</body>
</html>

View File

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

View File

@ -1,33 +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">
<section class="card">
<div class="card-body">
<h2>{{.Username}}'s list</h2>
{{if eq .GiftCount 0}}
<p>{{.Username}} hasn't written any gift ideas!</p>
<p>{{template "login_prompt"}} to add some! :^)</p>
{{else}}
{{if eq .GiftCount 1}}
<p>{{.Username}} has only written one gift idea.</p>
<p>{{template "login_prompt"}} to claim it, or add more! :^)</p>
{{else}}
<p>{{.Username}} has written {{.GiftCount}} gift ideas.</p>
<p>{{template "login_prompt"}} to claim an idea, or add more! :^)</p>
{{end}}
{{end}}
</div>
</section>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,33 +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">
<section class="card">
<div class="card-body">
<h2><em>{{.Group.Name}}</em> group members</h2>
<p>{{template "login_prompt"}} to see your groups</p>
{{with .Group.Members}}
<ul class="list-group">
{{range .}}
<li class="list-group-item">
{{.Name}}
</li>
{{end}}
</ul>
{{else}}
<p>There's nobody else in this group.</p>
{{end}}
</div>
</section>
</div>
</div>
</div>
</body>
</html>

View File

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

View File

@ -1,78 +0,0 @@
package templates
import (
"fmt"
"html/template"
)
type AccountLink struct {
Alert bool
}
type Link struct {
Href string
Name string
}
type CopyList struct {
Domain string
Reference string
}
type User struct {
Name string
CopyList *CopyList
}
type NavCollapse struct {
User *User
AccountLink *AccountLink
Links []Link
}
func DefaultNavCollapse() NavCollapse {
return NavCollapse{Links: []Link{{Href: "/", Name: "Home"}}}
}
func UserNavCollapse(username string, accountAlert bool) NavCollapse {
return NavCollapse{
Links: []Link{{Href: "/", Name: "Home"}},
User: &User{Name: username},
AccountLink: &AccountLink{Alert: accountAlert},
}
}
type InputProps struct {
Type string `json:",omitempty"`
Name string
Required bool `json:",omitempty"`
Value string
Error string `json:",omitempty"`
MinLength uint `json:",omitempty"`
}
func (p *InputProps) Validate() bool {
if p.Required && p.Value == "" {
p.Error = fmt.Sprintf("%v is required", p.Name)
return false
}
value_len := len(p.Value)
if p.MinLength > 0 && int(p.MinLength) > value_len {
p.Error = fmt.Sprintf("%v requires at least %v characters (currently %v characters)", p.Name, p.MinLength, value_len)
return false
}
return true
}
var Template *template.Template
func init() {
Template = load()
}
func load() *template.Template {
t := template.Must(template.ParseGlob("templates/*.gotmpl"))
return t
}

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)

85
server/api/login.go Normal file
View File

@ -0,0 +1,85 @@
package api
import (
"lishwist/db"
"lishwist/templates"
"log"
"golang.org/x/crypto/bcrypt"
)
type LoginProps struct {
GeneralError string `json:",omitempty"`
SuccessfulRegistration bool `json:",omitempty"`
Username templates.InputProps
Password templates.InputProps
}
func NewLoginProps(username, password string) *LoginProps {
return &LoginProps{
Username: templates.InputProps{
Name: "username",
Required: true,
Value: username,
},
Password: templates.InputProps{
Name: "password",
Type: "password",
Required: true,
Value: password,
},
}
}
func (p *LoginProps) Validate() (valid bool) {
valid = true
if !p.Username.Validate() {
valid = false
}
if !p.Password.Validate() {
valid = false
}
return
}
func Login(username, password string) *LoginProps {
props := NewLoginProps(username, password)
valid := props.Validate()
props.Password.Value = ""
if !valid {
log.Printf("Invalid props: %#v\n", props)
return props
}
user, err := db.GetUserByName(username)
if err != nil {
log.Printf("Failed to fetch user: %s\n", err)
props.GeneralError = "Username or password invalid"
return props
}
if user == nil {
log.Printf("User not found by name: %q\n", username)
props.GeneralError = "Username or password invalid"
return props
}
passHash, err := user.GetPassHash()
if err != nil {
log.Println("Failed to get password hash: " + err.Error())
props.GeneralError = "Something went wrong. Error code: Momo"
return props
}
err = bcrypt.CompareHashAndPassword(passHash, []byte(password))
if err != nil {
log.Println("Username or password invalid: " + err.Error())
props.GeneralError = "Username or password invalid"
return props
}
return nil
}

100
server/api/register.go Normal file
View File

@ -0,0 +1,100 @@
package api
import (
"log"
"lishwist/db"
"lishwist/templates"
"golang.org/x/crypto/bcrypt"
)
type RegisterProps struct {
GeneralError string `json:",omitempty"`
Username templates.InputProps
Password templates.InputProps
ConfirmPassword templates.InputProps
}
func (p *RegisterProps) Validate() (valid bool) {
valid = true
if p.Password.Value != p.ConfirmPassword.Value {
p.ConfirmPassword.Error = "Passwords didn't match"
valid = false
}
if !p.Username.Validate() {
valid = false
}
if !p.Password.Validate() {
valid = false
}
if !p.ConfirmPassword.Validate() {
valid = false
}
return
}
func NewRegisterProps(usernameVal, passwordVal, confirmPassVal string) *RegisterProps {
return &RegisterProps{
GeneralError: "",
Username: templates.InputProps{
Name: "username",
Required: true,
MinLength: 4,
Value: usernameVal,
},
Password: templates.InputProps{
Type: "password",
Name: "newPassword",
Required: true,
MinLength: 5,
Value: passwordVal,
},
ConfirmPassword: templates.InputProps{
Type: "password",
Name: "confirmPassword",
Required: true,
Value: confirmPassVal,
},
}
}
func Register(username, newPassword, confirmPassword string) *RegisterProps {
props := NewRegisterProps(username, newPassword, confirmPassword)
valid := props.Validate()
props.Password.Value = ""
props.ConfirmPassword.Value = ""
if !valid {
log.Printf("Invalid props: %#v\n", props)
return props
}
existingUser, _ := db.GetUserByName(username)
if existingUser != nil {
log.Printf("Username is taken: %q\n", username)
props.Username.Error = "Username is taken"
return props
}
hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost)
if err != nil {
log.Printf("Failed to hash password: %s\n", err)
props.GeneralError = "Something went wrong. Error code: Aang"
return props
}
_, err = db.CreateUser(username, hashedPasswordBytes)
if err != nil {
log.Printf("Failed to create user: %s\n", err)
props.GeneralError = "Something went wrong. Error code: Ozai"
return props
}
return nil
}

62
server/db/db.go Normal file
View File

@ -0,0 +1,62 @@
//go:generate go run gen_init_sql.go
package db
import (
"database/sql"
"fmt"
"lishwist/env"
"github.com/Teajey/sqlstore"
_ "github.com/glebarez/go-sqlite"
)
var database *sql.DB
func Open() error {
db, err := sql.Open("sqlite", "./lishwist.db")
if err != nil {
return err
}
database = db
return nil
}
func Init() error {
_, err := database.Exec(InitQuery)
if err != nil {
return err
}
return nil
}
func NewSessionStore() (*sqlstore.Store, error) {
deleteStmt, err := database.Prepare("DELETE FROM session WHERE id = ?;")
if err != nil {
return nil, fmt.Errorf("Failed to prepare delete statement: %w", err)
}
insertStmt, err := database.Prepare("INSERT INTO session (value) VALUES (?);")
if err != nil {
return nil, fmt.Errorf("Failed to prepare insert statement: %w", err)
}
selectStmt, err := database.Prepare("SELECT value FROM session WHERE id = ?;")
if err != nil {
return nil, fmt.Errorf("Failed to prepare select statement: %w", err)
}
updateStmt, err := database.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(database, sqlstore.Statements{
Delete: deleteStmt,
Insert: insertStmt,
Select: selectStmt,
Update: updateStmt,
}, []byte(env.SessionSecret))
return s, nil
}

View File

@ -11,7 +11,7 @@ import (
var initTemplate = template.Must(template.New("").Parse("// Code generated DO NOT EDIT.\n" + var initTemplate = template.Must(template.New("").Parse("// Code generated DO NOT EDIT.\n" +
"package db\n" + "package db\n" +
"\n" + "\n" +
"const initQuery = `{{.}}`\n", "const InitQuery = `{{.}}`\n",
)) ))
func main() { func main() {

121
server/db/group.go Normal file
View File

@ -0,0 +1,121 @@
package db
import (
"database/sql"
"fmt"
"strconv"
)
type Group struct {
Id string
Name string
Reference string
Members []User
}
func (g *Group) MemberIndex(userId string) int {
for i, u := range g.Members {
if u.Id == userId {
return i
}
}
return -1
}
func queryForGroup(query string, args ...any) (*Group, error) {
var group Group
err := database.QueryRow(query, args...).Scan(&group.Id, &group.Name, &group.Reference)
if err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
members, err := queryForGroupMembers(group.Id)
if err != nil {
return nil, err
}
group.Members = members
return &group, nil
}
func queryForGroups(query string, args ...any) ([]Group, error) {
groups := []Group{}
rows, err := database.Query(query, args...)
if err != nil {
return groups, fmt.Errorf("Query failed: %w", err)
}
defer rows.Close()
for rows.Next() {
var group Group
err := rows.Scan(&group.Id, &group.Name, &group.Reference)
if err != nil {
return groups, fmt.Errorf("Failed to scan row: %w", err)
}
members, err := queryForGroupMembers(group.Id)
if err != nil {
return groups, fmt.Errorf("Failed to query for group members: %w", err)
}
group.Members = members
groups = append(groups, group)
}
err = rows.Err()
if err != nil {
return groups, fmt.Errorf("Rows error: %w", err)
}
return groups, nil
}
func queryForGroupMembers(groupId string) ([]User, error) {
query := "SELECT user.id, user.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 := queryForUsers(query, groupId)
if err != nil {
return members, fmt.Errorf("Failed to get members: %w", err)
}
return members, nil
}
func GetGroupByReference(reference string) (*Group, error) {
query := "SELECT [group].id, [group].name, [group].reference FROM [group] WHERE [group].reference = ?"
return queryForGroup(query, reference)
}
func GetAllGroups() ([]Group, error) {
query := "SELECT id, name, reference FROM [group];"
return queryForGroups(query)
}
func CreateGroup(name string, reference string) (*Group, error) {
stmt := "INSERT INTO [group] (name, reference) VALUES (?, ?)"
result, err := database.Exec(stmt, name, reference)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
group := Group{
Id: strconv.FormatInt(id, 10),
Name: name,
Reference: reference,
}
return &group, nil
}
func (g *Group) AddUser(userId string) error {
stmt := "INSERT INTO group_member (group_id, user_id) VALUES (?, ?)"
_, err := database.Exec(stmt, g.Id, userId)
if err != nil {
return err
}
return nil
}
func (g *Group) RemoveUser(userId string) error {
stmt := "DELETE FROM group_member WHERE group_id = ? AND user_id = ?"
_, err := database.Exec(stmt, g.Id, userId)
if err != nil {
return err
}
return nil
}

View File

@ -2,16 +2,14 @@ BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "user" ( CREATE TABLE IF NOT EXISTS "user" (
"id" INTEGER NOT NULL UNIQUE, "id" INTEGER NOT NULL UNIQUE,
"name" TEXT NOT NULL UNIQUE, "name" TEXT NOT NULL UNIQUE,
"display_name" TEXT NOT NULL UNIQUE,
"reference" TEXT NOT NULL UNIQUE, "reference" TEXT NOT NULL UNIQUE,
"motto" TEXT NOT NULL DEFAULT "", "motto" TEXT NOT NULL DEFAULT "",
"password_hash" TEXT NOT NULL, "password_hash" TEXT NOT NULL,
"is_admin" INTEGER NOT NULL DEFAULT 0, "is_admin" INTEGER NOT NULL DEFAULT 0,
"is_live" INTEGER NOT NULL DEFAULT 1, "is_live" INTEGER NOT NULL DEFAULT 1,
"password_from_admin" INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY("id" AUTOINCREMENT) PRIMARY KEY("id" AUTOINCREMENT)
); );
CREATE TABLE IF NOT EXISTS "wish" ( CREATE TABLE IF NOT EXISTS "gift" (
"id" INTEGER NOT NULL UNIQUE, "id" INTEGER NOT NULL UNIQUE,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"recipient_id" INTEGER NOT NULL, "recipient_id" INTEGER NOT NULL,
@ -30,29 +28,15 @@ CREATE TABLE IF NOT EXISTS "group" (
PRIMARY KEY("id" AUTOINCREMENT) PRIMARY KEY("id" AUTOINCREMENT)
); );
CREATE TABLE IF NOT EXISTS "group_member" ( CREATE TABLE IF NOT EXISTS "group_member" (
"id" INTEGER NOT NULL UNIQUE,
"group_id" INTEGER NOT NULL, "group_id" INTEGER NOT NULL,
"user_id" INTEGER NOT NULL, "user_id" INTEGER NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT),
UNIQUE("user_id","group_id"), UNIQUE("user_id","group_id"),
FOREIGN KEY("group_id") REFERENCES "group"("id"), FOREIGN KEY("group_id") REFERENCES "group"("id"),
FOREIGN KEY("user_id") REFERENCES "user"("id") FOREIGN KEY("user_id") REFERENCES "user"("id")
); );
CREATE TABLE IF NOT EXISTS "session" ( CREATE TABLE IF NOT EXISTS "session" (
"id" INTEGER NOT NULL UNIQUE, "id" INTEGER NOT NULL UNIQUE,
"key" TEXT NOT NULL UNIQUE, "value" TEXT NOT NULL,
"user_id" INTEGER NOT NULL,
"expiry" TEXT NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT),
FOREIGN KEY("user_id") REFERENCES "user"("id")
);
CREATE TABLE IF NOT EXISTS "event" (
"id" INTEGER NOT NULL UNIQUE,
"actor_id" INTEGER NOT NULL,
"action_type" TEXT NOT NULL,
"target_type" TEXT NOT NULL,
"target_id" INTEGER NOT NULL,
"created_at" TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ')),
PRIMARY KEY("id" AUTOINCREMENT) PRIMARY KEY("id" AUTOINCREMENT)
); );
@ -64,6 +48,6 @@ SELECT * FROM user WHERE user.is_live = 1;
-- DROP VIEW IF EXISTS "v_wish"; -- DROP VIEW IF EXISTS "v_wish";
-- CREATE VIEW "v_wish" -- CREATE VIEW "v_wish"
-- AS -- AS
-- SELECT wish.id, wish.name, wish.sent FROM wish JOIN user AS recipient; -- SELECT gift.id, gift.name, gift.sent FROM gift JOIN user AS recipient;
COMMIT; COMMIT;

415
server/db/user.go Normal file
View File

@ -0,0 +1,415 @@
package db
import (
"database/sql"
"fmt"
"github.com/google/uuid"
)
type User struct {
Id string
Name string
Reference string
IsAdmin bool
IsLive bool
}
type Gift 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"`
}
func queryForUser(query string, args ...any) (*User, error) {
var u User
err := database.QueryRow(query, args...).Scan(&u.Id, &u.Name, &u.Reference, &u.IsAdmin, &u.IsLive)
if err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
return &u, nil
}
func queryForUsers(query string, args ...any) ([]User, error) {
rows, err := database.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
users := []User{}
for rows.Next() {
var u User
err = rows.Scan(&u.Id, &u.Name, &u.Reference, &u.IsAdmin, &u.IsLive)
if err != nil {
return nil, err
}
users = append(users, u)
}
err = rows.Err()
if err != nil {
return nil, err
}
return users, nil
}
func GetAllUsers() ([]User, error) {
stmt := "SELECT id, name, reference, is_admin, is_live FROM user"
return queryForUsers(stmt)
}
func GetUser(id string) (*User, error) {
stmt := "SELECT id, name, reference, is_admin, is_live FROM v_user WHERE id = ?"
return queryForUser(stmt, id)
}
func GetUserByName(username string) (*User, error) {
stmt := "SELECT id, name, reference, is_admin, is_live FROM v_user WHERE name = ?"
return queryForUser(stmt, username)
}
func GetUserByReference(reference string) (*User, error) {
stmt := "SELECT id, name, reference, is_admin, is_live FROM v_user WHERE reference = ?"
return queryForUser(stmt, reference)
}
func GetAnyUserByReference(reference string) (*User, error) {
stmt := "SELECT id, name, reference, is_admin, is_live FROM user WHERE reference = ?"
return queryForUser(stmt, reference)
}
func (u *User) SetLive(setting bool) error {
query := "UPDATE user SET is_live = ? WHERE reference = ?"
_, err := database.Exec(query, setting, u.Reference)
if err != nil {
return err
}
u.IsLive = setting
return err
}
func CreateUser(username string, passHash []byte) (*User, error) {
stmt := "INSERT INTO user (name, reference, password_hash) VALUES (?, ?, ?)"
reference, err := uuid.NewRandom()
if err != nil {
return nil, err
}
result, err := database.Exec(stmt, username, reference, passHash)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
user := User{
Id: fmt.Sprintf("%d", id),
Name: username,
}
return &user, nil
}
func (u *User) GetPassHash() ([]byte, error) {
stmt := "SELECT password_hash FROM v_user WHERE id = ?"
var passHash string
err := database.QueryRow(stmt, u.Id).Scan(&passHash)
if err != nil {
return nil, err
}
return []byte(passHash), nil
}
func (u *User) CountGifts() (int, error) {
stmt := "SELECT COUNT(gift.id) AS gift_count FROM gift WHERE gift.creator_id = ?1 AND gift.recipient_id = ?1"
var giftCount int
err := database.QueryRow(stmt, u.Id).Scan(&giftCount)
if err != nil {
return 0, err
}
return giftCount, nil
}
func (u *User) GetGifts() ([]Gift, error) {
stmt := "SELECT gift.id, gift.name, gift.sent FROM gift WHERE gift.creator_id = ?1 AND gift.recipient_id = ?1"
rows, err := database.Query(stmt, u.Id)
if err != nil {
return nil, err
}
defer rows.Close()
gifts := []Gift{}
for rows.Next() {
var id string
var name string
var sent bool
err = rows.Scan(&id, &name, &sent)
if err != nil {
return nil, err
}
gift := Gift{
Id: id,
Name: name,
Sent: sent,
}
gifts = append(gifts, gift)
}
err = rows.Err()
if err != nil {
return nil, err
}
return gifts, nil
}
func (u *User) GetOtherUserGifts(otherUserReference string) ([]Gift, error) {
otherUser, err := GetUserByReference(otherUserReference)
if err != nil {
return nil, fmt.Errorf("Failed to get other user: %w", err)
}
if otherUser.Id == u.Id {
return nil, fmt.Errorf("Not allowed to view own foreign wishlist")
}
stmt := "SELECT gift.id, gift.name, claimant.id, claimant.name, gift.sent, gift.creator_id, creator.name, gift.recipient_id FROM gift JOIN v_user AS user ON gift.recipient_id = user.id LEFT JOIN v_user AS claimant ON gift.claimant_id = claimant.id LEFT JOIN v_user AS creator ON gift.creator_id = creator.id WHERE user.id = ?"
rows, err := database.Query(stmt, otherUser.Id)
if err != nil {
return nil, fmt.Errorf("Failed to execute query: %w", err)
}
defer rows.Close()
gifts := []Gift{}
for rows.Next() {
var id string
var name string
var claimantId sql.NullString
var claimantName sql.NullString
var sent bool
var creatorId string
var creatorName string
var recipientId string
err = rows.Scan(&id, &name, &claimantId, &claimantName, &sent, &creatorId, &creatorName, &recipientId)
if err != nil {
return nil, fmt.Errorf("Failed to scan row: %w", err)
}
gift := Gift{
Id: id,
Name: name,
ClaimantId: claimantId.String,
ClaimantName: claimantName.String,
Sent: sent,
CreatorId: creatorId,
CreatorName: creatorName,
RecipientId: recipientId,
}
gifts = append(gifts, gift)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("Rows returned an error: %w", err)
}
return gifts, nil
}
func (u *User) GetTodo() ([]Gift, error) {
stmt := "SELECT gift.id, gift.name, gift.sent, recipient.name, recipient.reference FROM gift JOIN v_user AS user ON gift.claimant_id = user.id JOIN v_user AS recipient ON gift.recipient_id = recipient.id WHERE user.id = ? ORDER BY gift.sent ASC, gift.name"
rows, err := database.Query(stmt, u.Id)
if err != nil {
return nil, err
}
defer rows.Close()
gifts := []Gift{}
for rows.Next() {
var id string
var name string
var sent bool
var recipientName string
var recipientRef string
_ = rows.Scan(&id, &name, &sent, &recipientName, &recipientRef)
gift := Gift{
Id: id,
Name: name,
Sent: sent,
RecipientName: recipientName,
RecipientRef: recipientRef,
}
gifts = append(gifts, gift)
}
err = rows.Err()
if err != nil {
return nil, err
}
return gifts, nil
}
func (u *User) AddGift(name string) error {
stmt := "INSERT INTO gift (name, recipient_id, creator_id) VALUES (?, ?, ?)"
_, err := database.Exec(stmt, name, u.Id, u.Id)
if err != nil {
return err
}
return nil
}
func (u *User) deleteGifts(tx *sql.Tx, ids []string) error {
stmt := "DELETE FROM gift WHERE gift.creator_id = ? AND gift.id = ?"
for _, id := range ids {
r, err := tx.Exec(stmt, u.Id, id)
if err != nil {
return err
}
rE, err := r.RowsAffected()
if err != nil {
return err
}
if rE < 1 {
return fmt.Errorf("Gift deletion failed for '%s'", id)
}
}
return nil
}
func (u *User) RemoveGifts(ids ...string) error {
if len(ids) < 1 {
return fmt.Errorf("Attempt to remove zero gifts")
}
tx, err := database.Begin()
if err != nil {
return err
}
err = u.deleteGifts(tx, ids)
if err != nil {
rollBackErr := tx.Rollback()
if rollBackErr != nil {
return err
}
return err
}
err = tx.Commit()
return err
}
func (u *User) executeClaims(tx *sql.Tx, claims, unclaims []string) error {
claimStmt := "UPDATE gift SET claimant_id = ? WHERE id = ?"
unclaimStmt := "UPDATE gift SET claimant_id = NULL WHERE id = ?"
for _, id := range claims {
r, err := tx.Exec(claimStmt, u.Id, id)
if err != nil {
return err
}
rE, err := r.RowsAffected()
if err != nil {
return err
}
if rE < 1 {
return fmt.Errorf("Gift claim failed for '%s'", id)
}
}
for _, id := range unclaims {
r, err := tx.Exec(unclaimStmt, id)
if err != nil {
return err
}
rE, err := r.RowsAffected()
if err != nil {
return err
}
if rE < 1 {
return fmt.Errorf("Gift unclaim failed for '%s'", id)
}
}
return nil
}
func (u *User) ClaimGifts(claims, unclaims []string) error {
if len(claims) < 1 && len(unclaims) < 1 {
return fmt.Errorf("Attempt to claim/unclaim zero gifts")
}
tx, err := database.Begin()
if err != nil {
return err
}
err = u.executeClaims(tx, claims, unclaims)
if err != nil {
rollBackErr := tx.Rollback()
if rollBackErr != nil {
return err
}
return err
}
err = tx.Commit()
return err
}
func (u *User) executeCompletions(tx *sql.Tx, claims []string) error {
claimStmt := "UPDATE gift SET sent = 1 WHERE id = ?"
for _, id := range claims {
r, err := tx.Exec(claimStmt, id)
if err != nil {
return err
}
rE, err := r.RowsAffected()
if err != nil {
return err
}
if rE < 1 {
return fmt.Errorf("Gift completion failed for '%s'", id)
}
}
return nil
}
func (u *User) CompleteGifts(claims []string) error {
if len(claims) < 1 {
return fmt.Errorf("Attempt to complete zero gifts")
}
tx, err := database.Begin()
if err != nil {
return err
}
err = u.executeCompletions(tx, claims)
if err != nil {
rollBackErr := tx.Rollback()
if rollBackErr != nil {
return err
}
return err
}
err = tx.Commit()
return err
}
func (u *User) AddGiftToUser(otherUserReference string, giftName string) error {
otherUser, err := GetUserByReference(otherUserReference)
if err != nil {
return err
}
stmt := "INSERT INTO gift (name, recipient_id, creator_id) VALUES (?, ?, ?)"
_, err = database.Exec(stmt, giftName, otherUser.Id, u.Id)
if err != nil {
return err
}
return nil
}
func (u *User) 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 queryForGroups(stmt, u.Id)
}
func (u *User) GetGroupByReference(reference string) (*Group, error) {
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 queryForGroup(stmt, reference, u.Id)
}

32
server/env/env.go vendored Normal file
View File

@ -0,0 +1,32 @@
package env
import (
"log"
"net/url"
"os"
)
func GuaranteeEnv(key string) (variable string) {
variable, ok := os.LookupEnv(key)
if !ok || variable == "" {
log.Fatalln("Missing environment variable:", key)
}
return
}
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
}()

25
server/go.mod Normal file
View File

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

39
server/go.sum Normal file
View File

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

Some files were not shown because too many files have changed in this diff Show More