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)
}