Feat: add models/cache/config
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
.aider*
|
||||
golias
|
||||
|
35
Makefile
Normal file
35
Makefile
Normal 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
|
@ -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
35
config/config.go
Normal 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
7
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
|
||||
|
6
go.sum
Normal file
6
go.sum
Normal 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
115
handlers/auth.go
Normal 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
83
handlers/middleware.go
Normal 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
25
models/auth.go
Normal 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
69
models/main.go
Normal 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
144
pkg/cache/impl.go
vendored
Normal 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
9
pkg/cache/main.go
vendored
Normal 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
24
utils/main.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user