diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index 2d6046b..dff5374 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -71,6 +71,38 @@ func (app *application) play(w http.ResponseWriter, r *http.Request, params http } +func (app *application) createSession(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + if err := r.ParseForm(); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + + var code = strings.TrimSpace(r.PostForm.Get("code")) + var player = strings.ToLower(strings.TrimSpace(r.PostForm.Get("organiser"))) + + if len(player) < 1 { + app.clientError(w, http.StatusBadRequest) + return + } + + if s, p, err := app.model.CreateSession(player, code, time.Now(), r.Context()); err == nil { + if su, err := app.model.GetPlayersStateUpdate(s.ID, r.Context()); err == nil { + app.rtClients.SendToAll(s.ID, su) + http.Redirect(w, r, "/game/"+url.PathEscape(p.ID.String()), http.StatusSeeOther) + return + } else { + app.serverError(w, err) + return + } + } else if errors.Is(err, model.NoSuchEntity) { + app.clientError(w, http.StatusNotFound) + return + } else { + app.serverError(w, err) + return + } +} + func (app *application) game(w http.ResponseWriter, r *http.Request, params httprouter.Params) { var playerUid uuid.UUID if uid, err := uuid.Parse(params.ByName("playerUid")); err == nil { diff --git a/cmd/web/main.go b/cmd/web/main.go index 88efc41..fd6ea66 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -66,7 +66,7 @@ func main() { mux := httprouter.New() mux.GET("/", app.home) mux.POST("/play/:code", app.play) - mux.POST("/organise/:code", app.play) + mux.POST("/session", app.createSession) mux.GET("/game/:playerUid", app.game) mux.POST("/game/:playerUid/rpc/next", app.nextQuestion) mux.POST("/game/:playerUid/answers/:choiceUid", app.answer) diff --git a/pkg/codeGenerator/codeGenerator.go b/pkg/codeGenerator/codeGenerator.go new file mode 100644 index 0000000..cc70e7a --- /dev/null +++ b/pkg/codeGenerator/codeGenerator.go @@ -0,0 +1,67 @@ +package codeGenerator + +import ( + "crypto/rand" +) + +// some symbols are missing to prevent ambiguities +var alphabet = [...]byte{ + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + '1', '2', '3', '4', '5', '6', '7', '8', '9', +} + +// Generate a code, that can be guessed after 256^randomLength attempts on average. +// randomLength can be at most 8 (= 64b of randomness) +// See GenerateCode for more details +func GenerateRandomCode(incremental uint64, randomLength uint8) ([]byte, error) { + var buf [8]byte + if _, err := rand.Read(buf[:randomLength]); err != nil { + return nil, err + } + var random uint64 + for i := uint8(0); i < randomLength; i++ { + random |= uint64(buf[i]) << i * 8 + } + return GenerateCode(incremental, random), nil +} + +// Generate a code (a textual identifier) that is compact and easy for humans to deal with. +// It exists for all possible values of the arguments, but the smaller they are, the shorter the code. +// The code is guaranteed to be unique, if at least one of the arguments is unique. +// +// The typical usage is to use easy to predict yet easy to make unique value for incremental +// (to ensure uniqueness) and a random value for random to make the code unpredictable. +// +// Beware the code is not guaranteed to be parsable back to incremental and random arguments. +func GenerateCode(incremental uint64, random uint64) []byte { + var incrementalNumerals = countNumerals(incremental) + var randomNumerals = countNumerals(random) + + var res = make([]byte, incrementalNumerals+randomNumerals) + genCode(random, res) + genCode(incremental, res[:incrementalNumerals]) + return res +} + +// Count the bytes needed to stored encoded x +func countNumerals(x uint64) (numerals uint64) { + for i := x; i > 0; i /= uint64(len(alphabet)) { + numerals++ + } + if numerals == 0 { + numerals = 1 + } + return +} + +// Write encoded x to res from its end +func genCode(x uint64, res []byte) { + if x == 0 { + res[len(res)-1] = alphabet[0] + return + } + for i := len(res) - 1; x > 0 && i >= 0; i-- { + res[i] = alphabet[x%uint64(len(alphabet))] + x /= uint64(len(alphabet)) + } +} diff --git a/pkg/codeGenerator/codeGenerator_test.go b/pkg/codeGenerator/codeGenerator_test.go new file mode 100644 index 0000000..5496b74 --- /dev/null +++ b/pkg/codeGenerator/codeGenerator_test.go @@ -0,0 +1,20 @@ +package codeGenerator + +import ( + "bytes" + "testing" +) + +func TestGenerateCode(t *testing.T) { + test := func(incremental uint64, random uint64, expected []byte) { + if actual := GenerateCode(incremental, random); !bytes.Equal(actual, expected) { + t.Errorf("GenerateCode(%d, %d) returned %#v while %#v was expected", incremental, random, actual, expected) + } else if len(actual) != cap(actual) { + t.Errorf("GenerateCode(%d, %d) returned slice with capacity %d, while its length is %d. Potential memory waste", incremental, random, cap(actual), len(actual)) + } + } + test(0, 0, []byte{'A', 'A'}) + test(32, 31, []byte{'B', 'A', '9'}) + const maxUint64 = ^uint64(0) + test(maxUint64, maxUint64, []byte{'S', '9', '9', '9', '9', '9', '9', '9', '9', '9', '9', '9', '9', 'S', '9', '9', '9', '9', '9', '9', '9', '9', '9', '9', '9', '9'}) +} diff --git a/pkg/model/ent/schema/codesSequence.go b/pkg/model/ent/schema/codesSequence.go new file mode 100644 index 0000000..da62b70 --- /dev/null +++ b/pkg/model/ent/schema/codesSequence.go @@ -0,0 +1,10 @@ +package schema + +import ( + "entgo.io/ent" +) + +// Provides a DBMS independent way of obtaining unique values +type CodesSequence struct { + ent.Schema +} diff --git a/pkg/model/ent/schema/game.go b/pkg/model/ent/schema/game.go index b0df7bf..f9ae7ea 100644 --- a/pkg/model/ent/schema/game.go +++ b/pkg/model/ent/schema/game.go @@ -17,6 +17,7 @@ func (Game) Fields() []ent.Field { field.Text("name").MaxLen(64), field.Time("created").Immutable(), field.Text("author").MaxLen(64), + field.Text("code").MinLen(1).Unique(), } } diff --git a/pkg/model/ent/schema/session.go b/pkg/model/ent/schema/session.go index da4bf80..8e2d41b 100644 --- a/pkg/model/ent/schema/session.go +++ b/pkg/model/ent/schema/session.go @@ -16,7 +16,7 @@ func (Session) Fields() []ent.Field { field.UUID("id", uuid.Nil).Immutable(), field.Time("created").Immutable(), field.Time("started").Nillable().Optional(), - field.String("code").MinLen(6).MaxLen(6).Immutable().Unique(), + field.String("code").MinLen(1).Immutable().Unique(), } } diff --git a/pkg/model/model.go b/pkg/model/model.go index 533ec75..ef0a569 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -8,6 +8,7 @@ import ( "errors" "github.com/google/uuid" "time" + "vkane.cz/tinyquiz/pkg/codeGenerator" "vkane.cz/tinyquiz/pkg/model/ent" "vkane.cz/tinyquiz/pkg/model/ent/answer" "vkane.cz/tinyquiz/pkg/model/ent/askedquestion" @@ -84,6 +85,41 @@ func (m *Model) RegisterPlayer(playerName string, sessionCode string, now time.T } } +func (m *Model) CreateSession(organiserName string, gameCode string, now time.Time, c context.Context) (*ent.Session, *ent.Player, error) { + tx, err := m.c.BeginTx(c, &sql.TxOptions{ + Isolation: sql.LevelReadCommitted, + }) + if err != nil { + return nil, nil, err + } + defer tx.Rollback() + + if gameId, err := tx.Game.Query().Where(game.Code(gameCode)).OnlyID(c); err == nil { + if incremental, err := m.getCodeIncremental(c); err == nil { + if code, err := codeGenerator.GenerateRandomCode(incremental, codeRandomPartLength); err == nil { + if s, err := tx.Session.Create().SetID(uuid.New()).SetCreated(now).SetCode(string(code)).SetGameID(gameId).Save(c); err == nil { + if p, err := tx.Player.Create().SetID(uuid.New()).SetJoined(now).SetName(organiserName).SetSession(s).SetOrganiser(true).Save(c); err == nil { + err := tx.Commit() + return s, p, err + } else { + return nil, nil, err + } + } else { + return nil, nil, err + } + } else { + return nil, nil, err + } + } else { + return nil, nil, err + } + } else if ent.IsNotFound(err) { + return nil, nil, NoSuchEntity + } else { + return nil, nil, err + } +} + func (m *Model) GetPlayerWithSessionAndGame(uid uuid.UUID, c context.Context) (*ent.Player, error) { tx, err := m.c.BeginTx(c, &sql.TxOptions{ Isolation: sql.LevelRepeatableRead, @@ -268,3 +304,13 @@ func (m *Model) SaveAnswer(playerId uuid.UUID, choiceId uuid.UUID, now time.Time return nil, err } } + +const codeRandomPartLength uint8 = 3 + +func (m *Model) getCodeIncremental(c context.Context) (uint64, error) { + if c, err := m.c.CodesSequence.Create().Save(c); err == nil { + return uint64(c.ID), nil + } else { + return 0, nil + } +} diff --git a/pkg/model/model_test.go b/pkg/model/model_test.go index 5e68207..c01a166 100644 --- a/pkg/model/model_test.go +++ b/pkg/model/model_test.go @@ -43,7 +43,7 @@ func newTestModelWithData(t *testing.T) *Model { defer tx.Rollback() var gamesC = []*ent.GameCreate{ - tx.Game.Create().SetID(uuid.MustParse("cab48de7-bba3-4873-9335-eec4aaaae1e9")).SetName("5th grade knowledge test").SetCreated(time.Unix(1613387448, 0)).SetAuthor("Adam Smith PhD."), + tx.Game.Create().SetID(uuid.MustParse("cab48de7-bba3-4873-9335-eec4aaaae1e9")).SetName("5th grade knowledge test").SetCode("abcdef").SetCreated(time.Unix(1613387448, 0)).SetAuthor("Adam Smith PhD."), } games := tx.Game.CreateBulk(gamesC...).SaveX(c) diff --git a/ui/html/home.page.tmpl.html b/ui/html/home.page.tmpl.html index 0057023..91456cc 100644 --- a/ui/html/home.page.tmpl.html +++ b/ui/html/home.page.tmpl.html @@ -27,7 +27,7 @@

Začít hrát

-
+