Compare commits

...

10 Commits

Author SHA1 Message Date
Teajey 92ad5f5e90
Sort wishes by sent 2025-09-13 00:01:36 +09:00
Teajey 935d6c7a28
feat: remove client package 2025-09-12 23:54:41 +09:00
Teajey cd41c55c02
Use RSVP 0.13.1 2025-09-08 19:51:36 +09:00
Teajey cffeede0dc
Separate handler declaration 2025-09-06 13:02:34 +09:00
Teajey d909adb6fa
Moved permanently redirects 2025-08-26 20:44:56 +09:00
Teajey a1ac719229
go work sync 2025-08-26 20:19:18 +09:00
Teajey 98853e4efd
Add lishwist/client to workspace 2025-08-26 20:18:19 +09:00
Teajey eae0a7e0e3
Move all scripts to root 2025-08-26 20:11:26 +09:00
Teajey abb9c54036
feat: internally managed session 2025-08-26 20:02:15 +09:00
Teajey 57e18ae0ce
Make session user inaccessible 2025-08-25 21:40:47 +09:00
36 changed files with 428 additions and 194 deletions

View File

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

View File

@ -45,12 +45,14 @@ func GetGroupByReference(reference string) (*Group, error)
func (g *Group) MemberIndex(userId string) int
type Session struct {
User
Key string
Expiry time.Time
// Has unexported fields.
}
func Login(username, password string) (*Session, error)
func Login(username, password string, sessionMaxAge time.Duration) (*Session, error)
func SessionFromUsername(username string) (*Session, error)
func SessionFromKey(key string) (*Session, error)
func (s *Session) Admin() *Admin
@ -74,6 +76,8 @@ 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

View File

