aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--backend/api/generated.go323
-rw-r--r--backend/api/handlers.go46
-rw-r--r--backend/auth/jwt.go15
-rw-r--r--backend/game/client.go5
-rw-r--r--backend/game/http.go22
-rw-r--r--backend/game/hub.go28
-rw-r--r--backend/game/message.go12
-rw-r--r--backend/game/ws.go3
-rw-r--r--frontend/app/.server/api/schema.d.ts95
-rw-r--r--frontend/app/components/GolfPlayApp.tsx15
-rw-r--r--frontend/app/components/GolfWatchApp.tsx142
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx3
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppFinished.tsx3
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx39
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx7
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx3
-rw-r--r--frontend/app/routes/golf.$gameId.play.tsx49
-rw-r--r--frontend/app/routes/golf.$gameId.watch.tsx54
-rw-r--r--openapi.yaml129
19 files changed, 926 insertions, 67 deletions
diff --git a/backend/api/generated.go b/backend/api/generated.go
index e39e3ba..b6563c9 100644
--- a/backend/api/generated.go
+++ b/backend/api/generated.go
@@ -34,7 +34,12 @@ const (
// Defines values for GamePlayerMessageS2CExecResultPayloadStatus.
const (
- Success GamePlayerMessageS2CExecResultPayloadStatus = "success"
+ GamePlayerMessageS2CExecResultPayloadStatusSuccess GamePlayerMessageS2CExecResultPayloadStatus = "success"
+)
+
+// Defines values for GameWatcherMessageS2CExecResultPayloadStatus.
+const (
+ GameWatcherMessageS2CExecResultPayloadStatusSuccess GameWatcherMessageS2CExecResultPayloadStatus = "success"
)
// Game defines model for Game.
@@ -123,6 +128,57 @@ type GamePlayerMessageS2CStartPayload struct {
StartAt int `json:"start_at"`
}
+// GameWatcherMessage defines model for GameWatcherMessage.
+type GameWatcherMessage struct {
+ union json.RawMessage
+}
+
+// GameWatcherMessageS2C defines model for GameWatcherMessageS2C.
+type GameWatcherMessageS2C struct {
+ union json.RawMessage
+}
+
+// GameWatcherMessageS2CCode defines model for GameWatcherMessageS2CCode.
+type GameWatcherMessageS2CCode struct {
+ Data GameWatcherMessageS2CCodePayload `json:"data"`
+ Type string `json:"type"`
+}
+
+// GameWatcherMessageS2CCodePayload defines model for GameWatcherMessageS2CCodePayload.
+type GameWatcherMessageS2CCodePayload struct {
+ Code string `json:"code"`
+ PlayerId int `json:"player_id"`
+}
+
+// GameWatcherMessageS2CExecResult defines model for GameWatcherMessageS2CExecResult.
+type GameWatcherMessageS2CExecResult struct {
+ Data GameWatcherMessageS2CExecResultPayload `json:"data"`
+ Type string `json:"type"`
+}
+
+// GameWatcherMessageS2CExecResultPayload defines model for GameWatcherMessageS2CExecResultPayload.
+type GameWatcherMessageS2CExecResultPayload struct {
+ PlayerId int `json:"player_id"`
+ Score *int `json:"score"`
+ Status GameWatcherMessageS2CExecResultPayloadStatus `json:"status"`
+ Stderr string `json:"stderr"`
+ Stdout string `json:"stdout"`
+}
+
+// GameWatcherMessageS2CExecResultPayloadStatus defines model for GameWatcherMessageS2CExecResultPayload.Status.
+type GameWatcherMessageS2CExecResultPayloadStatus string
+
+// GameWatcherMessageS2CStart defines model for GameWatcherMessageS2CStart.
+type GameWatcherMessageS2CStart struct {
+ Data GameWatcherMessageS2CStartPayload `json:"data"`
+ Type string `json:"type"`
+}
+
+// GameWatcherMessageS2CStartPayload defines model for GameWatcherMessageS2CStartPayload.
+type GameWatcherMessageS2CStartPayload struct {
+ StartAt int `json:"start_at"`
+}
+
// JwtPayload defines model for JwtPayload.
type JwtPayload struct {
DisplayName string `json:"display_name"`
@@ -156,6 +212,11 @@ type PostLoginJSONBody struct {
Username string `json:"username"`
}
+// GetTokenParams defines parameters for GetToken.
+type GetTokenParams struct {
+ Authorization string `json:"Authorization"`
+}
+
// PostLoginJSONRequestBody defines body for PostLogin for application/json ContentType.
type PostLoginJSONRequestBody PostLoginJSONBody
@@ -397,6 +458,130 @@ func (t *GamePlayerMessageS2C) UnmarshalJSON(b []byte) error {
return err
}
+// AsGameWatcherMessageS2C returns the union data inside the GameWatcherMessage as a GameWatcherMessageS2C
+func (t GameWatcherMessage) AsGameWatcherMessageS2C() (GameWatcherMessageS2C, error) {
+ var body GameWatcherMessageS2C
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromGameWatcherMessageS2C overwrites any union data inside the GameWatcherMessage as the provided GameWatcherMessageS2C
+func (t *GameWatcherMessage) FromGameWatcherMessageS2C(v GameWatcherMessageS2C) error {
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeGameWatcherMessageS2C performs a merge with any union data inside the GameWatcherMessage, using the provided GameWatcherMessageS2C
+func (t *GameWatcherMessage) MergeGameWatcherMessageS2C(v GameWatcherMessageS2C) error {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+func (t GameWatcherMessage) MarshalJSON() ([]byte, error) {
+ b, err := t.union.MarshalJSON()
+ return b, err
+}
+
+func (t *GameWatcherMessage) UnmarshalJSON(b []byte) error {
+ err := t.union.UnmarshalJSON(b)
+ return err
+}
+
+// AsGameWatcherMessageS2CStart returns the union data inside the GameWatcherMessageS2C as a GameWatcherMessageS2CStart
+func (t GameWatcherMessageS2C) AsGameWatcherMessageS2CStart() (GameWatcherMessageS2CStart, error) {
+ var body GameWatcherMessageS2CStart
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromGameWatcherMessageS2CStart overwrites any union data inside the GameWatcherMessageS2C as the provided GameWatcherMessageS2CStart
+func (t *GameWatcherMessageS2C) FromGameWatcherMessageS2CStart(v GameWatcherMessageS2CStart) error {
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeGameWatcherMessageS2CStart performs a merge with any union data inside the GameWatcherMessageS2C, using the provided GameWatcherMessageS2CStart
+func (t *GameWatcherMessageS2C) MergeGameWatcherMessageS2CStart(v GameWatcherMessageS2CStart) error {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsGameWatcherMessageS2CCode returns the union data inside the GameWatcherMessageS2C as a GameWatcherMessageS2CCode
+func (t GameWatcherMessageS2C) AsGameWatcherMessageS2CCode() (GameWatcherMessageS2CCode, error) {
+ var body GameWatcherMessageS2CCode
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromGameWatcherMessageS2CCode overwrites any union data inside the GameWatcherMessageS2C as the provided GameWatcherMessageS2CCode
+func (t *GameWatcherMessageS2C) FromGameWatcherMessageS2CCode(v GameWatcherMessageS2CCode) error {
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeGameWatcherMessageS2CCode performs a merge with any union data inside the GameWatcherMessageS2C, using the provided GameWatcherMessageS2CCode
+func (t *GameWatcherMessageS2C) MergeGameWatcherMessageS2CCode(v GameWatcherMessageS2CCode) error {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsGameWatcherMessageS2CExecResult returns the union data inside the GameWatcherMessageS2C as a GameWatcherMessageS2CExecResult
+func (t GameWatcherMessageS2C) AsGameWatcherMessageS2CExecResult() (GameWatcherMessageS2CExecResult, error) {
+ var body GameWatcherMessageS2CExecResult
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromGameWatcherMessageS2CExecResult overwrites any union data inside the GameWatcherMessageS2C as the provided GameWatcherMessageS2CExecResult
+func (t *GameWatcherMessageS2C) FromGameWatcherMessageS2CExecResult(v GameWatcherMessageS2CExecResult) error {
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeGameWatcherMessageS2CExecResult performs a merge with any union data inside the GameWatcherMessageS2C, using the provided GameWatcherMessageS2CExecResult
+func (t *GameWatcherMessageS2C) MergeGameWatcherMessageS2CExecResult(v GameWatcherMessageS2CExecResult) error {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+func (t GameWatcherMessageS2C) MarshalJSON() ([]byte, error) {
+ b, err := t.union.MarshalJSON()
+ return b, err
+}
+
+func (t *GameWatcherMessageS2C) UnmarshalJSON(b []byte) error {
+ err := t.union.UnmarshalJSON(b)
+ return err
+}
+
// ServerInterface represents all server handlers.
type ServerInterface interface {
// List games
@@ -408,6 +593,9 @@ type ServerInterface interface {
// User login
// (POST /login)
PostLogin(ctx echo.Context) error
+ // Get a short-lived access token
+ // (GET /token)
+ GetToken(ctx echo.Context, params GetTokenParams) error
}
// ServerInterfaceWrapper converts echo contexts to parameters.
@@ -499,6 +687,37 @@ func (w *ServerInterfaceWrapper) PostLogin(ctx echo.Context) error {
return err
}
+// GetToken converts echo context to params.
+func (w *ServerInterfaceWrapper) GetToken(ctx echo.Context) error {
+ var err error
+
+ // Parameter object where we will unmarshal all parameters from the context
+ var params GetTokenParams
+
+ headers := ctx.Request().Header
+ // ------------- Required header parameter "Authorization" -------------
+ if valueList, found := headers[http.CanonicalHeaderKey("Authorization")]; found {
+ var Authorization string
+ n := len(valueList)
+ if n != 1 {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for Authorization, got %d", n))
+ }
+
+ err = runtime.BindStyledParameterWithOptions("simple", "Authorization", valueList[0], &Authorization, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true})
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter Authorization: %s", err))
+ }
+
+ params.Authorization = Authorization
+ } else {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Header parameter Authorization is required, but not found"))
+ }
+
+ // Invoke the callback with all the unmarshaled arguments
+ err = w.Handler.GetToken(ctx, params)
+ return err
+}
+
// This is a simple interface which specifies echo.Route addition functions which
// are present on both echo.Echo and echo.Group, since we want to allow using
// either of them for path registration
@@ -530,6 +749,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
router.GET(baseURL+"/games", wrapper.GetGames)
router.GET(baseURL+"/games/:game_id", wrapper.GetGamesGameId)
router.POST(baseURL+"/login", wrapper.PostLogin)
+ router.GET(baseURL+"/token", wrapper.GetToken)
}
@@ -622,6 +842,36 @@ func (response PostLogin401JSONResponse) VisitPostLoginResponse(w http.ResponseW
return json.NewEncoder(w).Encode(response)
}
+type GetTokenRequestObject struct {
+ Params GetTokenParams
+}
+
+type GetTokenResponseObject interface {
+ VisitGetTokenResponse(w http.ResponseWriter) error
+}
+
+type GetToken200JSONResponse struct {
+ Token string `json:"token"`
+}
+
+func (response GetToken200JSONResponse) VisitGetTokenResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetToken403JSONResponse struct {
+ Message string `json:"message"`
+}
+
+func (response GetToken403JSONResponse) VisitGetTokenResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(403)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
// StrictServerInterface represents all server handlers.
type StrictServerInterface interface {
// List games
@@ -633,6 +883,9 @@ type StrictServerInterface interface {
// User login
// (POST /login)
PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error)
+ // Get a short-lived access token
+ // (GET /token)
+ GetToken(ctx context.Context, request GetTokenRequestObject) (GetTokenResponseObject, error)
}
type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc
@@ -727,29 +980,57 @@ func (sh *strictHandler) PostLogin(ctx echo.Context) error {
return nil
}
+// GetToken operation middleware
+func (sh *strictHandler) GetToken(ctx echo.Context, params GetTokenParams) error {
+ var request GetTokenRequestObject
+
+ request.Params = params
+
+ handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
+ return sh.ssi.GetToken(ctx.Request().Context(), request.(GetTokenRequestObject))
+ }
+ for _, middleware := range sh.middlewares {
+ handler = middleware(handler, "GetToken")
+ }
+
+ response, err := handler(ctx, request)
+
+ if err != nil {
+ return err
+ } else if validResponse, ok := response.(GetTokenResponseObject); ok {
+ return validResponse.VisitGetTokenResponse(ctx.Response())
+ } else if response != nil {
+ return fmt.Errorf("unexpected response type: %T", response)
+ }
+ return nil
+}
+
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
- "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",
+ "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",
+ "/wwsoM7zwMcqy7Ls3wAAAP//jcdHSHceAAA=",
}
// GetSwagger returns the content of the embedded swagger specification file
diff --git a/backend/api/handlers.go b/backend/api/handlers.go
index cd8b3b5..c4810a0 100644
--- a/backend/api/handlers.go
+++ b/backend/api/handlers.go
@@ -50,6 +50,17 @@ func (h *ApiHandler) PostLogin(ctx context.Context, request PostLoginRequestObje
}, nil
}
+func (h *ApiHandler) GetToken(ctx context.Context, request GetTokenRequestObject) (GetTokenResponseObject, error) {
+ user := ctx.Value("user").(*auth.JWTClaims)
+ newToken, err := auth.NewShortLivedJWT(user)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ return GetToken200JSONResponse{
+ Token: newToken,
+ }, nil
+}
+
func (h *ApiHandler) GetGames(ctx context.Context, request GetGamesRequestObject) (GetGamesResponseObject, error) {
user := ctx.Value("user").(*auth.JWTClaims)
playerId := request.Params.PlayerId
@@ -179,26 +190,33 @@ func _assertJwtPayloadIsCompatibleWithJWTClaims() {
_ = p
}
+func setupJWTFromAuthorizationHeader(c echo.Context) error {
+ authorization := c.Request().Header.Get("Authorization")
+ const prefix = "Bearer "
+ if !strings.HasPrefix(authorization, prefix) {
+ return echo.NewHTTPError(http.StatusUnauthorized)
+ }
+ token := authorization[len(prefix):]
+ claims, err := auth.ParseJWT(token)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusUnauthorized, err.Error())
+ }
+ c.SetRequest(c.Request().WithContext(context.WithValue(c.Request().Context(), "user", claims)))
+ return nil
+}
+
func NewJWTMiddleware() StrictMiddlewareFunc {
return func(handler StrictHandlerFunc, operationID string) StrictHandlerFunc {
if operationID == "PostLogin" {
return handler
- } else {
- return func(c echo.Context, request interface{}) (response interface{}, err error) {
- authorization := c.Request().Header.Get("Authorization")
- const prefix = "Bearer "
- if !strings.HasPrefix(authorization, prefix) {
- return nil, echo.NewHTTPError(http.StatusUnauthorized)
- }
- token := authorization[len(prefix):]
+ }
- claims, err := auth.ParseJWT(token)
- if err != nil {
- return nil, echo.NewHTTPError(http.StatusUnauthorized)
- }
- c.SetRequest(c.Request().WithContext(context.WithValue(c.Request().Context(), "user", claims)))
- return handler(c, request)
+ return func(c echo.Context, request interface{}) (interface{}, error) {
+ err := setupJWTFromAuthorizationHeader(c)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusUnauthorized, err.Error())
}
+ return handler(c, request)
}
}
}
diff --git a/backend/auth/jwt.go b/backend/auth/jwt.go
index 0b92155..b7abc68 100644
--- a/backend/auth/jwt.go
+++ b/backend/auth/jwt.go
@@ -33,6 +33,21 @@ func NewJWT(user *db.User) (string, error) {
return token.SignedString([]byte("TODO"))
}
+func NewShortLivedJWT(claims *JWTClaims) (string, error) {
+ newClaims := &JWTClaims{
+ UserID: claims.UserID,
+ Username: claims.Username,
+ DisplayName: claims.DisplayName,
+ IconPath: claims.IconPath,
+ IsAdmin: claims.IsAdmin,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute * 5)),
+ },
+ }
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
+ return token.SignedString([]byte("TODO"))
+}
+
func ParseJWT(token string) (*JWTClaims, error) {
claims := new(JWTClaims)
t, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
diff --git a/backend/game/client.go b/backend/game/client.go
index 7cd66a4..fa699ce 100644
--- a/backend/game/client.go
+++ b/backend/game/client.go
@@ -12,12 +12,13 @@ type playerClient struct {
hub *gameHub
conn *websocket.Conn
s2cMessages chan playerMessageS2C
+ playerID int
}
// Receives messages from the client and sends them to the hub.
func (c *playerClient) readPump() {
defer func() {
- log.Printf("closing client")
+ log.Printf("closing player client")
c.hub.unregisterPlayer <- c
c.conn.Close()
}()
@@ -87,7 +88,7 @@ func (c *watcherClient) writePump() {
defer func() {
ticker.Stop()
c.conn.Close()
- log.Printf("closing watcher")
+ log.Printf("closing watcher client")
c.hub.unregisterWatcher <- c
}()
for {
diff --git a/backend/game/http.go b/backend/game/http.go
index beda46c..8cf7322 100644
--- a/backend/game/http.go
+++ b/backend/game/http.go
@@ -5,6 +5,8 @@ import (
"strconv"
"github.com/labstack/echo/v4"
+
+ "github.com/nsfisis/iosdc-2024-albatross/backend/auth"
)
type sockHandler struct {
@@ -18,7 +20,13 @@ func newSockHandler(hubs *GameHubs) *sockHandler {
}
func (h *sockHandler) HandleSockGolfPlay(c echo.Context) error {
- // TODO: auth
+ jwt := c.QueryParam("token")
+ claims, err := auth.ParseJWT(jwt)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusUnauthorized, err.Error())
+ }
+ // TODO: check user permission
+
gameId := c.Param("gameId")
gameIdInt, err := strconv.Atoi(gameId)
if err != nil {
@@ -34,11 +42,19 @@ func (h *sockHandler) HandleSockGolfPlay(c echo.Context) error {
if foundHub == nil {
return echo.NewHTTPError(http.StatusNotFound, "Game not found")
}
- return servePlayerWs(foundHub, c.Response(), c.Request(), "a")
+ return servePlayerWs(foundHub, c.Response(), c.Request(), claims.UserID)
}
func (h *sockHandler) HandleSockGolfWatch(c echo.Context) error {
- // TODO: auth
+ jwt := c.QueryParam("token")
+ claims, err := auth.ParseJWT(jwt)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusUnauthorized, err.Error())
+ }
+ if !claims.IsAdmin {
+ return echo.NewHTTPError(http.StatusForbidden, "Permission denied")
+ }
+
gameId := c.Param("gameId")
gameIdInt, err := strconv.Atoi(gameId)
if err != nil {
diff --git a/backend/game/hub.go b/backend/game/hub.go
index d4a9231..770d257 100644
--- a/backend/game/hub.go
+++ b/backend/game/hub.go
@@ -121,6 +121,14 @@ func (hub *gameHub) run() {
},
}
}
+ for watcher := range hub.watchers {
+ watcher.s2cMessages <- &watcherMessageS2CStart{
+ Type: watcherMessageTypeS2CStart,
+ Data: watcherMessageS2CStartPayload{
+ StartAt: int(startAt.Unix()),
+ },
+ }
+ }
err := hub.q.UpdateGameStartedAt(hub.ctx, db.UpdateGameStartedAtParams{
GameID: int32(hub.game.gameID),
StartedAt: pgtype.Timestamp{
@@ -151,9 +159,27 @@ func (hub *gameHub) run() {
Type: playerMessageTypeS2CExecResult,
Data: playerMessageS2CExecResultPayload{
Score: &score,
- Status: api.Success,
+ Status: api.GamePlayerMessageS2CExecResultPayloadStatusSuccess,
},
}
+ for watcher := range hub.watchers {
+ watcher.s2cMessages <- &watcherMessageS2CCode{
+ Type: watcherMessageTypeS2CCode,
+ Data: watcherMessageS2CCodePayload{
+ PlayerId: message.client.playerID,
+ Code: code,
+ },
+ }
+ watcher.s2cMessages <- &watcherMessageS2CExecResult{
+ Type: watcherMessageTypeS2CExecResult,
+ Data: watcherMessageS2CExecResultPayload{
+ PlayerId: message.client.playerID,
+ Score: &score,
+ Stdout: "",
+ Stderr: "",
+ },
+ }
+ }
default:
log.Fatalf("unexpected message type: %T", message.message)
}
diff --git a/backend/game/message.go b/backend/game/message.go
index 9116bde..bb57eb5 100644
--- a/backend/game/message.go
+++ b/backend/game/message.go
@@ -66,4 +66,16 @@ func asPlayerMessageC2S(raw map[string]json.RawMessage) (playerMessageC2S, error
}
}
+const (
+ watcherMessageTypeS2CStart = "watcher:s2c:start"
+ watcherMessageTypeS2CExecResult = "watcher:s2c:execreslut"
+ watcherMessageTypeS2CCode = "watcher:s2c:code"
+)
+
type watcherMessageS2C = interface{}
+type watcherMessageS2CStart = api.GameWatcherMessageS2CStart
+type watcherMessageS2CStartPayload = api.GameWatcherMessageS2CStartPayload
+type watcherMessageS2CCode = api.GameWatcherMessageS2CCode
+type watcherMessageS2CCodePayload = api.GameWatcherMessageS2CCodePayload
+type watcherMessageS2CExecResult = api.GameWatcherMessageS2CExecResult
+type watcherMessageS2CExecResultPayload = api.GameWatcherMessageS2CExecResultPayload
diff --git a/backend/game/ws.go b/backend/game/ws.go
index 013db7a..9a3956b 100644
--- a/backend/game/ws.go
+++ b/backend/game/ws.go
@@ -23,7 +23,7 @@ var upgrader = websocket.Upgrader{
},
}
-func servePlayerWs(hub *gameHub, w http.ResponseWriter, r *http.Request, team string) error {
+func servePlayerWs(hub *gameHub, w http.ResponseWriter, r *http.Request, playerID int) error {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return err
@@ -32,6 +32,7 @@ func servePlayerWs(hub *gameHub, w http.ResponseWriter, r *http.Request, team st
hub: hub,
conn: conn,
s2cMessages: make(chan playerMessageS2C),
+ playerID: playerID,
}
hub.registerPlayer <- player
diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts
index 40a3347..cd409f7 100644
--- a/frontend/app/.server/api/schema.d.ts
+++ b/frontend/app/.server/api/schema.d.ts
@@ -64,6 +64,59 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/token": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get a short-lived access token */
+ get: {
+ parameters: {
+ query?: never;
+ header: {
+ Authorization: string;
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successfully authenticated */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @example xxxxx.xxxxx.xxxxx */
+ token: string;
+ };
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @example Forbidden operation */
+ message: string;
+ };
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/games": {
parameters: {
query?: never;
@@ -261,6 +314,48 @@ export interface components {
/** @example print('Hello, world!') */
code: string;
};
+ GameWatcherMessage: components["schemas"]["GameWatcherMessageS2C"];
+ GameWatcherMessageS2C: components["schemas"]["GameWatcherMessageS2CStart"] | components["schemas"]["GameWatcherMessageS2CCode"] | components["schemas"]["GameWatcherMessageS2CExecResult"];
+ GameWatcherMessageS2CStart: {
+ /** @constant */
+ type: "watcher:s2c:start";
+ data: components["schemas"]["GameWatcherMessageS2CStartPayload"];
+ };
+ GameWatcherMessageS2CStartPayload: {
+ /** @example 946684800 */
+ start_at: number;
+ };
+ GameWatcherMessageS2CCode: {
+ /** @constant */
+ type: "watcher:s2c:code";
+ data: components["schemas"]["GameWatcherMessageS2CCodePayload"];
+ };
+ GameWatcherMessageS2CCodePayload: {
+ /** @example 1 */
+ player_id: number;
+ /** @example print('Hello, world!') */
+ code: string;
+ };
+ GameWatcherMessageS2CExecResult: {
+ /** @constant */
+ type: "watcher:s2c:execresult";
+ data: components["schemas"]["GameWatcherMessageS2CExecResultPayload"];
+ };
+ GameWatcherMessageS2CExecResultPayload: {
+ /** @example 1 */
+ player_id: number;
+ /**
+ * @example success
+ * @enum {string}
+ */
+ status: "success";
+ /** @example 100 */
+ score: number | null;
+ /** @example Hello, world! */
+ stdout: string;
+ /** @example */
+ stderr: string;
+ };
};
responses: never;
parameters: never;
diff --git a/frontend/app/components/GolfPlayApp.tsx b/frontend/app/components/GolfPlayApp.tsx
index c6c20d4..3cb512a 100644
--- a/frontend/app/components/GolfPlayApp.tsx
+++ b/frontend/app/components/GolfPlayApp.tsx
@@ -15,12 +15,18 @@ type Problem = components["schemas"]["Problem"];
type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished";
-export default function GolfPlayApp({ game }: { game: Game }) {
- // const socketUrl = `wss://t.nil.ninja/iosdc/2024/sock/golf/${game.game_id}/play`;
+export default function GolfPlayApp({
+ game,
+ sockToken,
+}: {
+ game: Game;
+ sockToken: string;
+}) {
+ // const socketUrl = `wss://t.nil.ninja/iosdc/2024/sock/golf/${game.game_id}/play?token=${sockToken}`;
const socketUrl =
process.env.NODE_ENV === "development"
- ? `ws://localhost:8002/sock/golf/${game.game_id}/play`
- : `ws://api-server/sock/golf/${game.game_id}/play`;
+ ? `ws://localhost:8002/sock/golf/${game.game_id}/play?token=${sockToken}`
+ : `ws://api-server/sock/golf/${game.game_id}/play?token=${sockToken}`;
const { sendJsonMessage, lastJsonMessage, readyState } =
useWebSocket<WebSocketMessage>(socketUrl, {});
@@ -66,7 +72,6 @@ export default function GolfPlayApp({ game }: { game: Game }) {
}, [gameState, startedAt, game.duration_seconds]);
const [currentScore, setCurrentScore] = useState<number | null>(null);
- void setCurrentScore;
const onCodeChange = useDebouncedCallback((code: string) => {
console.log("player:c2s:code");
diff --git a/frontend/app/components/GolfWatchApp.tsx b/frontend/app/components/GolfWatchApp.tsx
new file mode 100644
index 0000000..00ad005
--- /dev/null
+++ b/frontend/app/components/GolfWatchApp.tsx
@@ -0,0 +1,142 @@
+import type { components } from "../.server/api/schema";
+import { useState, useEffect } from "react";
+import useWebSocket, { ReadyState } from "react-use-websocket";
+import GolfWatchAppConnecting from "./GolfWatchApps/GolfWatchAppConnecting";
+import GolfWatchAppWaiting from "./GolfWatchApps/GolfWatchAppWaiting";
+import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting";
+import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming";
+import GolfWatchAppFinished from "./GolfWatchApps/GolfWatchAppFinished";
+
+type WebSocketMessage = components["schemas"]["GameWatcherMessageS2C"];
+
+type Game = components["schemas"]["Game"];
+type Problem = components["schemas"]["Problem"];
+
+type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished";
+
+export default function GolfWatchApp({
+ game,
+ sockToken,
+}: {
+ game: Game;
+ sockToken: string;
+}) {
+ // const socketUrl = `wss://t.nil.ninja/iosdc/2024/sock/golf/${game.game_id}/watch?token=${sockToken}`;
+ const socketUrl =
+ process.env.NODE_ENV === "development"
+ ? `ws://localhost:8002/sock/golf/${game.game_id}/watch?token=${sockToken}`
+ : `ws://api-server/sock/golf/${game.game_id}/watch?token=${sockToken}`;
+
+ const { lastJsonMessage, readyState } = useWebSocket<WebSocketMessage>(
+ socketUrl,
+ {},
+ );
+
+ const [gameState, setGameState] = useState<GameState>("connecting");
+
+ const [problem, setProblem] = useState<Problem | null>(null);
+
+ const [startedAt, setStartedAt] = useState<number | null>(null);
+
+ const [timeLeftSeconds, setTimeLeftSeconds] = useState<number | null>(null);
+
+ useEffect(() => {
+ if (gameState === "starting" && startedAt !== null) {
+ const timer1 = setInterval(() => {
+ setTimeLeftSeconds((prev) => {
+ if (prev === null) {
+ return null;
+ }
+ if (prev <= 1) {
+ clearInterval(timer1);
+ setGameState("gaming");
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+
+ const timer2 = setInterval(() => {
+ const nowSec = Math.floor(Date.now() / 1000);
+ const finishedAt = startedAt + game.duration_seconds;
+ if (nowSec >= finishedAt) {
+ clearInterval(timer2);
+ setGameState("finished");
+ }
+ }, 1000);
+
+ return () => {
+ clearInterval(timer1);
+ clearInterval(timer2);
+ };
+ }
+ }, [gameState, startedAt, game.duration_seconds]);
+
+ const [scoreA, setScoreA] = useState<number | null>(null);
+ const [scoreB, setScoreB] = useState<number | null>(null);
+ const [codeA, setCodeA] = useState<string>("");
+ const [codeB, setCodeB] = useState<string>("");
+
+ if (readyState === ReadyState.UNINSTANTIATED) {
+ throw new Error("WebSocket is not connected");
+ }
+
+ useEffect(() => {
+ if (readyState === ReadyState.CLOSING || readyState === ReadyState.CLOSED) {
+ if (gameState !== "finished") {
+ setGameState("connecting");
+ }
+ } else if (readyState === ReadyState.CONNECTING) {
+ setGameState("connecting");
+ } else if (readyState === ReadyState.OPEN) {
+ if (lastJsonMessage !== null) {
+ console.log(lastJsonMessage.type);
+ if (lastJsonMessage.type === "watcher:s2c:start") {
+ if (
+ gameState !== "starting" &&
+ gameState !== "gaming" &&
+ gameState !== "finished"
+ ) {
+ const { start_at } = lastJsonMessage.data;
+ setStartedAt(start_at);
+ const nowSec = Math.floor(Date.now() / 1000);
+ setTimeLeftSeconds(start_at - nowSec);
+ setGameState("starting");
+ }
+ } else if (lastJsonMessage.type === "watcher:s2c:code") {
+ const { player_id, code } = lastJsonMessage.data;
+ setCodeA(code);
+ } else if (lastJsonMessage.type === "watcher:s2c:execresult") {
+ const { score } = lastJsonMessage.data;
+ if (score !== null && (scoreA === null || score < scoreA)) {
+ setScoreA(score);
+ }
+ }
+ } else {
+ setGameState("waiting");
+ }
+ }
+ }, [lastJsonMessage, readyState, gameState, scoreA]);
+
+ if (gameState === "connecting") {
+ return <GolfWatchAppConnecting />;
+ } else if (gameState === "waiting") {
+ return <GolfWatchAppWaiting />;
+ } else if (gameState === "starting") {
+ return <GolfWatchAppStarting timeLeft={timeLeftSeconds!} />;
+ } else if (gameState === "gaming") {
+ return (
+ <GolfWatchAppGaming
+ problem={problem!.description}
+ codeA={codeA}
+ scoreA={scoreA}
+ codeB={codeB}
+ scoreB={scoreB}
+ />
+ );
+ } else if (gameState === "finished") {
+ return <GolfWatchAppFinished />;
+ } else {
+ return null;
+ }
+}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx
new file mode 100644
index 0000000..07e359f
--- /dev/null
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx
@@ -0,0 +1,3 @@
+export default function GolfWatchAppConnecting() {
+ return <div>Connecting...</div>;
+}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppFinished.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppFinished.tsx
new file mode 100644
index 0000000..330d1a6
--- /dev/null
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppFinished.tsx
@@ -0,0 +1,3 @@
+export default function GolfWatchAppFinished() {
+ return <div>Finished</div>;
+}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
new file mode 100644
index 0000000..d58a04f
--- /dev/null
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
@@ -0,0 +1,39 @@
+export default function GolfWatchAppGaming({
+ problem,
+ codeA,
+ scoreA,
+ codeB,
+ scoreB,
+}: {
+ problem: string;
+ codeA: string;
+ scoreA: number | null;
+ codeB: string;
+ scoreB: number | null;
+}) {
+ return (
+ <div style={{ display: "flex", flexDirection: "column" }}>
+ <div style={{ display: "flex", flex: 1, justifyContent: "center" }}>
+ <div>{problem}</div>
+ </div>
+ <div style={{ display: "flex", flex: 3 }}>
+ <div style={{ display: "flex", flex: 3, flexDirection: "column" }}>
+ <div style={{ flex: 1, justifyContent: "center" }}>{scoreA}</div>
+ <div style={{ flex: 3 }}>
+ <pre>
+ <code>{codeA}</code>
+ </pre>
+ </div>
+ </div>
+ <div style={{ display: "flex", flex: 3, flexDirection: "column" }}>
+ <div style={{ flex: 1, justifyContent: "center" }}>{scoreB}</div>
+ <div style={{ flex: 3 }}>
+ <pre>
+ <code>{codeB}</code>
+ </pre>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx
new file mode 100644
index 0000000..643af93
--- /dev/null
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx
@@ -0,0 +1,7 @@
+export default function GolfWatchAppStarting({
+ timeLeft,
+}: {
+ timeLeft: number;
+}) {
+ return <div>Starting... ({timeLeft} s)</div>;
+}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx
new file mode 100644
index 0000000..6733b3b
--- /dev/null
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx
@@ -0,0 +1,3 @@
+export default function GolfWatchAppWaiting() {
+ return <div>Waiting...</div>;
+}
diff --git a/frontend/app/routes/golf.$gameId.play.tsx b/frontend/app/routes/golf.$gameId.play.tsx
index bda563f..78ce585 100644
--- a/frontend/app/routes/golf.$gameId.play.tsx
+++ b/frontend/app/routes/golf.$gameId.play.tsx
@@ -8,26 +8,47 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const { token } = await isAuthenticated(request, {
failureRedirect: "/login",
});
- const { data, error } = await apiClient.GET("/games/{game_id}", {
- params: {
- path: {
- game_id: Number(params.gameId),
+
+ const fetchGame = async () => {
+ const { data, error } = await apiClient.GET("/games/{game_id}", {
+ params: {
+ path: {
+ game_id: Number(params.gameId),
+ },
+ header: {
+ Authorization: `Bearer ${token}`,
+ },
},
- header: {
- Authorization: `Bearer ${token}`,
+ });
+ if (error) {
+ throw new Error(error.message);
+ }
+ return data;
+ };
+
+ const fetchSockToken = async () => {
+ const { data, error } = await apiClient.GET("/token", {
+ params: {
+ header: {
+ Authorization: `Bearer ${token}`,
+ },
},
- },
- });
- if (error) {
- throw new Error(error.message);
- }
+ });
+ if (error) {
+ throw new Error(error.message);
+ }
+ return data.token;
+ };
+
+ const [game, sockToken] = await Promise.all([fetchGame(), fetchSockToken()]);
return {
- game: data,
+ game,
+ sockToken,
};
}
export default function GolfPlay() {
- const { game } = useLoaderData<typeof loader>();
+ const { game, sockToken } = useLoaderData<typeof loader>();
- return <GolfPlayApp game={game} />;
+ return <GolfPlayApp game={game} sockToken={sockToken} />;
}
diff --git a/frontend/app/routes/golf.$gameId.watch.tsx b/frontend/app/routes/golf.$gameId.watch.tsx
new file mode 100644
index 0000000..28c17cc
--- /dev/null
+++ b/frontend/app/routes/golf.$gameId.watch.tsx
@@ -0,0 +1,54 @@
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { isAuthenticated } from "../.server/auth";
+import { apiClient } from "../.server/api/client";
+import { useLoaderData } from "@remix-run/react";
+import GolfWatchApp from "../components/GolfWatchApp";
+
+export async function loader({ params, request }: LoaderFunctionArgs) {
+ const { token } = await isAuthenticated(request, {
+ failureRedirect: "/login",
+ });
+
+ const fetchGame = async () => {
+ const { data, error } = await apiClient.GET("/games/{game_id}", {
+ params: {
+ path: {
+ game_id: Number(params.gameId),
+ },
+ header: {
+ Authorization: `Bearer ${token}`,
+ },
+ },
+ });
+ if (error) {
+ throw new Error(error.message);
+ }
+ return data;
+ };
+
+ const fetchSockToken = async () => {
+ const { data, error } = await apiClient.GET("/token", {
+ params: {
+ header: {
+ Authorization: `Bearer ${token}`,
+ },
+ },
+ });
+ if (error) {
+ throw new Error(error.message);
+ }
+ return data.token;
+ };
+
+ const [game, sockToken] = await Promise.all([fetchGame(), fetchSockToken()]);
+ return {
+ game,
+ sockToken,
+ };
+}
+
+export default function GolfWatch() {
+ const { game, sockToken } = useLoaderData<typeof loader>();
+
+ return <GolfWatchApp game={game} sockToken={sockToken} />;
+}
diff --git a/openapi.yaml b/openapi.yaml
index d058381..739f013 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -47,6 +47,40 @@ paths:
example: "Invalid credentials"
required:
- message
+ /token:
+ get:
+ summary: Get a short-lived access token
+ parameters:
+ - in: header
+ name: Authorization
+ schema:
+ type: string
+ required: true
+ responses:
+ '200':
+ description: Successfully authenticated
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ token:
+ type: string
+ example: "xxxxx.xxxxx.xxxxx"
+ required:
+ - token
+ '403':
+ description: Forbidden
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ example: "Forbidden operation"
+ required:
+ - message
/games:
get:
summary: List games
@@ -306,11 +340,94 @@ components:
example: "print('Hello, world!')"
required:
- code
- # GameWatcherMessage:
- # oneOf:
- # - $ref: '#/components/schemas/GameWatcherMessageS2C'
- # - $ref: '#/components/schemas/GameWatcherMessageC2S'
- # GameWatcherMessageS2C:
- # oneOf:
+ GameWatcherMessage:
+ oneOf:
+ - $ref: '#/components/schemas/GameWatcherMessageS2C'
+ # - $ref: '#/components/schemas/GameWatcherMessageC2S'
+ GameWatcherMessageS2C:
+ oneOf:
+ - $ref: '#/components/schemas/GameWatcherMessageS2CStart'
+ - $ref: '#/components/schemas/GameWatcherMessageS2CCode'
+ - $ref: '#/components/schemas/GameWatcherMessageS2CExecResult'
+ GameWatcherMessageS2CStart:
+ type: object
+ properties:
+ type:
+ type: string
+ const: "watcher:s2c:start"
+ data:
+ $ref: '#/components/schemas/GameWatcherMessageS2CStartPayload'
+ required:
+ - type
+ - data
+ GameWatcherMessageS2CStartPayload:
+ type: object
+ properties:
+ start_at:
+ type: integer
+ example: 946684800
+ required:
+ - start_at
+ GameWatcherMessageS2CCode:
+ type: object
+ properties:
+ type:
+ type: string
+ const: "watcher:s2c:code"
+ data:
+ $ref: '#/components/schemas/GameWatcherMessageS2CCodePayload'
+ required:
+ - type
+ - data
+ GameWatcherMessageS2CCodePayload:
+ type: object
+ properties:
+ player_id:
+ type: integer
+ example: 1
+ code:
+ type: string
+ example: "print('Hello, world!')"
+ required:
+ - player_id
+ - code
+ GameWatcherMessageS2CExecResult:
+ type: object
+ properties:
+ type:
+ type: string
+ const: "watcher:s2c:execresult"
+ data:
+ $ref: '#/components/schemas/GameWatcherMessageS2CExecResultPayload'
+ required:
+ - type
+ - data
+ GameWatcherMessageS2CExecResultPayload:
+ type: object
+ properties:
+ player_id:
+ type: integer
+ example: 1
+ status:
+ type: string
+ example: "success"
+ enum:
+ - success
+ score:
+ type: integer
+ nullable: true
+ example: 100
+ stdout:
+ type: string
+ example: "Hello, world!"
+ stderr:
+ type: string
+ example: ""
+ required:
+ - player_id
+ - status
+ - score
+ - stdout
+ - stderr
# GameWatcherMessageC2S:
# oneOf: