commit
863571b687
10 changed files with 938 additions and 0 deletions
@ -0,0 +1,21 @@ |
|||||||
|
module stick |
||||||
|
|
||||||
|
go 1.22.2 |
||||||
|
|
||||||
|
require ( |
||||||
|
github.com/gorilla/sessions v1.1.1 |
||||||
|
github.com/gorilla/websocket v1.5.3 |
||||||
|
github.com/joho/godotenv v1.5.1 |
||||||
|
github.com/markbates/goth v1.81.0 |
||||||
|
) |
||||||
|
|
||||||
|
require ( |
||||||
|
github.com/go-chi/chi/v5 v5.1.0 // indirect |
||||||
|
github.com/golang/protobuf v1.5.3 // indirect |
||||||
|
github.com/gorilla/context v1.1.1 // indirect |
||||||
|
github.com/gorilla/mux v1.6.2 // indirect |
||||||
|
github.com/gorilla/securecookie v1.1.1 // indirect |
||||||
|
golang.org/x/oauth2 v0.17.0 // indirect |
||||||
|
google.golang.org/appengine v1.6.8 // indirect |
||||||
|
google.golang.org/protobuf v1.32.0 // indirect |
||||||
|
) |
||||||
@ -0,0 +1,64 @@ |
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= |
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= |
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= |
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= |
||||||
|
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= |
||||||
|
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= |
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= |
||||||
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= |
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= |
||||||
|
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= |
||||||
|
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= |
||||||
|
github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= |
||||||
|
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= |
||||||
|
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= |
||||||
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= |
||||||
|
github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE= |
||||||
|
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= |
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= |
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= |
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= |
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= |
||||||
|
github.com/markbates/goth v1.81.0 h1:XVcCkeGWokynPV7MXvgb8pd2s3r7DS40P7931w6kdnE= |
||||||
|
github.com/markbates/goth v1.81.0/go.mod h1:+6z31QyUms84EHmuBY7iuqYSxyoN3njIgg9iCF/lR1k= |
||||||
|
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= |
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= |
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= |
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= |
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= |
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= |
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= |
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= |
||||||
|
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= |
||||||
|
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= |
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= |
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= |
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= |
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= |
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= |
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= |
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= |
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
||||||
|
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= |
||||||
|
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= |
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= |
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= |
||||||
|
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= |
||||||
|
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= |
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
||||||
@ -0,0 +1,84 @@ |
|||||||
|
package handlers |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"os" |
||||||
|
|
||||||
|
"github.com/gorilla/sessions" |
||||||
|
"github.com/markbates/goth/gothic" |
||||||
|
) |
||||||
|
|
||||||
|
func (h *Handler) Auth(w http.ResponseWriter, r *http.Request) { |
||||||
|
q := r.URL.Query() |
||||||
|
q.Add("provider", "twitch") |
||||||
|
r.URL.RawQuery = q.Encode() |
||||||
|
key := os.Getenv("SESSION_SECRET") |
||||||
|
fmt.Printf("my secret is this long: %v", len(key)) |
||||||
|
gothic.BeginAuthHandler(w, r) |
||||||
|
} |
||||||
|
|
||||||
|
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) { |
||||||
|
user, err := gothic.CompleteUserAuth(w, r) |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest) |
||||||
|
return |
||||||
|
} |
||||||
|
key := os.Getenv("SESSION_SECRET") // Replace with your SESSION_SECRET or similar
|
||||||
|
maxAge := 86400 * 30 // 30 days
|
||||||
|
isProd := false // Set to true when serving over https
|
||||||
|
|
||||||
|
store := sessions.NewCookieStore([]byte(key)) |
||||||
|
store.MaxAge(maxAge) |
||||||
|
store.Options.Path = "/" |
||||||
|
store.Options.HttpOnly = true // HttpOnly should always be enabled
|
||||||
|
store.Options.Secure = isProd |
||||||
|
|
||||||
|
gothic.Store = store |
||||||
|
session, _ := gothic.Store.Get(r, "user-session") |
||||||
|
session.Values["user_name"] = user.Name |
||||||
|
session.Values["avatar_url"] = user.AvatarURL |
||||||
|
session.Values["user_id"] = user.UserID |
||||||
|
session.Values["provider"] = user.Provider |
||||||
|
|
||||||
|
err = session.Save(r, w) |
||||||
|
if err != nil { |
||||||
|
fmt.Printf("error saving the session: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound) |
||||||
|
} |
||||||
|
|
||||||
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { |
||||||
|
session, err := gothic.Store.Get(r, "user-session") |
||||||
|
if err != nil { |
||||||
|
fmt.Printf("error retrieving session: %v", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Clear the session data
|
||||||
|
session.Values = make(map[interface{}]interface{}) |
||||||
|
session.Options.MaxAge = -1 |
||||||
|
|
||||||
|
// Save the empty session
|
||||||
|
err = session.Save(r, w) |
||||||
|
if err != nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound) |
||||||
|
} |
||||||
|
|
||||||
|
func RequireAuth(next http.Handler) http.Handler { |
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||||
|
session, _ := gothic.Store.Get(r, "user-session") |
||||||
|
userID, ok := session.Values["user_id"] |
||||||
|
if !ok || userID == nil { |
||||||
|
http.Redirect(w, r, "/", http.StatusFound) |
||||||
|
return |
||||||
|
} |
||||||
|
next.ServeHTTP(w, r) |
||||||
|
}) |
||||||
|
} |
||||||
@ -0,0 +1,122 @@ |
|||||||
|
package handlers |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"html/template" |
||||||
|
"net/http" |
||||||
|
"sync" |
||||||
|
|
||||||
|
"github.com/gorilla/websocket" |
||||||
|
"github.com/markbates/goth/gothic" |
||||||
|
) |
||||||
|
|
||||||
|
type Handler struct { |
||||||
|
Template template.Template |
||||||
|
} |
||||||
|
|
||||||
|
type Message struct { |
||||||
|
Id string |
||||||
|
Action string |
||||||
|
Message string |
||||||
|
} |
||||||
|
|
||||||
|
type PageData struct { |
||||||
|
Username string |
||||||
|
} |
||||||
|
|
||||||
|
type hub struct { |
||||||
|
clients map[*websocket.Conn]bool |
||||||
|
Broadcast chan []byte |
||||||
|
register chan *websocket.Conn |
||||||
|
unregister chan *websocket.Conn |
||||||
|
mutex sync.RWMutex |
||||||
|
} |
||||||
|
|
||||||
|
var Hub = &hub{ |
||||||
|
clients: make(map[*websocket.Conn]bool), |
||||||
|
Broadcast: make(chan []byte), |
||||||
|
register: make(chan *websocket.Conn), |
||||||
|
unregister: make(chan *websocket.Conn), |
||||||
|
} |
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{ |
||||||
|
CheckOrigin: func(r *http.Request) bool { |
||||||
|
return true // this should change
|
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
func (h *hub) Run() { |
||||||
|
for { |
||||||
|
select { |
||||||
|
case conn := <-h.register: |
||||||
|
h.mutex.Lock() |
||||||
|
h.clients[conn] = true |
||||||
|
h.mutex.Unlock() |
||||||
|
fmt.Printf("Client connected. Total clients: %d\n", len(h.clients)) |
||||||
|
|
||||||
|
case conn := <-h.unregister: |
||||||
|
h.mutex.Lock() |
||||||
|
if _, ok := h.clients[conn]; ok { |
||||||
|
delete(h.clients, conn) |
||||||
|
conn.Close() |
||||||
|
} |
||||||
|
h.mutex.Unlock() |
||||||
|
fmt.Printf("Client disconnected. Total clients: %d\n", len(h.clients)) |
||||||
|
|
||||||
|
case message := <-h.Broadcast: |
||||||
|
h.mutex.RLock() |
||||||
|
for conn := range h.clients { |
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, message); err != nil { |
||||||
|
fmt.Printf("Error sending message to client: %v\n", err) |
||||||
|
// Remove failed connection
|
||||||
|
delete(h.clients, conn) |
||||||
|
conn.Close() |
||||||
|
} |
||||||
|
} |
||||||
|
h.mutex.RUnlock() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (h *Handler) Home(w http.ResponseWriter, r *http.Request) { |
||||||
|
session, err := gothic.Store.Get(r, "user-session") |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
http.Error(w, "Error retrieving session for welcome page", http.StatusInternalServerError) |
||||||
|
} |
||||||
|
username, ok := session.Values["user_name"].(string) |
||||||
|
var pagedata PageData |
||||||
|
|
||||||
|
if ok { |
||||||
|
pagedata.Username = username |
||||||
|
} else { |
||||||
|
pagedata.Username = "" |
||||||
|
} |
||||||
|
|
||||||
|
err = h.Template.ExecuteTemplate(w, "index.html", &pagedata) |
||||||
|
if err != nil { |
||||||
|
http.Error(w, "Template rendering error", http.StatusInternalServerError) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (h *Handler) WsHandler(w http.ResponseWriter, r *http.Request) { |
||||||
|
conn, err := upgrader.Upgrade(w, r, nil) |
||||||
|
if err != nil { |
||||||
|
fmt.Println("upgrade error:", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
Hub.register <- conn |
||||||
|
|
||||||
|
defer conn.Close() |
||||||
|
|
||||||
|
for { |
||||||
|
_, messageBytes, err := conn.ReadMessage() |
||||||
|
if err != nil { |
||||||
|
fmt.Printf("error reading message: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
fmt.Printf("message being broadcasted: %v", string(messageBytes)) |
||||||
|
Hub.Broadcast <- messageBytes |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,80 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
"os" |
||||||
|
"os/signal" |
||||||
|
"stick/internal/handlers" |
||||||
|
"syscall" |
||||||
|
"time" |
||||||
|
|
||||||
|
"html/template" |
||||||
|
|
||||||
|
"github.com/joho/godotenv" |
||||||
|
"github.com/markbates/goth" |
||||||
|
"github.com/markbates/goth/providers/twitch" |
||||||
|
) |
||||||
|
|
||||||
|
func main() { |
||||||
|
err := godotenv.Load() |
||||||
|
if err != nil { |
||||||
|
fmt.Printf("error loading .env file: %v", err) |
||||||
|
} |
||||||
|
fs := http.FileServer(http.Dir("./static")) |
||||||
|
tmpl, err := template.ParseGlob("templates/*.html") |
||||||
|
mux := http.NewServeMux() |
||||||
|
h := handlers.Handler{Template: *tmpl} |
||||||
|
|
||||||
|
mux.Handle("/static/", http.StripPrefix("/static", fs)) |
||||||
|
mux.HandleFunc("/", h.Home) |
||||||
|
goth.UseProviders( |
||||||
|
twitch.New( |
||||||
|
os.Getenv("TWITCH_CLIENT_ID"), |
||||||
|
os.Getenv("TWITCH_SECRET"), |
||||||
|
"https://localhost:8080/auth/twitch/callback", |
||||||
|
), |
||||||
|
) |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
panic(fmt.Errorf("failed to parse templates: %w\n", err)) |
||||||
|
} |
||||||
|
|
||||||
|
go func() { |
||||||
|
hub := handlers.Hub |
||||||
|
hub.Run() |
||||||
|
}() |
||||||
|
|
||||||
|
mux.HandleFunc("/ws", h.WsHandler) |
||||||
|
mux.HandleFunc("/auth/twitch", h.Auth) |
||||||
|
mux.HandleFunc("/auth/twitch/callback", h.Callback) |
||||||
|
mux.HandleFunc("/logout", h.Logout) |
||||||
|
|
||||||
|
server := &http.Server{ |
||||||
|
Addr: ":8080", |
||||||
|
Handler: mux, |
||||||
|
} |
||||||
|
|
||||||
|
go func() { |
||||||
|
fmt.Println("Server started...") |
||||||
|
if err := server.ListenAndServe(); err != http.ErrServerClosed { |
||||||
|
fmt.Printf("Serve error: %v\n", err) |
||||||
|
} |
||||||
|
}() |
||||||
|
|
||||||
|
stop := make(chan os.Signal, 1) |
||||||
|
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) |
||||||
|
|
||||||
|
<-stop |
||||||
|
fmt.Println("Shutting down server") |
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
if err := server.Shutdown(ctx); err != nil { |
||||||
|
fmt.Printf("Server force to shutdown: %v\n", err) |
||||||
|
} else { |
||||||
|
fmt.Println("Server exit gracefully") |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,491 @@ |
|||||||
|
window.addEventListener("DOMContentLoaded", function() { |
||||||
|
class StickFigure { |
||||||
|
constructor(x, facing, id, canvas) { |
||||||
|
this.canvas = canvas; |
||||||
|
this.id = id; |
||||||
|
this.x = x; |
||||||
|
this.y = canvas.height - 60; |
||||||
|
this.facing = facing; |
||||||
|
this.action = "idle"; |
||||||
|
this.hitFrame = 0; |
||||||
|
this.speed = 2; |
||||||
|
this.vx = 0; |
||||||
|
this.vy = 0; |
||||||
|
this.gravity = 0.5; |
||||||
|
this.jumpStrength = -10; |
||||||
|
this.onGround = true; |
||||||
|
this.talking = false; |
||||||
|
this.talkTimer = 0; |
||||||
|
this.message = ""; |
||||||
|
this.keys = { |
||||||
|
h: false, |
||||||
|
l: false, |
||||||
|
}; |
||||||
|
this.crouching = false; |
||||||
|
this.isPunching = false; |
||||||
|
this.punchHasHit = false; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
update() { |
||||||
|
this.vx = 0; |
||||||
|
if (this.keys.h) { |
||||||
|
this.vx = -this.speed; |
||||||
|
this.facing = -1; |
||||||
|
} |
||||||
|
|
||||||
|
if (this.keys.l) { |
||||||
|
this.vx = this.speed; |
||||||
|
this.facing = 1; |
||||||
|
} |
||||||
|
|
||||||
|
this.x += this.vx; |
||||||
|
|
||||||
|
this.vy += this.gravity; |
||||||
|
this.y += this.vy; |
||||||
|
|
||||||
|
const groundY = this.canvas.height; |
||||||
|
const totalHeight = 60; |
||||||
|
|
||||||
|
if (this.y + totalHeight >= groundY) { |
||||||
|
this.y = groundY - totalHeight; |
||||||
|
this.vy = 0; |
||||||
|
this.onGround = true; |
||||||
|
} else { |
||||||
|
this.onGround = false; |
||||||
|
} |
||||||
|
|
||||||
|
this.x = Math.max(20, Math.min(this.canvas.width - 20, this.x)); |
||||||
|
|
||||||
|
if (this.action === "punch") { |
||||||
|
this.hitFrame++; |
||||||
|
if (this.hitFrame > 15) { |
||||||
|
this.action = 'idle'; |
||||||
|
this.hitFrame = 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (this.talking) { |
||||||
|
this.talkTimer--; |
||||||
|
if (this.talkTimer <= 0) { |
||||||
|
this.talking = false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
draw(ctx, time) { |
||||||
|
const headRadius = 10; |
||||||
|
const x = this.x; |
||||||
|
let y = this.y; |
||||||
|
|
||||||
|
const crouch = !!this.crouching; |
||||||
|
const bodyLength = crouch ? 20 : 30; |
||||||
|
const legLength = crouch ? 20 : 25; |
||||||
|
if (crouch) y += 15; // shift down a touch so crouch looks grounded
|
||||||
|
|
||||||
|
|
||||||
|
// head
|
||||||
|
ctx.beginPath(); |
||||||
|
ctx.arc(x, y, headRadius, 0, Math.PI * 2); |
||||||
|
ctx.stroke(); |
||||||
|
|
||||||
|
// eye
|
||||||
|
ctx.beginPath(); |
||||||
|
ctx.arc(x + this.facing * 5, y - 3, 2, 0, Math.PI * 2); |
||||||
|
ctx.fill(); |
||||||
|
|
||||||
|
// body
|
||||||
|
const chestY = y + headRadius; |
||||||
|
const hipY = y + headRadius + bodyLength; |
||||||
|
ctx.beginPath(); |
||||||
|
ctx.moveTo(x, chestY); |
||||||
|
ctx.lineTo(x, hipY); |
||||||
|
ctx.stroke(); |
||||||
|
|
||||||
|
// arms
|
||||||
|
ctx.beginPath(); |
||||||
|
ctx.moveTo(x, y + 20); |
||||||
|
const walking = this.vx !== 0; |
||||||
|
const armSwing = walking ? Math.sin(time * 0.2) * 4 : 0; // subtle swing
|
||||||
|
if (this.action === "punch" && this.hitFrame < 10) { |
||||||
|
ctx.lineTo(x + 30 * this.facing, y + 10); |
||||||
|
} else { |
||||||
|
ctx.lineTo(x + (20 + armSwing) * this.facing, y + 20); |
||||||
|
} |
||||||
|
ctx.moveTo(x, y + 20); |
||||||
|
ctx.lineTo(x - (20 + armSwing) * this.facing, y + 20); |
||||||
|
ctx.stroke(); |
||||||
|
|
||||||
|
// legs
|
||||||
|
ctx.beginPath(); |
||||||
|
if (this.vx !== 0) { |
||||||
|
const step = Math.sin(time * 0.75); // cadence; lower = slower
|
||||||
|
const stride = 14; // how far forward/back feet go
|
||||||
|
const lift = 0; // little foot lift
|
||||||
|
|
||||||
|
// right leg
|
||||||
|
ctx.moveTo(x, hipY); |
||||||
|
ctx.lineTo(x + stride * step, hipY + legLength + (-lift * Math.abs(step))); |
||||||
|
|
||||||
|
// left leg (opposite phase)
|
||||||
|
ctx.moveTo(x, hipY); |
||||||
|
ctx.lineTo(x - stride * step, hipY + legLength + (-lift * Math.abs(step))); |
||||||
|
} else { |
||||||
|
const idleSpread = 10; |
||||||
|
ctx.moveTo(x, hipY); |
||||||
|
ctx.lineTo(x + idleSpread, hipY + legLength); |
||||||
|
ctx.moveTo(x, hipY); |
||||||
|
ctx.lineTo(x - idleSpread, hipY + legLength); |
||||||
|
} |
||||||
|
ctx.stroke(); |
||||||
|
|
||||||
|
// speech bubble
|
||||||
|
if (this.talking) { |
||||||
|
const padding = 12; |
||||||
|
const maxMessageWidth = 150; |
||||||
|
|
||||||
|
// set font BEFORE measuring
|
||||||
|
ctx.font = "12px sans-serif"; |
||||||
|
|
||||||
|
const lines = this._wrapText(ctx, this.message, maxMessageWidth); |
||||||
|
const textWidth = ctx.measureText(this.message).width; |
||||||
|
const widths = lines.map(line => ctx.measureText(line).width); |
||||||
|
const contentWidth = Math.max(...widths, textWidth); |
||||||
|
const bubbleWidth = Math.min(contentWidth + padding * 2, maxMessageWidth + padding * 2); |
||||||
|
const lineHeight = 18; |
||||||
|
const bubbleHeight = lines.length * lineHeight + padding; |
||||||
|
const bubbleX = x - bubbleWidth; |
||||||
|
const bubbleY = y - 30; |
||||||
|
|
||||||
|
ctx.beginPath(); |
||||||
|
ctx.fillStyle = "white"; |
||||||
|
ctx.strokeStyle = "black"; |
||||||
|
if (typeof ctx.roundRect === "function") { |
||||||
|
ctx.roundRect(bubbleX, bubbleY, bubbleWidth, bubbleHeight, 4); |
||||||
|
} else { |
||||||
|
ctx.rect(bubbleX, bubbleY, bubbleWidth, bubbleHeight); |
||||||
|
} |
||||||
|
ctx.fill(); |
||||||
|
ctx.stroke(); |
||||||
|
|
||||||
|
ctx.fillStyle = "black"; |
||||||
|
for (let i = 0; i < lines.length; i++) { |
||||||
|
ctx.fillText(lines[i], bubbleX + padding, bubbleY + padding + i * lineHeight); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
punch() { |
||||||
|
this.isPunching = true; |
||||||
|
this.punchHasHit = false; |
||||||
|
|
||||||
|
if (this.action === 'idle') { |
||||||
|
this.action = 'punch'; |
||||||
|
this.hitFrame = 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
talk(message) { |
||||||
|
this.talking = true; |
||||||
|
this.talkTimer = 120; |
||||||
|
this.message = message; |
||||||
|
} |
||||||
|
|
||||||
|
jump() { |
||||||
|
if (this.onGround) { |
||||||
|
this.vy = this.jumpStrength; |
||||||
|
this.onGround = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
_wrapText(ctx, text, maxWidth) { |
||||||
|
const words = text.split(" "); |
||||||
|
const lines = []; |
||||||
|
let line = ""; |
||||||
|
|
||||||
|
for (let i = 0; i < words.length; i++) { |
||||||
|
const testLine = line + words[i] + " "; |
||||||
|
const testWidth = ctx.measureText(testLine).width; |
||||||
|
|
||||||
|
if (testWidth > maxWidth && i > 0) { |
||||||
|
lines.push(line.trimEnd()); |
||||||
|
line = words[i] + " "; |
||||||
|
} else { |
||||||
|
line = testLine; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
lines.push(line.trim()); |
||||||
|
return lines; |
||||||
|
} |
||||||
|
|
||||||
|
getBodyHitbox() { |
||||||
|
const w = 24; |
||||||
|
const h = 60 - (this.crouching ? 10 : 0); |
||||||
|
return { |
||||||
|
x: this.x - w / 2, |
||||||
|
y: this.y - 10, |
||||||
|
w, |
||||||
|
h |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
getPunchHitbox() { |
||||||
|
if (this.action === 'punch' && this.hitFrame < 10) { |
||||||
|
const w = 18; |
||||||
|
const h = 14; |
||||||
|
const frontX = this.x + this.facing * 22; |
||||||
|
const x = this.facing === 1 ? frontX : frontX - w; |
||||||
|
const y = this.y + (this.crouching ? 18 : 10); |
||||||
|
return { |
||||||
|
x, |
||||||
|
y, |
||||||
|
w, |
||||||
|
h |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class Game { |
||||||
|
constructor({ canvasId = 'canvas', chatInputId = 'msg', sendBtnId = "send" }) { |
||||||
|
this.canvas = document.getElementById(canvasId); |
||||||
|
this.ctx = this.canvas.getContext('2d'); |
||||||
|
|
||||||
|
this.chatInput = document.getElementById(chatInputId); |
||||||
|
this.sendBtn = document.getElementById(sendBtnId); |
||||||
|
|
||||||
|
this.time = 0; |
||||||
|
this.players = {}; |
||||||
|
this.pageData = this._getPageData(); |
||||||
|
|
||||||
|
this.ws = null; |
||||||
|
|
||||||
|
this.loop = this.loop.bind(this); |
||||||
|
this._onKeyDown = this._onKeyDown.bind(this); |
||||||
|
this._onKeyUp = this._onKeyUp.bind(this); |
||||||
|
this._onSendClick = this._onSendClick.bind(this); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
start() { |
||||||
|
document.addEventListener("keydown", this._onKeyDown); |
||||||
|
document.addEventListener("keyup", this._onKeyUp); |
||||||
|
if (this.sendBtn) this.sendBtn.addEventListener("click", this._onSendClick); |
||||||
|
|
||||||
|
this._ensurePlayer(this.pageData.username); |
||||||
|
this._initWebSocket(); |
||||||
|
|
||||||
|
requestAnimationFrame(this.loop); |
||||||
|
} |
||||||
|
|
||||||
|
destroy() { |
||||||
|
document.removeEventListener("keydown", this._onKeyDown); |
||||||
|
document.removeEventListener("keyup", this._onKeyUp); |
||||||
|
if (this.sendBtn) this.sendBtn.removeEventListener("click", this._onSendClick); |
||||||
|
if (this.ws) this.ws.close(); |
||||||
|
} |
||||||
|
|
||||||
|
loop() { |
||||||
|
this.time += 0.1; |
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); |
||||||
|
|
||||||
|
const playerArray = Object.values(this.players); |
||||||
|
|
||||||
|
playerArray.forEach(player => { |
||||||
|
player.update(); // Call the update method for each player
|
||||||
|
}); |
||||||
|
|
||||||
|
for (let i = 0; i < playerArray.length; i++) { |
||||||
|
for (let j = i + 1; j < playerArray.length; j++) { |
||||||
|
const playerA = playerArray[i]; |
||||||
|
const playerB = playerArray[j]; |
||||||
|
|
||||||
|
const playerAPunch = playerA.getPunchHitbox(); |
||||||
|
const playerBPunch = playerB.getPunchHitbox(); |
||||||
|
const playerABody = playerA.getBodyHitbox(); |
||||||
|
const playerBBody = playerB.getBodyHitbox(); |
||||||
|
|
||||||
|
if (playerAPunch && rectangleOverlap(playerAPunch, playerBBody && !playerA.punchHasHit)) { |
||||||
|
playerA.punchHasHit = true; |
||||||
|
console.log("Player A hits player B"); |
||||||
|
} |
||||||
|
|
||||||
|
if (playerBPunch && rectangleOverlap(playerBPunch, playerABody) && !playerB.punchHasHit) { |
||||||
|
playerB.punchHasHit = true; |
||||||
|
console.log("Player B hits player A") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
playerArray.forEach(player => { |
||||||
|
player.draw(this.ctx, this.time); // Call the draw method for each player
|
||||||
|
|
||||||
|
}) |
||||||
|
|
||||||
|
requestAnimationFrame(this.loop) |
||||||
|
} |
||||||
|
|
||||||
|
_getPageData() { |
||||||
|
const metaTag = document.querySelector('meta[name="pagedata"]'); |
||||||
|
const json = JSON.parse(metaTag.getAttribute("content")); |
||||||
|
return json; |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
_isTypingInChat() { |
||||||
|
return document.activeElement === this.chatInput; |
||||||
|
} |
||||||
|
|
||||||
|
_onKeyDown(e) { |
||||||
|
if (this._isTypingInChat()) return; |
||||||
|
|
||||||
|
const me = this._me() |
||||||
|
if (!me) return; |
||||||
|
|
||||||
|
switch (e.key) { |
||||||
|
case 'e': |
||||||
|
me.punch() |
||||||
|
this._sendAction('e', "keyDown") |
||||||
|
break; |
||||||
|
case 'k': |
||||||
|
me.jump(); |
||||||
|
this._sendAction('k', "keyDown") |
||||||
|
break; |
||||||
|
case 'j': |
||||||
|
me.crouching = true; |
||||||
|
this._sendAction('j', 'keyDown') |
||||||
|
break; |
||||||
|
case 'h': |
||||||
|
case 'l': |
||||||
|
// avoid redundant work if already true
|
||||||
|
if (!me.keys) me.keys = {}; |
||||||
|
if (!me.keys[e.key]) me.keys[e.key] = true; |
||||||
|
this._sendAction(e.key, 'keyDown') |
||||||
|
break; |
||||||
|
default: |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
_me() { |
||||||
|
return this.players[this.pageData.username]; |
||||||
|
} |
||||||
|
|
||||||
|
_onKeyUp(e) { |
||||||
|
const me = this._me(); |
||||||
|
if (!me) return; |
||||||
|
|
||||||
|
if (e.key === 'j') { |
||||||
|
me.crouching = false; |
||||||
|
this._sendAction('j', 'keyUp') |
||||||
|
} |
||||||
|
|
||||||
|
if (e.key === 'h' || e.key === 'l') { |
||||||
|
if (!me.keys) me.keys = {}; |
||||||
|
me.keys[e.key] = false; |
||||||
|
this._sendAction(e.key, 'keyUp') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
_ensurePlayer(id) { |
||||||
|
if (!this.players[id] && id !== "") { |
||||||
|
this.players[id] = new StickFigure(250, 1, id, this.canvas) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
_onSendClick() { |
||||||
|
const inputEl = this.chatInput; |
||||||
|
if (!inputEl) return; |
||||||
|
|
||||||
|
const text = inputEl.value.trim(); |
||||||
|
if (!text) return; |
||||||
|
|
||||||
|
const me = this._me(); |
||||||
|
if (me) me.talk(text); |
||||||
|
|
||||||
|
this._sendAction("t", "", text); |
||||||
|
|
||||||
|
inputEl.value = ""; |
||||||
|
} |
||||||
|
|
||||||
|
_initWebSocket() { |
||||||
|
const scheme = location.protocol === "https:" ? "wss://" : "ws://"; |
||||||
|
this.ws = new WebSocket(scheme + location.host + "/ws"); |
||||||
|
|
||||||
|
this.ws.onopen = () => { |
||||||
|
if (this.pageData.username !== "") { |
||||||
|
this._send({ |
||||||
|
Id: this.pageData.username, |
||||||
|
Action: "join", |
||||||
|
Message: "" |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
this.ws.onmessage = (e) => { |
||||||
|
const data = JSON.parse(e.data); |
||||||
|
this._ensurePlayer(data.Id); |
||||||
|
|
||||||
|
// ignore echo
|
||||||
|
if (data.Id === this.pageData.username) return; |
||||||
|
|
||||||
|
const p = this.players[data.Id]; |
||||||
|
switch (data.Action) { |
||||||
|
case "t": |
||||||
|
p.talk(data.Message); |
||||||
|
break; |
||||||
|
case "e": |
||||||
|
p.punch(); |
||||||
|
break; |
||||||
|
case "k": |
||||||
|
p.jump(); |
||||||
|
break; |
||||||
|
case "h": |
||||||
|
case "l": |
||||||
|
p.keys[data.Action] = data.ActionType === "keyDown"; |
||||||
|
break; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
this.ws.onerror = (err) => { |
||||||
|
console.log("ws error:", err); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
_sendAction(action, actionType = "", message = "") { |
||||||
|
this._send({ |
||||||
|
Id: this.pageData.username, |
||||||
|
Action: action, |
||||||
|
ActionType: actionType, |
||||||
|
Message: message |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
_send(obj) { |
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) { |
||||||
|
this.ws.send(JSON.stringify(obj)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
const game = new Game({ |
||||||
|
canvasId: 'canvas', |
||||||
|
chatInputId: 'msg', |
||||||
|
sendBtnId: 'send', |
||||||
|
}); |
||||||
|
game.start(); |
||||||
|
}); |
||||||
|
|
||||||
|
function rectangleOverlap(a, b) { |
||||||
|
return ( |
||||||
|
a.x < b.x + b.w && |
||||||
|
a.x + a.w > b.x && |
||||||
|
a.y < b.y + b.h && |
||||||
|
a.y + a.h > b.y |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,73 @@ |
|||||||
|
<html> |
||||||
|
|
||||||
|
<head> |
||||||
|
|
||||||
|
<meta name="pagedata" content='{"username": "{{ .Username}}"}' /> |
||||||
|
<style> |
||||||
|
body { |
||||||
|
margin: 0; |
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji"; |
||||||
|
padding: 12px 0 0; |
||||||
|
} |
||||||
|
|
||||||
|
canvas { |
||||||
|
margin: 0 auto; |
||||||
|
width: 1024px; |
||||||
|
display: block; |
||||||
|
border: 1px solid #000; |
||||||
|
} |
||||||
|
|
||||||
|
.chat { |
||||||
|
width: 1024px; |
||||||
|
margin: 20px auto 0; |
||||||
|
border: 1px solid #000; |
||||||
|
padding: 8px; |
||||||
|
box-sizing: border-box; |
||||||
|
display: flex; |
||||||
|
} |
||||||
|
|
||||||
|
#msg { |
||||||
|
display: flex; |
||||||
|
flex-grow: 1; |
||||||
|
margin-right: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
#send { |
||||||
|
height: 30px; |
||||||
|
} |
||||||
|
|
||||||
|
.header { |
||||||
|
width: 1024px; |
||||||
|
margin: 0 auto 12px; |
||||||
|
justify-content: flex-end; |
||||||
|
display: flex; |
||||||
|
} |
||||||
|
|
||||||
|
.login:link, |
||||||
|
.login:visited { |
||||||
|
color: #ffffff; |
||||||
|
background-color: #310f69; |
||||||
|
border-radius: 12px; |
||||||
|
padding: 8px; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
|
||||||
|
<body> |
||||||
|
<div class="header"> |
||||||
|
{{ if eq .Username ""}} |
||||||
|
<a href="/auth/twitch" class="login">Login with Twitch</a> |
||||||
|
{{ else }} |
||||||
|
Logged In as {{ .Username}} |
||||||
|
{{ end }} |
||||||
|
</div> |
||||||
|
<canvas id="canvas" width="1024" height="400"></canvas> |
||||||
|
<div class="chat"> |
||||||
|
<input type="text" id="msg" placeholder="Type in a message to chat" /> |
||||||
|
<button type="button" id="send">Send</button> |
||||||
|
</div> |
||||||
|
<script src="/static/script.js"></script> |
||||||
|
|
||||||
|
</body> |
||||||
|
|
||||||
|
</html> |
||||||
@ -0,0 +1 @@ |
|||||||
|
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 |
||||||
Binary file not shown.
Loading…
Reference in new issue