diff options
| -rw-r--r-- | backend/api/generated.go | 323 | ||||
| -rw-r--r-- | backend/api/handlers.go | 46 | ||||
| -rw-r--r-- | backend/auth/jwt.go | 15 | ||||
| -rw-r--r-- | backend/game/client.go | 5 | ||||
| -rw-r--r-- | backend/game/http.go | 22 | ||||
| -rw-r--r-- | backend/game/hub.go | 28 | ||||
| -rw-r--r-- | backend/game/message.go | 12 | ||||
| -rw-r--r-- | backend/game/ws.go | 3 | ||||
| -rw-r--r-- | frontend/app/.server/api/schema.d.ts | 95 | ||||
| -rw-r--r-- | frontend/app/components/GolfPlayApp.tsx | 15 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApp.tsx | 142 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppConnecting.tsx | 3 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppFinished.tsx | 3 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx | 39 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx | 7 | ||||
| -rw-r--r-- | frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx | 3 | ||||
| -rw-r--r-- | frontend/app/routes/golf.$gameId.play.tsx | 49 | ||||
| -rw-r--r-- | frontend/app/routes/golf.$gameId.watch.tsx | 54 | ||||
| -rw-r--r-- | openapi.yaml | 129 |
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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", + "/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: |
