From 02766918f9bf69aeba38626685a3900ae6bcb84e Mon Sep 17 00:00:00 2001 From: zikaeroh <48577114+zikaeroh@users.noreply.github.com> Date: Sun, 24 May 2020 20:20:48 -0700 Subject: [PATCH] Embed version number, force client refresh on mismatch --- Dockerfile | 13 +- frontend/src/common/index.ts | 5 + frontend/src/components/about.tsx | 5 +- frontend/src/metadata.json | 3 + frontend/src/pages/game.tsx | 15 +- frontend/src/pages/login.tsx | 30 +++- internal/version/version.go | 5 + main.go | 249 ++++++++++++++++++------------ 8 files changed, 220 insertions(+), 105 deletions(-) create mode 100644 frontend/src/metadata.json diff --git a/Dockerfile b/Dockerfile index 6c652fa..783efc6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,19 @@ FROM node:14 AS JS_BUILD +RUN apt-get update && \ + apt-get install -y --no-install-recommends jq moreutils && \ + rm -rf /var/lib/apt/lists/* + WORKDIR /frontend COPY ./frontend/package.json ./frontend/yarn.lock ./ RUN yarn install --frozen-lockfile COPY ./frontend ./ + +# If using environment variables (REACT_APP_*) to pass data in at build-time, +# sometimes the values end up in the library code bundles. This means that changing +# the verison (as below) would invalidate everyone's caches even if the actual code +# didn't change. To avoid this, resort to using a JSON file that is edited before build. +ARG version +RUN jq ".version = \"${version}\"" ./src/metadata.json | sponge ./src/metadata.json RUN yarn build FROM golang:1.14 as GO_BUILD @@ -21,5 +32,5 @@ FROM gcr.io/distroless/base:nonroot WORKDIR /codies COPY --from=GO_BUILD /codies/codies ./codies COPY --from=JS_BUILD /frontend/build ./frontend/build -ENTRYPOINT [ "/codies/codies" ] +ENTRYPOINT [ "/codies/codies", "--prod" ] EXPOSE 5000 diff --git a/frontend/src/common/index.ts b/frontend/src/common/index.ts index f970904..27a81ad 100644 --- a/frontend/src/common/index.ts +++ b/frontend/src/common/index.ts @@ -30,3 +30,8 @@ export function assertIsDefined(val: T): asserts val is NonNullable { } export const nameofFactory = () => (name: keyof T) => name; + +export function reloadOutdatedPage() { + console.log('Frontend version appears to be outdated; reloading to allow the browser to update.'); + window.location.reload(true); +} diff --git a/frontend/src/components/about.tsx b/frontend/src/components/about.tsx index 937213a..54dc185 100644 --- a/frontend/src/components/about.tsx +++ b/frontend/src/components/about.tsx @@ -13,6 +13,8 @@ import { import { Help } from '@material-ui/icons'; import * as React from 'react'; +import { version } from '../metadata.json'; + const useStyles = makeStyles((theme: Theme) => createStyles({ modal: { @@ -80,7 +82,8 @@ export const AboutButton = (props: { style?: React.CSSProperties }) => {

You can find this site's code on{' '} - GitHub. + GitHub. This site is + currently running version {version}.

diff --git a/frontend/src/metadata.json b/frontend/src/metadata.json new file mode 100644 index 0000000..399ea51 --- /dev/null +++ b/frontend/src/metadata.json @@ -0,0 +1,3 @@ +{ + "version": "(devel)" +} diff --git a/frontend/src/pages/game.tsx b/frontend/src/pages/game.tsx index b2015e0..bd0b875 100644 --- a/frontend/src/pages/game.tsx +++ b/frontend/src/pages/game.tsx @@ -3,8 +3,9 @@ import * as React from 'react'; import useWebSocket from 'react-use-websocket'; import { v4 } from 'uuid'; -import { assertIsDefined, assertNever, noop, websocketUrl } from '../common'; +import { assertIsDefined, assertNever, noop, reloadOutdatedPage, websocketUrl } from '../common'; import { useServerTime } from '../hooks/useServerTime'; +import { version } from '../metadata.json'; import { ClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol'; import { GameView, Sender } from './gameView'; import { Loading } from './loading'; @@ -65,12 +66,22 @@ function useWS(roomID: string, playerID: string, nickname: string, dead: () => v const retry = React.useRef(0); return useWebSocket(socketUrl, { - queryParams: { roomID, playerID, nickname }, + // The names here matter; explicitly naming them so that renaming + // these variables doesn't change the actual wire names. + // + // X-CODIES-VERSION would be cleaner, but the WS hook doesn't + // support anything but query params. + queryParams: { roomID: roomID, playerID: playerID, nickname: nickname, codiesVersion: version }, reconnectAttempts, onMessage: () => { retry.current = 0; }, onOpen, + onClose: (e: CloseEvent) => { + if (e.code === 4418) { + reloadOutdatedPage(); + } + }, shouldReconnect: () => { if (didUnmount.current) { return false; diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index ac9b1b9..1ddf03b 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -3,10 +3,19 @@ import isArray from 'lodash/isArray'; import querystring from 'querystring'; import * as React from 'react'; -import { assertIsDefined, isDefined } from '../common'; +import { assertIsDefined, isDefined, reloadOutdatedPage } from '../common'; import { LoginForm, LoginFormData } from '../components/loginForm'; +import { version } from '../metadata.json'; import { RoomResponse } from '../protocol'; +function checkOutdated(response: Response) { + if (response.status === 418) { + reloadOutdatedPage(); + return true; + } + return false; +} + export interface LoginProps { onLogin: (roomID: string, nickname: string) => void; } @@ -69,12 +78,22 @@ export const Login = (props: LoginProps) => { onSubmit={async (d: LoginFormData) => { let id = roomID; + const headers = { + 'X-CODIES-VERSION': version, + }; + if (id) { const query = querystring.stringify({ roomID: id, }); - const response = await fetch('/api/exists?' + query); + const response = await fetch('/api/exists?' + query, { headers }); + + if (checkOutdated(response)) { + return; + } + await response.text(); + if (!response.ok) { setErrorMessage('Room does not exist.'); setRoomID(undefined); @@ -90,7 +109,12 @@ export const Login = (props: LoginProps) => { roomPass: d.roomPass, create: d.create, }); - response = await fetch('/api/room', { method: 'POST', body: reqBody }); + response = await fetch('/api/room', { method: 'POST', body: reqBody, headers }); + + if (checkOutdated(response)) { + return; + } + const body = await response.json(); resp = RoomResponse.parse(body); // eslint-disable-next-line no-empty diff --git a/internal/version/version.go b/internal/version/version.go index e52ebfb..1ec1f39 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -10,3 +10,8 @@ func Version() string { } return version } + +// VersionSet returns true if the verison has been set. +func VersionSet() bool { + return version != "" +} diff --git a/main.go b/main.go index a415e29..591134b 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "log" "math/rand" "net/http" @@ -26,11 +27,14 @@ import ( var args = struct { Addr string `long:"addr" env:"CODIES_ADDR" description:"Address to listen at"` Origins []string `long:"origins" env:"CODIES_ORIGINS" env-delim:"," description:"Additional valid origins for WebSocket connections"` + Prod bool `long:"prod" env:"CODIES_PROD" description:"Enables production mode"` Debug bool `long:"debug" env:"CODIES_DEBUG" description:"Enables debug mode"` }{ Addr: ":5000", } +var wsOpts *websocket.AcceptOptions + func main() { rand.Seed(time.Now().Unix()) log.SetFlags(log.LstdFlags | log.Lshortfile) @@ -40,15 +44,25 @@ func main() { os.Exit(1) } + if !args.Prod && !args.Debug { + log.Fatal("missing required option --prod or --debug") + } else if args.Prod && args.Debug { + log.Fatal("must specify either --prod or --debug") + } + log.Printf("starting codies server, version %s", version.Version()) - wsOpts := &websocket.AcceptOptions{ + wsOpts = &websocket.AcceptOptions{ OriginPatterns: args.Origins, } if args.Debug { log.Println("starting in debug mode, allowing any WebSocket origin host") wsOpts.OriginPatterns = []string{"*"} + } else { + if !version.VersionSet() { + log.Fatal("running production build without version set") + } } g, ctx := errgroup.WithContext(ctxutil.Interrupt()) @@ -68,103 +82,6 @@ func main() { _ = json.NewEncoder(w).Encode(&protocol.TimeResponse{Time: time.Now()}) }) - r.Get("/api/exists", func(w http.ResponseWriter, r *http.Request) { - query := &protocol.ExistsQuery{} - if err := queryparam.Parse(r.URL.Query(), query); err != nil { - httpErr(w, http.StatusBadRequest) - return - } - - room := srv.FindRoomByID(query.RoomID) - if room == nil { - w.WriteHeader(http.StatusNotFound) - } else { - w.WriteHeader(http.StatusOK) - } - - _, _ = w.Write([]byte(".")) - }) - - r.Post("/api/room", func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - - req := &protocol.RoomRequest{} - if err := json.NewDecoder(r.Body).Decode(req); err != nil { - httpErr(w, http.StatusBadRequest) - return - } - - if !req.Valid() { - httpErr(w, http.StatusBadRequest) - return - } - - resp := &protocol.RoomResponse{} - - w.Header().Add("Content-Type", "application/json") - - if req.Create { - room, err := srv.CreateRoom(req.RoomName, req.RoomPass) - if err != nil { - switch err { - case server.ErrRoomExists: - resp.Error = stringPtr("Room already exists.") - w.WriteHeader(http.StatusBadRequest) - case server.ErrTooManyRooms: - resp.Error = stringPtr("Too many rooms.") - w.WriteHeader(http.StatusServiceUnavailable) - default: - resp.Error = stringPtr("An unknown error occurred.") - w.WriteHeader(http.StatusInternalServerError) - } - } else { - resp.ID = &room.ID - w.WriteHeader(http.StatusOK) - } - } else { - room := srv.FindRoom(req.RoomName) - if room == nil || room.Password != req.RoomPass { - resp.Error = stringPtr("Room not found or password does not match.") - w.WriteHeader(http.StatusNotFound) - } else { - resp.ID = &room.ID - w.WriteHeader(http.StatusOK) - } - } - - _ = json.NewEncoder(w).Encode(resp) - }) - - r.Get("/api/ws", func(w http.ResponseWriter, r *http.Request) { - query := &protocol.WSQuery{} - if err := queryparam.Parse(r.URL.Query(), query); err != nil { - httpErr(w, http.StatusBadRequest) - return - } - - if !query.Valid() { - httpErr(w, http.StatusBadRequest) - return - } - - room := srv.FindRoomByID(query.RoomID) - if room == nil { - httpErr(w, http.StatusNotFound) - return - } - - c, err := websocket.Accept(w, r, wsOpts) - if err != nil { - log.Println(err) - return - } - - g.Go(func() error { - room.HandleConn(query.PlayerID, query.Nickname, c) - return nil - }) - }) - r.Get("/api/stats", func(w http.ResponseWriter, r *http.Request) { rooms, clients := srv.Stats() @@ -175,6 +92,109 @@ func main() { Clients: clients, }) }) + + r.Group(func(r chi.Router) { + if !args.Debug { + r.Use(checkVersion) + } + + r.Get("/api/exists", func(w http.ResponseWriter, r *http.Request) { + query := &protocol.ExistsQuery{} + if err := queryparam.Parse(r.URL.Query(), query); err != nil { + httpErr(w, http.StatusBadRequest) + return + } + + room := srv.FindRoomByID(query.RoomID) + if room == nil { + w.WriteHeader(http.StatusNotFound) + } else { + w.WriteHeader(http.StatusOK) + } + + _, _ = w.Write([]byte(".")) + }) + + r.Post("/api/room", func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + req := &protocol.RoomRequest{} + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + httpErr(w, http.StatusBadRequest) + return + } + + if !req.Valid() { + httpErr(w, http.StatusBadRequest) + return + } + + resp := &protocol.RoomResponse{} + + w.Header().Add("Content-Type", "application/json") + + if req.Create { + room, err := srv.CreateRoom(req.RoomName, req.RoomPass) + if err != nil { + switch err { + case server.ErrRoomExists: + resp.Error = stringPtr("Room already exists.") + w.WriteHeader(http.StatusBadRequest) + case server.ErrTooManyRooms: + resp.Error = stringPtr("Too many rooms.") + w.WriteHeader(http.StatusServiceUnavailable) + default: + resp.Error = stringPtr("An unknown error occurred.") + w.WriteHeader(http.StatusInternalServerError) + } + } else { + resp.ID = &room.ID + w.WriteHeader(http.StatusOK) + } + } else { + room := srv.FindRoom(req.RoomName) + if room == nil || room.Password != req.RoomPass { + resp.Error = stringPtr("Room not found or password does not match.") + w.WriteHeader(http.StatusNotFound) + } else { + resp.ID = &room.ID + w.WriteHeader(http.StatusOK) + } + } + + _ = json.NewEncoder(w).Encode(resp) + }) + + r.Get("/api/ws", func(w http.ResponseWriter, r *http.Request) { + query := &protocol.WSQuery{} + if err := queryparam.Parse(r.URL.Query(), query); err != nil { + httpErr(w, http.StatusBadRequest) + return + } + + if !query.Valid() { + httpErr(w, http.StatusBadRequest) + return + } + + room := srv.FindRoomByID(query.RoomID) + if room == nil { + httpErr(w, http.StatusNotFound) + return + } + + c, err := websocket.Accept(w, r, wsOpts) + if err != nil { + log.Println(err) + return + } + + g.Go(func() error { + room.HandleConn(query.PlayerID, query.Nickname, c) + return nil + }) + }) + }) }) g.Go(func() error { @@ -217,6 +237,39 @@ func staticRouter() http.Handler { return r } +func checkVersion(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := version.Version() + + toCheck := []string{ + r.Header.Get("X-CODIES-VERSION"), + r.URL.Query().Get("codiesVersion"), + } + + for _, got := range toCheck { + if got == want { + next.ServeHTTP(w, r) + return + } + } + + reason := fmt.Sprintf("client version too old, please reload to get %s", want) + + if r.Header.Get("Upgrade") == "websocket" { + c, err := websocket.Accept(w, r, wsOpts) + if err != nil { + log.Println(err) + return + } + c.Close(4418, reason) + return + } + + w.WriteHeader(http.StatusTeapot) + fmt.Fprint(w, reason) + }) +} + func httpErr(w http.ResponseWriter, code int) { http.Error(w, http.StatusText(code), code) }