Embed version number, force client refresh on mismatch

This commit is contained in:
zikaeroh 2020-05-24 20:20:48 -07:00
parent 987b6a9dfc
commit 02766918f9
8 changed files with 220 additions and 105 deletions

View File

@ -1,8 +1,19 @@
FROM node:14 AS JS_BUILD 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 WORKDIR /frontend
COPY ./frontend/package.json ./frontend/yarn.lock ./ COPY ./frontend/package.json ./frontend/yarn.lock ./
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
COPY ./frontend ./ 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 RUN yarn build
FROM golang:1.14 as GO_BUILD FROM golang:1.14 as GO_BUILD
@ -21,5 +32,5 @@ FROM gcr.io/distroless/base:nonroot
WORKDIR /codies WORKDIR /codies
COPY --from=GO_BUILD /codies/codies ./codies COPY --from=GO_BUILD /codies/codies ./codies
COPY --from=JS_BUILD /frontend/build ./frontend/build COPY --from=JS_BUILD /frontend/build ./frontend/build
ENTRYPOINT [ "/codies/codies" ] ENTRYPOINT [ "/codies/codies", "--prod" ]
EXPOSE 5000 EXPOSE 5000

View File

@ -30,3 +30,8 @@ export function assertIsDefined<T>(val: T): asserts val is NonNullable<T> {
} }
export const nameofFactory = <T>() => (name: keyof T) => name; export const nameofFactory = <T>() => (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);
}

View File

@ -13,6 +13,8 @@ import {
import { Help } from '@material-ui/icons'; import { Help } from '@material-ui/icons';
import * as React from 'react'; import * as React from 'react';
import { version } from '../metadata.json';
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
modal: { modal: {
@ -80,7 +82,8 @@ export const AboutButton = (props: { style?: React.CSSProperties }) => {
</p> </p>
<p> <p>
You can find this site&apos;s code on{' '} You can find this site&apos;s code on{' '}
<NewPageLink href="https://github.com/zikaeroh/codies">GitHub</NewPageLink>. <NewPageLink href="https://github.com/zikaeroh/codies">GitHub</NewPageLink>. This site is
currently running version {version}.
</p> </p>
</Paper> </Paper>
</Fade> </Fade>

View File

@ -0,0 +1,3 @@
{
"version": "(devel)"
}

View File

@ -3,8 +3,9 @@ import * as React from 'react';
import useWebSocket from 'react-use-websocket'; import useWebSocket from 'react-use-websocket';
import { v4 } from 'uuid'; 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 { useServerTime } from '../hooks/useServerTime';
import { version } from '../metadata.json';
import { ClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol'; import { ClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol';
import { GameView, Sender } from './gameView'; import { GameView, Sender } from './gameView';
import { Loading } from './loading'; import { Loading } from './loading';
@ -65,12 +66,22 @@ function useWS(roomID: string, playerID: string, nickname: string, dead: () => v
const retry = React.useRef(0); const retry = React.useRef(0);
return useWebSocket(socketUrl, { 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, reconnectAttempts,
onMessage: () => { onMessage: () => {
retry.current = 0; retry.current = 0;
}, },
onOpen, onOpen,
onClose: (e: CloseEvent) => {
if (e.code === 4418) {
reloadOutdatedPage();
}
},
shouldReconnect: () => { shouldReconnect: () => {
if (didUnmount.current) { if (didUnmount.current) {
return false; return false;

View File

@ -3,10 +3,19 @@ import isArray from 'lodash/isArray';
import querystring from 'querystring'; import querystring from 'querystring';
import * as React from 'react'; import * as React from 'react';
import { assertIsDefined, isDefined } from '../common'; import { assertIsDefined, isDefined, reloadOutdatedPage } from '../common';
import { LoginForm, LoginFormData } from '../components/loginForm'; import { LoginForm, LoginFormData } from '../components/loginForm';
import { version } from '../metadata.json';
import { RoomResponse } from '../protocol'; import { RoomResponse } from '../protocol';
function checkOutdated(response: Response) {
if (response.status === 418) {
reloadOutdatedPage();
return true;
}
return false;
}
export interface LoginProps { export interface LoginProps {
onLogin: (roomID: string, nickname: string) => void; onLogin: (roomID: string, nickname: string) => void;
} }
@ -69,12 +78,22 @@ export const Login = (props: LoginProps) => {
onSubmit={async (d: LoginFormData) => { onSubmit={async (d: LoginFormData) => {
let id = roomID; let id = roomID;
const headers = {
'X-CODIES-VERSION': version,
};
if (id) { if (id) {
const query = querystring.stringify({ const query = querystring.stringify({
roomID: id, roomID: id,
}); });
const response = await fetch('/api/exists?' + query); const response = await fetch('/api/exists?' + query, { headers });
if (checkOutdated(response)) {
return;
}
await response.text(); await response.text();
if (!response.ok) { if (!response.ok) {
setErrorMessage('Room does not exist.'); setErrorMessage('Room does not exist.');
setRoomID(undefined); setRoomID(undefined);
@ -90,7 +109,12 @@ export const Login = (props: LoginProps) => {
roomPass: d.roomPass, roomPass: d.roomPass,
create: d.create, 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(); const body = await response.json();
resp = RoomResponse.parse(body); resp = RoomResponse.parse(body);
// eslint-disable-next-line no-empty // eslint-disable-next-line no-empty

View File

@ -10,3 +10,8 @@ func Version() string {
} }
return version return version
} }
// VersionSet returns true if the verison has been set.
func VersionSet() bool {
return version != ""
}

249
main.go
View File

@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"math/rand" "math/rand"
"net/http" "net/http"
@ -26,11 +27,14 @@ import (
var args = struct { var args = struct {
Addr string `long:"addr" env:"CODIES_ADDR" description:"Address to listen at"` 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"` 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"` Debug bool `long:"debug" env:"CODIES_DEBUG" description:"Enables debug mode"`
}{ }{
Addr: ":5000", Addr: ":5000",
} }
var wsOpts *websocket.AcceptOptions
func main() { func main() {
rand.Seed(time.Now().Unix()) rand.Seed(time.Now().Unix())
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
@ -40,15 +44,25 @@ func main() {
os.Exit(1) 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()) log.Printf("starting codies server, version %s", version.Version())
wsOpts := &websocket.AcceptOptions{ wsOpts = &websocket.AcceptOptions{
OriginPatterns: args.Origins, OriginPatterns: args.Origins,
} }
if args.Debug { if args.Debug {
log.Println("starting in debug mode, allowing any WebSocket origin host") log.Println("starting in debug mode, allowing any WebSocket origin host")
wsOpts.OriginPatterns = []string{"*"} wsOpts.OriginPatterns = []string{"*"}
} else {
if !version.VersionSet() {
log.Fatal("running production build without version set")
}
} }
g, ctx := errgroup.WithContext(ctxutil.Interrupt()) g, ctx := errgroup.WithContext(ctxutil.Interrupt())
@ -68,103 +82,6 @@ func main() {
_ = json.NewEncoder(w).Encode(&protocol.TimeResponse{Time: time.Now()}) _ = 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) { r.Get("/api/stats", func(w http.ResponseWriter, r *http.Request) {
rooms, clients := srv.Stats() rooms, clients := srv.Stats()
@ -175,6 +92,109 @@ func main() {
Clients: clients, 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 { g.Go(func() error {
@ -217,6 +237,39 @@ func staticRouter() http.Handler {
return r 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) { func httpErr(w http.ResponseWriter, code int) {
http.Error(w, http.StatusText(code), code) http.Error(w, http.StatusText(code), code)
} }