@ -72,7 +72,7 @@ func queryManyGroupMembers(groupId string) ([]User, error) {
func (s *Session) 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 queryOneGroup(stmt, reference, s.User.Id)
return queryOneGroup(stmt, reference, s.User().Id)
}
func GetGroupByReference(reference string) (*Group, error) {
@ -126,5 +126,5 @@ func (a *Admin) RemoveUserFromGroup(userId, groupId string) error {
// Get the groups the session user belongs to
func (u *Session) GetGroups() ([]Group, error) {
stmt := "SELECT [group].id, [group].name, [group].reference FROM [group] JOIN group_member ON group_member.group_id = [group].id JOIN v_user AS user ON user.id = group_member.user_id WHERE user.id = ?"
return queryManyGroups(stmt, u.Id)
return queryManyGroups(stmt, u.User().Id)
}

View File

@ -26,7 +26,7 @@ func TestCantSeeSelfInGroup(t *testing.T) {
group, err := s.Admin().CreateGroup(" My Friends ", " my-friends ")
fixtures.FailIfErr(t, err, "Failed to create group")
err = s.Admin().AddUserToGroup(s.User.Id, group.Id)
err = s.Admin().AddUserToGroup(s.User().Id, group.Id)
fixtures.FailIfErr(t, err, "Failed to add self to group")
err = s.Admin().AddUserToGroup(caleb.Id, group.Id)

View File

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

View File

@ -37,8 +37,11 @@ CREATE TABLE IF NOT EXISTS "group_member" (
);
CREATE TABLE IF NOT EXISTS "session" (
"id" INTEGER NOT NULL UNIQUE,
"value" TEXT NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT)
"key" TEXT NOT NULL UNIQUE,
"user_id" INTEGER NOT NULL,
"expiry" TEXT NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT),
FOREIGN KEY("user_id") REFERENCES "user"("id")
);
DROP VIEW IF EXISTS "v_user";

View File

@ -3,6 +3,7 @@ package fixtures
import (
"log"
"testing"
"time"
lishwist "lishwist/core"
@ -26,7 +27,7 @@ func Login(t *testing.T, username, password string) *lishwist.Session {
log.Fatalf("Failed to register on login fixture: %s\n", err)
}
session, err := lishwist.Login(username, password)
session, err := lishwist.Login(username, password, time.Hour*24)
if err != nil {
log.Fatalf("Failed to login on fixture: %s\n", err)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,8 +23,8 @@ type Wish struct {
}
func (s *Session) GetWishes() ([]Wish, error) {
stmt := "SELECT wish.id, wish.name, wish.sent FROM wish WHERE wish.creator_id = ?1 AND wish.recipient_id = ?1"
rows, err := db.Connection.Query(stmt, s.User.Id)
stmt := "SELECT wish.id, wish.name, wish.sent FROM wish WHERE wish.creator_id = ?1 AND wish.recipient_id = ?1 ORDER BY wish.sent"
rows, err := db.Connection.Query(stmt, s.User().Id)
if err != nil {
return nil, fmt.Errorf("Query execution failed: %w", err)
}
@ -54,17 +54,17 @@ func (s *Session) GetWishes() ([]Wish, error) {
func (s *Session) MakeWish(name string) error {
stmt := "INSERT INTO wish (name, recipient_id, creator_id) VALUES (?, ?, ?)"
_, err := db.Connection.Exec(stmt, strings.TrimSpace(name), s.User.Id, s.User.Id)
_, err := db.Connection.Exec(stmt, strings.TrimSpace(name), s.User().Id, s.User().Id)
if err != nil {
return fmt.Errorf("Query execution failed: %w", err)
}
return nil
}
func (u *Session) deleteWishes(tx *sql.Tx, ids []string) error {
func (s *Session) deleteWishes(tx *sql.Tx, ids []string) error {
stmt := "DELETE FROM wish WHERE wish.creator_id = ? AND wish.id = ?"
for _, id := range ids {
r, err := tx.Exec(stmt, u.Id, id)
r, err := tx.Exec(stmt, s.User().Id, id)
if err != nil {
return err
}
@ -107,10 +107,10 @@ func (s *Session) GetOthersWishes(userReference string) ([]Wish, error) {
if err != nil {
return nil, fmt.Errorf("Failed to get other user: %w", err)
}
if otherUser.Id == s.User.Id {
if otherUser.Id == s.User().Id {
return nil, errors.New("Use (s *Session) GetWishes() to view your own wishes")
}
stmt := "SELECT wish.id, wish.name, claimant.id, claimant.name, wish.sent, wish.creator_id, creator.name, wish.recipient_id FROM wish JOIN v_user AS user ON wish.recipient_id = user.id LEFT JOIN v_user AS claimant ON wish.claimant_id = claimant.id LEFT JOIN v_user AS creator ON wish.creator_id = creator.id WHERE user.id = ?"
stmt := "SELECT wish.id, wish.name, claimant.id, claimant.name, wish.sent, wish.creator_id, creator.name, wish.recipient_id FROM wish JOIN v_user AS user ON wish.recipient_id = user.id LEFT JOIN v_user AS claimant ON wish.claimant_id = claimant.id LEFT JOIN v_user AS creator ON wish.creator_id = creator.id WHERE user.id = ? ORDER BY wish.sent"
rows, err := db.Connection.Query(stmt, otherUser.Id)
if err != nil {
return nil, fmt.Errorf("Failed to execute query: %w", err)
@ -164,7 +164,7 @@ func (s *Session) executeClaims(tx *sql.Tx, claims, unclaims []string) error {
claimStmt := "UPDATE wish SET claimant_id = ? WHERE id = ?"
unclaimStmt := "UPDATE wish SET claimant_id = NULL WHERE id = ?"
for _, id := range claims {
r, err := tx.Exec(claimStmt, s.Id, id)
r, err := tx.Exec(claimStmt, s.User().Id, id)
if err != nil {
return err
}
@ -264,7 +264,7 @@ func (u *Session) SuggestWishForUser(otherUserReference string, wishName string)
return err
}
stmt := "INSERT INTO wish (name, recipient_id, creator_id) VALUES (?, ?, ?)"
_, err = db.Connection.Exec(stmt, wishName, otherUser.Id, u.Id)
_, err = db.Connection.Exec(stmt, wishName, otherUser.Id, u.User().Id)
if err != nil {
return err
}

View File

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

4
http/env/env.go vendored
View File

@ -6,12 +6,12 @@ import (
"os"
)
func GuaranteeEnv(key string) (variable string) {
func GuaranteeEnv(key string) string {
variable, ok := os.LookupEnv(key)
if !ok || variable == "" {
log.Fatalln("Missing environment variable:", key)
}
return
return variable
}
var DatabaseFile = GuaranteeEnv("LISHWIST_DATABASE_FILE")

View File

@ -5,10 +5,14 @@ go 1.23.3
toolchain go1.24.5
require (
github.com/Teajey/rsvp v0.13.0
github.com/Teajey/sqlstore v0.0.6
github.com/Teajey/rsvp v0.13.1
github.com/gorilla/sessions v1.4.0
golang.org/x/crypto v0.22.0
golang.org/x/crypto v0.39.0
)
require github.com/gorilla/securecookie v1.1.2 // indirect
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,12 +1,22 @@
github.com/Teajey/rsvp v0.13.0 h1:EeuHMHtU2/eLuSbCIlzKqy5FI9f6Qq+yUJnrVPBJvKk=
github.com/Teajey/rsvp v0.13.0/go.mod h1:WCWos0l+K/9heUuvbIUXkKAHAtxoLpkJ43C/fszD4RY=
github.com/Teajey/sqlstore v0.0.6 h1:kUEpA+3BKFHZl128MuMeYY6zVcmq1QmOlNyofcFEJOA=
github.com/Teajey/sqlstore v0.0.6/go.mod h1:hjk0S593/2Q4QxkEXCgpThj9w5KWGTQi9JtgfziHXXk=
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=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

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

View File

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

View File

@ -1,20 +1,21 @@
package response
import (
"lishwist/http/templates"
"log"
"net/http"
"lishwist/http/session"
"lishwist/http/templates"
"github.com/Teajey/rsvp"
"github.com/Teajey/sqlstore"
)
type ServeMux struct {
inner *rsvp.ServeMux
store *sqlstore.Store
store *session.Store
}
func NewServeMux(store *sqlstore.Store) *ServeMux {
func NewServeMux(store *session.Store) *ServeMux {
mux := rsvp.NewServeMux()
mux.Config.HtmlTemplate = templates.Template
return &ServeMux{

View File

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

View File

@ -13,15 +13,19 @@ import (
func ExpectAppSession(next func(*lishwist.Session, http.Header, *http.Request) rsvp.Response) response.HandlerFunc {
return func(session *response.Session, h http.Header, r *http.Request) rsvp.Response {
username, ok := session.GetValue("username").(string)
sessionKey, ok := session.GetValue("sessionKey").(string)
if !ok {
log.Printf("Failed to get username from session\n")
log.Printf("Failed to get key from session\n")
return response.Error(http.StatusInternalServerError, "Something went wrong.")
}
appSession, err := lishwist.SessionFromUsername(username)
appSession, err := lishwist.SessionFromKey(sessionKey)
if err != nil {
log.Printf("Failed to get session by username %q: %s\n", username, err)
log.Printf("Failed to get session by key %v: %s\n", sessionKey, err)
return response.Error(http.StatusInternalServerError, "Something went wrong.")
}
if appSession == nil {
log.Printf("Session not found under key: %s\n", sessionKey)
return response.Error(http.StatusInternalServerError, "Something went wrong.")
}

View File

@ -18,7 +18,8 @@ type foreignWishlistProps struct {
func ForeignWishlist(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response {
userReference := r.PathValue("userReference")
if app.User.Reference == 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)
@ -31,10 +32,10 @@ func ForeignWishlist(app *lishwist.Session, h http.Header, r *http.Request) rsvp
}
wishes, err := app.GetOthersWishes(userReference)
if err != nil {
log.Printf("%q couldn't get wishes of other user %q: %s\n", app.User.Name, otherUser.Name, err)
log.Printf("%q couldn't get wishes of other user %q: %s\n", user.Name, otherUser.Name, err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching this user :(")
}
p := foreignWishlistProps{CurrentUserId: app.User.Id, CurrentUserName: app.User.Name, Username: otherUser.Name, Gifts: wishes}
p := foreignWishlistProps{CurrentUserId: user.Id, CurrentUserName: user.Name, Username: otherUser.Name, Gifts: wishes}
return response.Data("foreign_wishlist.gotmpl", p)
}

View File

@ -25,19 +25,21 @@ func AdminGroup(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Resp
if group == nil {
return response.Error(http.StatusNotFound, "Group not found")
}
if !app.User.IsAdmin {
index := group.MemberIndex(app.User.Id)
user := app.User()
if !user.IsAdmin {
index := group.MemberIndex(user.Id)
group.Members = slices.Delete(group.Members, index, index+1)
}
p := GroupProps{
Group: group,
CurrentUsername: app.User.Name,
CurrentUsername: user.Name,
}
return response.Data("group_page.gotmpl", p)
}
func Group(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response {
if app.User.IsAdmin {
user := app.User()
if user.IsAdmin {
return AdminGroup(app, h, r)
}
groupReference := r.PathValue("groupReference")
@ -49,11 +51,11 @@ func Group(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response
if group == nil {
return response.Error(http.StatusNotFound, "Group not found. (It might be because you're not a member)")
}
index := group.MemberIndex(app.User.Id)
index := group.MemberIndex(user.Id)
group.Members = slices.Delete(group.Members, index, index+1)
p := GroupProps{
Group: group,
CurrentUsername: app.User.Name,
CurrentUsername: user.Name,
}
return response.Data("group_page.gotmpl", p)
}

View File

@ -26,7 +26,8 @@ func Home(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response {
log.Printf("Failed to get gifts: %s\n", err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(")
}
todo, err := app.GetTodo()
user := app.User()
todo, err := user.GetTodo()
if err != nil {
log.Printf("Failed to get todo: %s\n", err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(")
@ -36,7 +37,7 @@ func Home(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Response {
log.Printf("Failed to get groups: %s\n", err)
return response.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(")
}
p := HomeProps{Username: app.User.Name, Gifts: gifts, Todo: todo, Reference: app.User.Reference, HostUrl: env.HostUrl.String(), Groups: groups}
p := HomeProps{Username: user.Name, Gifts: gifts, Todo: todo, Reference: user.Reference, HostUrl: env.HostUrl.String(), Groups: groups}
return response.Data("home.gotmpl", p)
}

View File

@ -1,8 +1,10 @@
package routing
import (
"errors"
"log"
"net/http"
"time"
lishwist "lishwist/core"
"lishwist/http/api"
@ -54,10 +56,11 @@ func LoginPost(session *response.Session, h http.Header, r *http.Request) rsvp.R
return resp
}
app, err := lishwist.Login(username, password)
appSession, err := lishwist.Login(username, password, time.Hour*24)
if err != nil {
switch err.(type) {
case lishwist.ErrorInvalidCredentials:
var targ lishwist.ErrorInvalidCredentials
switch {
case errors.As(err, &targ):
props.GeneralError = "Username or password invalid"
session.FlashSet(&props)
log.Printf("Invalid credentials: %s: %#v\n", err, props)
@ -71,8 +74,7 @@ func LoginPost(session *response.Session, h http.Header, r *http.Request) rsvp.R
}
session.SetID("")
session.SetValue("authorized", true)
session.SetValue("username", app.User.Name)
session.SetValue("sessionKey", appSession.Key)
return rsvp.SeeOther(r.URL.Path, "Login successful!")
}

View File

@ -53,7 +53,7 @@ func UserPost(app *lishwist.Session, h http.Header, r *http.Request) rsvp.Respon
}
reference := r.PathValue("userReference")
if reference == app.User.Reference {
if reference == app.User().Reference {
return response.Error(http.StatusForbidden, "You cannot delete yourself.")
}

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

@ -0,0 +1,61 @@
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 Create(useSecureCookies bool) *router.VisibilityRouter {
gob.Register(&api.RegisterProps{})
gob.Register(&api.LoginProps{})
store := session.NewInMemoryStore([]byte(env.SessionSecret))
store.Options.MaxAge = 86_400 // 24 hours in seconds
store.Options.Secure = useSecureCookies
store.Options.HttpOnly = true
r := router.New(store)
r.Public.HandleFunc("GET /", routing.Login)
r.Public.HandleFunc("GET /groups/{groupReference}", routing.PublicGroup)
r.Public.HandleFunc("GET /lists/{userReference}", routing.PublicWishlist)
r.Public.HandleFunc("GET /register", routing.Register)
r.Public.HandleFunc("POST /", routing.LoginPost)
r.Public.HandleFunc("POST /register", routing.RegisterPost)
r.Private.HandleFunc("GET /", routing.NotFound)
r.Private.HandleFunc("GET /groups", routing.ExpectAppSession(routing.Groups))
r.Private.HandleFunc("GET /groups/{groupReference}", routing.ExpectAppSession(routing.Group))
r.Private.HandleFunc("GET /lists/{userReference}", routing.ExpectAppSession(routing.ForeignWishlist))
r.Private.HandleFunc("GET /users", routing.ExpectAppSession(routing.Users))
r.Private.HandleFunc("GET /users/{userReference}", routing.ExpectAppSession(routing.User))
r.Private.HandleFunc("GET /{$}", routing.ExpectAppSession(routing.Home))
r.Private.HandleFunc("POST /groups/{groupReference}", routing.ExpectAppSession(routing.GroupPost))
r.Private.HandleFunc("POST /list/{userReference}", routing.ExpectAppSession(routing.ForeignWishlistPost))
r.Private.HandleFunc("POST /logout", routing.LogoutPost)
r.Private.HandleFunc("POST /users/{userReference}", routing.ExpectAppSession(routing.UserPost))
r.Private.HandleFunc("POST /{$}", routing.ExpectAppSession(routing.HomePost))
// Deprecated
r.HandleFunc("GET /group/{groupReference}", prefixMovedPermanently("/group/", "/groups/"))
r.HandleFunc("GET /list/{userReference}", prefixMovedPermanently("/list/", "/lists/"))
return r
}

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

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

View File

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

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

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

3
scripts/api_snapshot Executable file
View File

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

View File

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