diff --git a/.gitignore b/.gitignore index b0ac3ed..ad3605a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .aider* +golias diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e711f49 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +.PHONY: all init deps install test lint run stop + +run: + go build + ./golias start + +init: + go mod init + +# install all dependencies used by the application +deps: + go clean -modcache + go mod download + +# install the application in the Go bin/ folder +install: + go install ./... + +test: + go test ./... + +lint: + golangci-lint run --config .golangci.yml + +gen: + go generate ./... + +build-container: + docker build -t golias:master . + +stop-container: + docker rm -f golias 2>/dev/null && echo "old container removed" + +run-container: stop-container + docker run --name=golias -v $(CURDIR)/store.json:/root/store.json -p 0.0.0.0:9000:9000 -d golias:master diff --git a/components/index.html b/components/index.html index 14ef046..c7c1cbb 100644 --- a/components/index.html +++ b/components/index.html @@ -4,7 +4,7 @@ Word Colors - +

Word Color Cards

diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..39f776e --- /dev/null +++ b/config/config.go @@ -0,0 +1,35 @@ +package config + +import ( + "log/slog" + + "github.com/BurntSushi/toml" +) + +type Config struct { + ServerConfig ServerConfig `toml:"SERVICE"` + BaseURL string `toml:"BASE_URL"` + SessionLifetime int `toml:"SESSION_LIFETIME_SECONDS"` + DBURI string `toml:"DBURI"` + CookieSecret string `toml:"COOKIE_SECRET"` +} + +type ServerConfig struct { + Host string `toml:"HOST"` + Port string `toml:"PORT"` +} + +func LoadConfigOrDefault(fn string) *Config { + if fn == "" { + fn = "config.toml" + } + config := &Config{} + _, err := toml.DecodeFile(fn, &config) + if err != nil { + slog.Warn("failed to read config from file, loading default", "error", err) + config.BaseURL = "https://localhost:3000" + config.SessionLifetime = 300 + config.CookieSecret = "test" + } + return config +} diff --git a/go.mod b/go.mod index 10e16d2..55a533b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module golias go 1.24 + +require ( + github.com/BurntSushi/toml v1.5.0 + honnef.co/go/tools v0.6.1 +) + +require golang.org/x/tools v0.30.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fda7250 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= +golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= +honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= +honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..a3b877a --- /dev/null +++ b/handlers/auth.go @@ -0,0 +1,115 @@ +package handlers + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "golias/models" + "golias/utils" + "html/template" + "log/slog" + "net/http" + "strings" + "time" +) + +func abortWithError(w http.ResponseWriter, msg string) { + tmpl := template.Must(template.ParseGlob("components/*.html")) + tmpl.ExecuteTemplate(w, "error", msg) +} + +func HandleFrontLogin(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + username := r.PostFormValue("username") + if username == "" { + msg := "username not provided" + slog.Error(msg) + abortWithError(w, msg) + return + } + // make sure username does not exists + cleanName := utils.RemoveSpacesFromStr(username) + // TODO: create user in db + // login user + cookie, err := makeCookie(cleanName, r.RemoteAddr) + if err != nil { + slog.Error("failed to login", "error", err) + abortWithError(w, err.Error()) + return + } + http.SetCookie(w, cookie) + tmpl, err := template.ParseGlob("components/*.html") + if err != nil { + abortWithError(w, err.Error()) + return + } + tmpl.ExecuteTemplate(w, "main", nil) +} + +func makeCookie(username string, remote string) (*http.Cookie, error) { + // secret + // Create a new random session token + // sessionToken := xid.New().String() + sessionToken := "token" + expiresAt := time.Now().Add(time.Duration(cfg.SessionLifetime) * time.Second) + // Set the token in the session map, along with the session information + session := &models.Session{ + Username: username, + Expiry: expiresAt, + } + cookieName := "session_token" + // hmac to protect cookies + hm := hmac.New(sha256.New, []byte(cfg.CookieSecret)) + hm.Write([]byte(cookieName)) + hm.Write([]byte(sessionToken)) + signature := hm.Sum(nil) + // b64 enc to avoid non-ascii + cookieValue := base64.URLEncoding.EncodeToString([]byte( + string(signature) + sessionToken)) + cookie := &http.Cookie{ + Name: cookieName, + Value: cookieValue, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteNoneMode, + Domain: cfg.ServerConfig.Host, + } + slog.Info("check remote addr for cookie set", + "remote", remote, "session", session) + if strings.Contains(remote, "192.168.0") { + // no idea what is going on + // cookie.Domain = "192.168.0.15" + cookie.Domain = "home.host" + slog.Info("changing cookie domain", "domain", cookie.Domain) + } + // set ctx? + // set user in session + if err := cacheSetSession(sessionToken, session); err != nil { + return nil, err + } + return cookie, nil +} + +func cacheGetSession(key string) (*models.Session, error) { + userSessionB, err := memcache.Get(key) + if err != nil { + return nil, err + } + var us *models.Session + if err := json.Unmarshal(userSessionB, &us); err != nil { + return nil, err + } + return us, nil +} + +func cacheSetSession(key string, session *models.Session) error { + sesb, err := json.Marshal(session) + if err != nil { + return err + } + memcache.Set(key, sesb) + // expire in 10 min + memcache.Expire(key, 10*60) + return nil +} diff --git a/handlers/middleware.go b/handlers/middleware.go new file mode 100644 index 0000000..ac9642f --- /dev/null +++ b/handlers/middleware.go @@ -0,0 +1,83 @@ +package handlers + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "golias/config" + "golias/pkg/cache" + "log/slog" + "net/http" +) + +var ( + cfg config.Config + memcache cache.Cache +) + +func GetSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookieName := "session_token" + sessionCookie, err := r.Cookie(cookieName) + if err != nil { + msg := "auth failed; failed to get session token from cookies" + slog.Debug(msg, "error", err) + next.ServeHTTP(w, r) + return + } + cookieValueB, err := base64.URLEncoding. + DecodeString(sessionCookie.Value) + if err != nil { + msg := "auth failed; failed to decode b64 cookie" + slog.Debug(msg, "error", err) + next.ServeHTTP(w, r) + return + } + cookieValue := string(cookieValueB) + if len(cookieValue) < sha256.Size { + slog.Warn("small cookie", "size", len(cookieValue)) + next.ServeHTTP(w, r) + return + } + // Split apart the signature and original cookie value. + signature := cookieValue[:sha256.Size] + sessionToken := cookieValue[sha256.Size:] + //verify signature + mac := hmac.New(sha256.New, []byte(cfg.CookieSecret)) + mac.Write([]byte(cookieName)) + mac.Write([]byte(sessionToken)) + expectedSignature := mac.Sum(nil) + if !hmac.Equal([]byte(signature), expectedSignature) { + slog.Debug("cookie with an invalid sign") + next.ServeHTTP(w, r) + return + } + userSession, err := cacheGetSession(sessionToken) + if err != nil { + msg := "auth failed; session does not exists" + err = errors.New(msg) + slog.Debug(msg, "error", err) + next.ServeHTTP(w, r) + return + } + if userSession.IsExpired() { + memcache.RemoveKey(sessionToken) + msg := "session is expired" + slog.Debug(msg, "error", err, "token", sessionToken) + next.ServeHTTP(w, r) + return + } + ctx := context.WithValue(r.Context(), + "username", userSession.Username) + if err := cacheSetSession(sessionToken, + userSession); err != nil { + msg := "failed to marshal user session" + slog.Warn(msg, "error", err) + next.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/models/auth.go b/models/auth.go new file mode 100644 index 0000000..5dadf8a --- /dev/null +++ b/models/auth.go @@ -0,0 +1,25 @@ +package models + +import ( + "time" +) + +// each session contains the username of the user and the time at which it expires +type Session struct { + Username string + CurrentRoom string + Expiry time.Time +} + +// we'll use this method later to determine if the session has expired +func (s Session) IsExpired() bool { + return s.Expiry.Before(time.Now()) +} + +func ListUsernames(ss map[string]*Session) []string { + resp := make([]string, 0, len(ss)) + for _, s := range ss { + resp = append(resp, s.Username) + } + return resp +} diff --git a/models/main.go b/models/main.go new file mode 100644 index 0000000..81b4105 --- /dev/null +++ b/models/main.go @@ -0,0 +1,69 @@ +package models + +import "time" + +type WordColor string + +const ( + WordColorWhite = "white" + WordColorBlue = "blue" + WordColorRed = "red" + WordColorBlack = "black" +) + +type Room struct { + ID string `json:"id" db:"id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + RoomName string `json:"room_name"` + RoomPass string `json:"room_pass"` + RoomLink string + CreatorName string `json:"creator_name"` + PlayerList []string `json:"player_list"` + RedMime string + BlueMime string + RedGuessers []string + BlueGuessers []string + Cards []WordCard + GameSettings *GameSettings `json:"settings"` + Result uint8 // 0 for unknown; 1 is win for red; 2 if for blue; +} + +type WordCard struct { + Word string + Color WordColor + Revealed bool +} + +type RoomPublic struct { + ID string `json:"id" db:"id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + PlayerList []string `json:"player_list"` + CreatorName string `json:"creator_name"` + GameSettings *GameSettings `json:"settings"` + RedMime string + BlueMime string + RedGuessers []string + BlueGuessers []string +} + +func (r Room) ToPublic() RoomPublic { + return RoomPublic{ + ID: r.ID, + CreatedAt: r.CreatedAt, + PlayerList: r.PlayerList, + GameSettings: r.GameSettings, + CreatorName: r.CreatorName, + RedMime: r.RedMime, + BlueMime: r.BlueMime, + RedGuessers: r.RedGuessers, + BlueGuessers: r.BlueGuessers, + } +} + +type GameSettings struct { + IsRunning bool `json:"is_running"` + Language string `json:"language" example:"en" form:"language"` + RoundTime int32 `json:"round_time"` + ProgressPct uint32 `json:"progress_pct"` + IsOver bool +} diff --git a/pkg/cache/impl.go b/pkg/cache/impl.go new file mode 100644 index 0000000..04cf8f0 --- /dev/null +++ b/pkg/cache/impl.go @@ -0,0 +1,144 @@ +package cache + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "sync" + "time" +) + +const storeFileName = "store.json" + +// var MemCache Cache +var ( + MemCache *MemoryCache +) + +func readJSON(fileName string) (map[string][]byte, error) { + data := make(map[string][]byte) + file, err := os.Open(fileName) + if err != nil { + return data, err + } + defer file.Close() + decoder := json.NewDecoder(file) + if err := decoder.Decode(&data); err != nil { + return data, err + } + return data, nil +} + +func init() { + data, err := readJSON(storeFileName) + if err != nil { + slog.Error("failed to load store from file") + } + MemCache = &MemoryCache{ + data: data, + timeMap: make(map[string]time.Time), + lock: &sync.RWMutex{}, + } + MemCache.StartExpiryRoutine(time.Minute) + MemCache.StartBackupRoutine(time.Minute) +} + +type MemoryCache struct { + data map[string][]byte + timeMap map[string]time.Time + lock *sync.RWMutex +} + +// Get a value by key from the cache +func (mc *MemoryCache) Get(key string) (value []byte, err error) { + var ok bool + mc.lock.RLock() + if value, ok = mc.data[key]; !ok { + err = fmt.Errorf("not found data in mc for the key: %v", key) + } + mc.lock.RUnlock() + return value, err +} + +// Update a single value in the cache +func (mc *MemoryCache) Set(key string, value []byte) { + // no async writing + mc.lock.Lock() + mc.data[key] = value + mc.lock.Unlock() +} + +func (mc *MemoryCache) Expire(key string, exp int64) { + mc.lock.RLock() + mc.timeMap[key] = time.Now().Add(time.Duration(exp) * time.Second) + mc.lock.RUnlock() +} + +func (mc *MemoryCache) GetAll() (resp map[string][]byte) { + resp = make(map[string][]byte) + mc.lock.RLock() + for k, v := range mc.data { + resp[k] = v + } + mc.lock.RUnlock() + return +} + +func (mc *MemoryCache) GetAllTime() (resp map[string]time.Time) { + resp = make(map[string]time.Time) + mc.lock.RLock() + for k, v := range mc.timeMap { + resp[k] = v + } + mc.lock.RUnlock() + return +} + +func (mc *MemoryCache) RemoveKey(key string) { + mc.lock.RLock() + delete(mc.data, key) + delete(mc.timeMap, key) + mc.lock.RUnlock() +} + +func (mc *MemoryCache) StartExpiryRoutine(n time.Duration) { + ticker := time.NewTicker(n) + go func() { + for { + <-ticker.C + // get all + timeData := mc.GetAllTime() + // check time + currentTS := time.Now() + for k, ts := range timeData { + if ts.Before(currentTS) { + // delete exp keys + mc.RemoveKey(k) + slog.Debug("remove by expiry", "key", k) + } + } + } + }() +} + +func (mc *MemoryCache) StartBackupRoutine(n time.Duration) { + ticker := time.NewTicker(n) + go func() { + for { + <-ticker.C + // get all + data := mc.GetAll() + jsonString, err := json.Marshal(data) + if err != nil { + slog.Warn("failed to marshal", "err", err) + continue + } + err = os.WriteFile(storeFileName, jsonString, os.ModePerm) + if err != nil { + slog.Warn("failed to write", "err", err) + continue + } + } + }() +} diff --git a/pkg/cache/main.go b/pkg/cache/main.go new file mode 100644 index 0000000..606f50f --- /dev/null +++ b/pkg/cache/main.go @@ -0,0 +1,9 @@ +package cache + +type Cache interface { + Get(key string) ([]byte, error) + Set(key string, value []byte) + Expire(key string, exp int64) + GetAll() (resp map[string][]byte) + RemoveKey(key string) +} diff --git a/utils/main.go b/utils/main.go new file mode 100644 index 0000000..f49313e --- /dev/null +++ b/utils/main.go @@ -0,0 +1,24 @@ +package utils + +import ( + "strings" + "unicode" +) + +func RemoveSpacesFromStr(origin string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, origin) +} + +func StrInSlice(key string, sl []string) bool { + for _, i := range sl { + if key == i { + return true + } + } + return false +}