Feat: add models/cache/config

This commit is contained in:
Grail Finder
2025-05-02 09:06:17 +03:00
parent dbc87d7b9b
commit acaf4af4ce
13 changed files with 554 additions and 1 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
.aider*
golias

35
Makefile Normal file
View File

@ -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

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Word Colors</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
</head>
<body>
<h1>Word Color Cards</h1>

35
config/config.go Normal file
View File

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

7
go.mod
View File

@ -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

6
go.sum Normal file
View File

@ -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=

115
handlers/auth.go Normal file
View File

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

83
handlers/middleware.go Normal file
View File

@ -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))
})
}

25
models/auth.go Normal file
View File

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

69
models/main.go Normal file
View File

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

144
pkg/cache/impl.go vendored Normal file
View File

@ -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
}
}
}()
}

9
pkg/cache/main.go vendored Normal file
View File

@ -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)
}

24
utils/main.go Normal file
View File

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