1package main
2
3import (
4 "context"
5 "encoding/json"
6 "log"
7 "math/rand"
8 "net/http"
9 "os"
10 "reflect"
11 "time"
12
13 "github.com/go-chi/chi"
14 "github.com/go-chi/chi/middleware"
15 "github.com/gofrs/uuid"
16 "github.com/jessevdk/go-flags"
17 "github.com/posener/ctxutil"
18 "github.com/tomwright/queryparam/v4"
19 "github.com/zikaeroh/codies/internal/protocol"
20 "github.com/zikaeroh/codies/internal/server"
21 "github.com/zikaeroh/codies/internal/version"
22 "golang.org/x/sync/errgroup"
23 "nhooyr.io/websocket"
24)
25
26var args = struct {
27 Addr string `long:"addr" env:"CODIES_ADDR" description:"Address to listen at"`
28 Origins []string `long:"origins" env:"CODIES_ORIGINS" env-delim:"," description:"Additional valid origins for WebSocket connections"`
29 Debug bool `long:"debug" env:"CODIES_DEBUG" description:"Enables debug mode"`
30}{
31 Addr: ":5000",
32}
33
34func main() {
35 rand.Seed(time.Now().Unix())
36 log.SetFlags(log.LstdFlags | log.Lshortfile)
37
38 if _, err := flags.Parse(&args); err != nil {
39 // Default flag parser prints messages, so just exit.
40 os.Exit(1)
41 }
42
43 log.Printf("starting codies server, version %s", version.Version())
44
45 wsOpts := &websocket.AcceptOptions{
46 OriginPatterns: args.Origins,
47 }
48
49 if args.Debug {
50 log.Println("starting in debug mode, allowing any WebSocket origin host")
51 wsOpts.OriginPatterns = []string{"*"}
52 }
53
54 g, ctx := errgroup.WithContext(ctxutil.Interrupt())
55
56 srv := server.NewServer()
57
58 r := chi.NewMux()
59 r.Use(middleware.Heartbeat("/ping"))
60 r.Use(middleware.Recoverer)
61 r.NotFound(staticRouter().ServeHTTP)
62
63 r.Group(func(r chi.Router) {
64 r.Use(middleware.NoCache)
65
66 r.Get("/api/time", func(w http.ResponseWriter, r *http.Request) {
67 w.Header().Add("Content-Type", "application/json")
68 _ = json.NewEncoder(w).Encode(&protocol.TimeResponse{Time: time.Now()})
69 })
70
71 r.Get("/api/exists", func(w http.ResponseWriter, r *http.Request) {
72 query := &protocol.ExistsQuery{}
73 if err := queryparam.Parse(r.URL.Query(), query); err != nil {
74 httpErr(w, http.StatusBadRequest)
75 return
76 }
77
78 room := srv.FindRoomByID(query.RoomID)
79 if room == nil {
80 w.WriteHeader(http.StatusNotFound)
81 } else {
82 w.WriteHeader(http.StatusOK)
83 }
84
85 _, _ = w.Write([]byte("."))
86 })
87
88 r.Post("/api/room", func(w http.ResponseWriter, r *http.Request) {
89 defer r.Body.Close()
90
91 req := &protocol.RoomRequest{}
92 if err := json.NewDecoder(r.Body).Decode(req); err != nil {
93 httpErr(w, http.StatusBadRequest)
94 return
95 }
96
97 if !req.Valid() {
98 httpErr(w, http.StatusBadRequest)
99 return
100 }
101
102 resp := &protocol.RoomResponse{}
103
104 w.Header().Add("Content-Type", "application/json")
105
106 if req.Create {
107 room, err := srv.CreateRoom(req.RoomName, req.RoomPass)
108 if err != nil {
109 switch err {
110 case server.ErrRoomExists:
111 resp.Error = stringPtr("Room already exists.")
112 w.WriteHeader(http.StatusBadRequest)
113 case server.ErrTooManyRooms:
114 resp.Error = stringPtr("Too many rooms.")
115 w.WriteHeader(http.StatusServiceUnavailable)
116 default:
117 resp.Error = stringPtr("An unknown error occurred.")
118 w.WriteHeader(http.StatusInternalServerError)
119 }
120 } else {
121 resp.ID = &room.ID
122 w.WriteHeader(http.StatusOK)
123 }
124 } else {
125 room := srv.FindRoom(req.RoomName)
126 if room == nil || room.Password != req.RoomPass {
127 resp.Error = stringPtr("Room not found or password does not match.")
128 w.WriteHeader(http.StatusNotFound)
129 } else {
130 resp.ID = &room.ID
131 w.WriteHeader(http.StatusOK)
132 }
133 }
134
135 _ = json.NewEncoder(w).Encode(resp)
136 })
137
138 r.Get("/api/ws", func(w http.ResponseWriter, r *http.Request) {
139 query := &protocol.WSQuery{}
140 if err := queryparam.Parse(r.URL.Query(), query); err != nil {
141 httpErr(w, http.StatusBadRequest)
142 return
143 }
144
145 if !query.Valid() {
146 httpErr(w, http.StatusBadRequest)
147 return
148 }
149
150 room := srv.FindRoomByID(query.RoomID)
151 if room == nil {
152 httpErr(w, http.StatusNotFound)
153 return
154 }
155
156 c, err := websocket.Accept(w, r, wsOpts)
157 if err != nil {
158 log.Println(err)
159 return
160 }
161
162 g.Go(func() error {
163 room.HandleConn(query.PlayerID, query.Nickname, c)
164 return nil
165 })
166 })
167
168 r.Get("/api/stats", func(w http.ResponseWriter, r *http.Request) {
169 rooms, clients := srv.Stats()
170
171 enc := json.NewEncoder(w)
172 enc.SetIndent("", " ")
173 _ = enc.Encode(&protocol.StatsResponse{
174 Rooms: rooms,
175 Clients: clients,
176 })
177 })
178 })
179
180 g.Go(func() error {
181 return srv.Run(ctx)
182 })
183
184 httpSrv := http.Server{Addr: args.Addr, Handler: r}
185
186 g.Go(func() error {
187 <-ctx.Done()
188
189 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
190 defer cancel()
191
192 return httpSrv.Shutdown(ctx)
193 })
194
195 g.Go(func() error {
196 return httpSrv.ListenAndServe()
197 })
198
199 log.Fatal(g.Wait())
200}
201
202func staticRouter() http.Handler {
203 fs := http.Dir("./frontend/build")
204 fsh := http.FileServer(fs)
205
206 r := chi.NewMux()
207 r.Use(middleware.Compress(5))
208
209 r.Handle("/static/*", fsh)
210 r.Handle("/favicon/*", fsh)
211
212 r.Group(func(r chi.Router) {
213 r.Use(middleware.NoCache)
214 r.Handle("/*", fsh)
215 })
216
217 return r
218}
219
220func httpErr(w http.ResponseWriter, code int) {
221 http.Error(w, http.StatusText(code), code)
222}
223
224func stringPtr(s string) *string {
225 return &s
226}
227
228func init() {
229 queryparam.DefaultParser.ValueParsers[reflect.TypeOf(uuid.UUID{})] = func(value string, _ string) (reflect.Value, error) {
230 id, err := uuid.FromString(value)
231 return reflect.ValueOf(id), err
232 }
233}