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
+}