From 81623db66a67ea2433ca22a44f51e43c8e60a1a4 Mon Sep 17 00:00:00 2001 From: Hamza Ali Date: Fri, 25 Oct 2019 06:17:07 +0700 Subject: [PATCH] server: add websocket module and http module --- common/structs.go | 29 +++++++ db/connection.go | 58 +++++++++++++ db/staff.go | 160 +++++++++++++++++++++++++++++++++++ db/staff_test.go | 155 ++++++++++++++++++++++++++++++++++ db/ticket.go | 126 +++++++++++++++++++++++++++ db/ticket_test.go | 61 ++++++++++++++ db/visitor.go | 77 +++++++++++++++++ db/visitor_test.go | 102 ++++++++++++++++++++++ go.mod | 9 +- go.sum | 13 +++ main.go | 19 ++++- server/auth.go | 46 ++++++++++ server/server.go | 37 ++++++++ server/socket/display.go | 24 ++++++ server/socket/hub.go | 79 +++++++++++++++++ server/socket/kiosk.go | 49 +++++++++++ server/socket/socket.go | 77 +++++++++++++++++ server/socket/staff.go | 178 +++++++++++++++++++++++++++++++++++++++ server/utilities.go | 74 ++++++++++++++++ 19 files changed, 1371 insertions(+), 2 deletions(-) create mode 100644 common/structs.go create mode 100644 db/connection.go create mode 100644 db/staff.go create mode 100644 db/staff_test.go create mode 100644 db/ticket.go create mode 100644 db/ticket_test.go create mode 100644 db/visitor.go create mode 100644 db/visitor_test.go create mode 100644 go.sum create mode 100644 server/auth.go create mode 100644 server/server.go create mode 100644 server/socket/display.go create mode 100644 server/socket/hub.go create mode 100644 server/socket/kiosk.go create mode 100644 server/socket/socket.go create mode 100644 server/socket/staff.go create mode 100644 server/utilities.go diff --git a/common/structs.go b/common/structs.go new file mode 100644 index 0000000..0cb0932 --- /dev/null +++ b/common/structs.go @@ -0,0 +1,29 @@ +package common + +import "time" + +//Visitor struct represents the customer who comes to the IT +type Visitor struct { + Email string `json:"email"` + Name string `json:"name"` + FirstTicket int `json:"first_ticket"` +} + +//Staff struct represents a staff member who operates the queuing system +type Staff struct { + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + Hash string `json:"password"` + Admin bool `json:"admin"` +} + +//Ticket represents the item that visitors and staff interact with for each visit +type Ticket struct { + ID int `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Staff string `json:"staff"` + Start time.Time `json:"start"` + End time.Time `json:"end"` +} diff --git a/db/connection.go b/db/connection.go new file mode 100644 index 0000000..9ebc235 --- /dev/null +++ b/db/connection.go @@ -0,0 +1,58 @@ +package db + +import ( + "database/sql" + + // Import the SQLite library we're going to use so SQL can use it. + _ "github.com/mattn/go-sqlite3" +) + +var db *sql.DB +var alreadyUsingMemory bool + +func init() { + openDatabase("./database.sqlite") +} + +func openDatabase(fileLocation string) { + db, _ = sql.Open("sqlite3", fileLocation) + db.SetMaxOpenConns(1) + + // Initializes the tables + db.Exec(`CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER NOT NULL UNIQUE PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + name TEXT NOT NULL, + staff TEXT NOT NULL, + time_start DATE NOT NULL DEFAULT CURRENT_TIMESTAMP, + time_end DATE)`) + + db.Exec(`CREATE TABLE IF NOT EXISTS clients ( + email TEXT NOT NULL UNIQUE PRIMARY KEY, + name TEXT NOT NULL, + first_ticket INT NOT NULL)`) + + db.Exec(`CREATE TABLE IF NOT EXISTS staff ( + username TEXT NOT NULL UNIQUE PRIMARY KEY, + password TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + admin BIT NOT NULL)`) +} + +//UseInMemoryDatabase requests DB to be ran in memory instead of a file which can be useful during testing. +//Creates a (seemingly) new DB every time by dropping all tables. +func UseInMemoryDatabase() { + if db != nil { + if alreadyUsingMemory { + dropAllTables() + } + db.Close() + } + openDatabase("file::memory:?cache=shared") + alreadyUsingMemory = true +} + +func dropAllTables() { + db.Exec("DROP TABLE tickets; DROP TABLE clients; DROP TABLE staff;") +} diff --git a/db/staff.go b/db/staff.go new file mode 100644 index 0000000..1fce95b --- /dev/null +++ b/db/staff.go @@ -0,0 +1,160 @@ +package db + +import ( + "JISQueueing/common" + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "log" + + "golang.org/x/crypto/bcrypt" +) + +//LoginFailReason specifies either the reason that a login request had failed or a value to indicate that it had succeeded. +type LoginFailReason uint8 + +//RegisterFailReason specifies either the reason that a registration request had failed or a value to indicate that it had succeeded. +type RegisterFailReason uint8 + +//BcryptCost is the cost of the password hash we use. A larger value means that it is harder to break but takes longer to compute on the server too. +const BcryptCost = 10 + +var ErrTokenNotFound = errors.New("the provided token does not belong to any staff member") +const ( + //InvalidUser indicates that the provided username could not be found in the database. + InvalidUser LoginFailReason = iota + //InvalidPassword indicates that the provided password was incorrect. + InvalidPassword + //InvalidInternalPasswordHash indicates that the validation of password hash that was stored in database had failed. + InvalidInternalPasswordHash + + //ReloginRequest indicates that the login was successful but the user is already logged in. + ReloginRequest + //LoginSuccessful indicates that the login was successful. + LoginSuccessful +) + +const ( + //UsernameAlreadyExists indicates that the provided username was already registered. + UsernameAlreadyExists RegisterFailReason = iota + + //RegisterSuccessful indicates that the registration was successful. + RegisterSuccessful +) + +var ( + staffToHash = make(map[common.Staff]string) + hashToStaff = make(map[string]common.Staff) + + encoder = base64.StdEncoding +) + +//Login attempts to log a user in. It takes in username and password, processes the request and returns LoginFailReason. +func Login(username string, password string) (string, LoginFailReason) { + results, _ := db.Query("SELECT * FROM staff WHERE username=? COLLATE NOCASE", username) + defer results.Close() + + //No results? + if !results.Next() { + return "", InvalidUser + } + staff := convertIntoStaff(results) + result := bcrypt.CompareHashAndPassword([]byte(staff.Hash), []byte(password)) + if result == bcrypt.ErrMismatchedHashAndPassword { + return "", InvalidPassword + } + + if result != nil { + log.Printf("[WARN] An internal password hash is invalid: user=%s, hash=%s", username, staff.Hash) + return "", InvalidInternalPasswordHash + } + token, loggedInAlready := newToken(staff) + if loggedInAlready { + return token, ReloginRequest + } + return token, LoginSuccessful +} + +func Register(staff common.Staff, password string) RegisterFailReason { + token, loginFailReason := Login(staff.Username, password) //Attempt to login as the user to make sure it doesn't exist. + if loginFailReason == LoginSuccessful || loginFailReason == ReloginRequest { + if loginFailReason == LoginSuccessful { + Logout(token) + } + return UsernameAlreadyExists + } + if loginFailReason != InvalidUser { + //Oops. The user already exists! + return UsernameAlreadyExists + } + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + staff.Hash = string(hashedPassword) + db.Exec("INSERT INTO staff (username, password, name, email, admin) VALUES (?, ?, ?, ?, ?)", + staff.Username, staff.Hash, staff.Name, staff.Email, staff.Admin) + return RegisterSuccessful +} + +//Logout logs out the user with the token. +func Logout(token string) bool { + if hashToStaff[token] == (common.Staff{}) { + return false + } + delete(staffToHash, hashToStaff[token]) + delete(hashToStaff, token) + return true +} + +//GetTokenOwner returns the owner of a given token. +func GetTokenOwner(token string) (common.Staff, error) { + if _, ok := hashToStaff[token]; ok { + return hashToStaff[token], nil + } + return common.Staff{}, ErrTokenNotFound +} + +//VerifyToken checks if a given token is valid. +func VerifyToken(token string) bool { + _, ok := hashToStaff[token] + return ok +} + +//newToken creates or returns a previously cached token for the given staff member. The second return value is a bool that is true if the member had already been logged in previously. +func newToken(staff common.Staff) (string, bool) { + if staffToHash[staff] != "" { + return staffToHash[staff], true + } + + tokenString := "" + ok := true + //Keep looping if the token already exists. Shouldn't happen too often. + for ok { + token := make([]byte, 18) //18 bytes is 24 characters of base64 + _, err := rand.Read(token) + if err != nil { + panic(err) //Having no longer random bits is worse than the service not working. + } + tokenString = encoder.EncodeToString(token) + _, ok = hashToStaff[tokenString] + } + + staffToHash[staff] = tokenString + hashToStaff[tokenString] = staff + return tokenString, false +} + +func convertIntoStaff(rows *sql.Rows) common.Staff { + var username string + var hash string + var name string + var email string + var admin bool + rows.Scan(&username, &hash, &name, &email, &admin) + return common.Staff{ + Username: username, + Name: name, + Hash: hash, + Admin: admin, + } +} diff --git a/db/staff_test.go b/db/staff_test.go new file mode 100644 index 0000000..b5b235b --- /dev/null +++ b/db/staff_test.go @@ -0,0 +1,155 @@ +package db_test + +import ( + "JISQueueing/common" + "JISQueueing/db" + "testing" +) + +func TestRegister(t *testing.T) { + db.UseInMemoryDatabase() + staff := exampleStaffUser() + + result := db.Register(staff, "test123") + if result != db.RegisterSuccessful { + t.Errorf("Registration should be successful. Got: %d", result) + } + result = db.Register(staff, "test123") + if result != db.UsernameAlreadyExists { + t.Errorf("Registration with the same name should fail. Got: %d", result) + } + result = db.Register(staff, "test456") + if result != db.UsernameAlreadyExists { + t.Errorf("Registration with the same name should fail. Got: %d", result) + } + result = db.Register(staff, "test123") + if result != db.UsernameAlreadyExists { + t.Errorf("Registration with the same name should fail. Got: %d", result) + } + + token, _ := db.Login(staff.Username, "test123") + result = db.Register(staff, "test123") + if result != db.UsernameAlreadyExists { + t.Errorf("Registration with same username should fail. Got: %d", result) + } + if !db.VerifyToken(token) { + t.Error("Registering a logged-in user logs the user out!") + } + db.Logout(token) +} + +func TestLogin(t *testing.T) { + db.UseInMemoryDatabase() + staff1 := exampleStaffUser() + staff2 := exampleStaffUser2() + staff3 := exampleStaffUser3() + db.Register(staff1, "test123") + db.Register(staff2, "test456") + + token1, result := db.Login(staff1.Username, "test123") + if result != db.LoginSuccessful { + t.Errorf("Login should be successful. Got: %d", result) + } + token2, result := db.Login(staff2.Username, "test456") + if result != db.LoginSuccessful { + t.Errorf("Login should be successful. Got: %d", result) + } + + _, result = db.Login(staff1.Username, "test456") + if result != db.InvalidPassword { + t.Errorf("Password should be invalid. Got: %d", result) + } + _, result = db.Login(staff3.Username, "test123") + if result != db.InvalidUser { + t.Errorf("User should be invalid. Got: %d", result) + } + + db.Register(staff3, "test789") + token3, result := db.Login(staff3.Username, "test789") + if result != db.LoginSuccessful { + t.Errorf("Login should be successful. Got: %d", result) + } + + newToken1, result := db.Login(staff1.Username, "test123") + if result != db.ReloginRequest { + t.Errorf("DB should acknowledge login as a relogin. Got %d instead.", result) + } + if newToken1 != token1 { + t.Errorf("Old token doesn't match new token! old=%s, new=%s", token1, newToken1) + } + newToken2, result := db.Login(staff2.Username, "test456") + if result != db.ReloginRequest { + t.Errorf("DB should acknowledge login as a relogin. Got %d instead.", result) + } + if newToken2 != token2 { + t.Errorf("Old token doesn't match new token! old=%s, new=%s", token2, newToken2) + } + newToken3, result := db.Login(staff3.Username, "test789") + if result != db.ReloginRequest { + t.Errorf("DB should acknowledge login as a relogin. Got %d instead.", result) + } + if newToken3 != token3 { + t.Errorf("Old token doesn't match new token! old=%s, new=%s", token3, newToken3) + } + + // Logout everyone and make sure they are no longer acknowledged as relogin requests. + if !db.Logout(token1) { + t.Errorf("Failed to log out a valid token!") + } + if !db.Logout(token2) { + t.Errorf("Failed to log out a valid token!") + } + if !db.Logout(token3) { + t.Errorf("Failed to log out a valid token!") + } + + token1, result = db.Login(staff1.Username, "test123") + if result != db.LoginSuccessful { + t.Errorf("Login should be successful. Got: %d", result) + } + token2, result = db.Login(staff2.Username, "test456") + if result != db.LoginSuccessful { + t.Errorf("Login should be successful. Got: %d", result) + } + token3, result = db.Login(staff3.Username, "test789") + if result != db.LoginSuccessful { + t.Errorf("Login should be successful. Got: %d", result) + } + + // Logout everyone again so we have a consistent token cache. + if !db.Logout(token1) { + t.Errorf("Failed to log out a valid token!") + } + if !db.Logout(token2) { + t.Errorf("Failed to log out a valid token!") + } + if !db.Logout(token3) { + t.Errorf("Failed to log out a valid token!") + } +} + +func exampleStaffUser() common.Staff { + return common.Staff{ + Username: "test", + Name: "Test Member", + Email: "test@example.com", + Admin: false, + } +} +func exampleStaffUser2() common.Staff { + return common.Staff{ + Username: "test2", + Name: "Test Member2", + Email: "test2@example.com", + Admin: false, + } +} + +func exampleStaffUser3() common.Staff { + return common.Staff { + Username: "test3", + Name: "Test Member3", + Email: "test3@example.com", + Admin: false, + } +} \ No newline at end of file diff --git a/db/ticket.go b/db/ticket.go new file mode 100644 index 0000000..2657b41 --- /dev/null +++ b/db/ticket.go @@ -0,0 +1,126 @@ +package db + +import ( + "JISQueueing/common" + "database/sql" + "errors" + "time" +) + +//ErrTicketNotFound indicates that the provided ticket with the id does not belong to any tickets +var ErrTicketNotFound = errors.New("ticket id does not exist") + +func GetAllTickets() []common.Ticket { + rows, _ := db.Query("SELECT * FROM tickets") + defer rows.Close() + + return convertIntoTickets(rows) +} + +//GetTicketsByEmail queries all tickets that a specific client has made. +func GetTicketsByEmail(email string) []common.Ticket { + rows, _ := db.Query("SELECT * FROM tickets WHERE email=? COLLATE NOCASE", email) + defer rows.Close() + + return convertIntoTickets(rows) +} + +func GetTicket(id int) (common.Ticket, error) { + rows, _ := db.Query("SELECT * FROM tickets where id=?", id) + defer rows.Close() + + tickets := convertIntoTickets(rows) + if len(tickets) == 0 { + return common.Ticket{}, ErrTicketNotFound + } + return tickets[0], nil +} + +//NewTicket generates a ticket with the current timestamp and inserts it into the database. +func NewTicket(visitor common.Visitor) common.Ticket { + now := time.Now().UTC() + result, _ := db.Exec("INSERT INTO tickets (email, name, staff, time_start, time_end) VALUES (?, ?, ?, ?, ?)", + visitor.Email, visitor.Name, "{}", now, time.Unix(0, 0).UTC()) + + lastInsertID, _ := result.LastInsertId() + return common.Ticket{ + ID: int(lastInsertID), + Email: visitor.Email, + Name: visitor.Name, + Staff: "{}", + Start: now, + End: time.Unix(0, 0).UTC(), + } +} + +//SetClaimer updates the database with the staff user who claims a ticket. +func SetClaimer(id int, username string) bool { + ticket, ok := GetTicket(id) + if ok != nil || ticket.Staff != "{}" { + return false + } + + result, _ := db.Exec("UPDATE tickets SET staff=? WHERE id=?", username, id) + rowsAffected, _ := result.RowsAffected() + + return rowsAffected > 0 +} + +//FinishTicket marks a ticket as complete in the database. +func FinishTicket(id int) bool { + if !isActive(id) { + return false + } + result, _ := db.Exec("UPDATE tickets SET time_end=? WHERE id=?", time.Now().UTC(), id) + rowsAffected, _ := result.RowsAffected() + + return rowsAffected > 0 +} + +//Finish Ticket marks a ticket as cancelled if the user decides to cancel it or does not show up. +func CancelTicket(id int) bool { + if !isActive(id) { + return false + } + result, _ := db.Exec("UPDATE tickets SET time_start=? WHERE id=?", time.Unix(0, 0), id) + rowsAffected, _ := result.RowsAffected() + + return rowsAffected > 0 +} + +func isActive(id int) bool { + ticket, ok := GetTicket(id) + if ok != nil { + return false + } + if ticket.Start.Unix() == 0 || ticket.End.Unix() != 0 { + println("start", ticket.Start.Unix(),"end", ticket.End.Unix()) + return false + } + return true +} + +//convertIntoTickets converts the result set from a sql query of a rows of visitors into a []common.Visitor. +func convertIntoTickets(rows *sql.Rows) []common.Ticket { + tickets := make([]common.Ticket, 0) + for rows.Next() { + var id int + var email string + var name string + var staff string + var start time.Time + var end time.Time + rows.Scan(&id, &email, &name, &staff, &start, &end) + + ticket := common.Ticket{ + ID: id, + Email: email, + Name: name, + Staff: staff, + Start: start, + End: end, + } + tickets = append(tickets, ticket) + } + return tickets +} diff --git a/db/ticket_test.go b/db/ticket_test.go new file mode 100644 index 0000000..82a92d3 --- /dev/null +++ b/db/ticket_test.go @@ -0,0 +1,61 @@ +package db_test + +import ( + "JISQueueing/db" + "testing" + "time" +) + +func TestNewTicket(t *testing.T) { + db.UseInMemoryDatabase() + db.NewVisitor(exampleVisitor()) + + ticket := db.NewTicket(exampleVisitor()) + if ticket.Email != exampleVisitor().Email || ticket.Staff != "{}" { + t.Errorf("Expected email and username to mach.") + } + newTicket, _ := db.GetTicket(ticket.ID) + if ticket != newTicket { + t.Errorf("Expected ticket to be present in database. returned %v, instead of %v", newTicket, ticket) + } +} + +func TestSetClaimer(t *testing.T) { + db.UseInMemoryDatabase() + db.Register(exampleStaffUser(), "test123") + + ticket := db.NewTicket(exampleVisitor()) + if !db.SetClaimer(ticket.ID, exampleStaffUser().Username) { + t.Errorf("Expected ticket to update claimer successfully.") + } + + if db.SetClaimer(ticket.ID, exampleStaffUser().Username) { + t.Errorf("Expected ticket's claimer to not update as already set") + } + +} + +func TestFinishTicket(t *testing.T) { + db.UseInMemoryDatabase() + ticket := db.NewTicket(exampleVisitor()) + breakpoint := time.Date(2019, 10, 25, 0, 0, 0, 0, time.UTC) + if breakpoint.Before(ticket.End) { + t.Errorf("Expected ticket end time to be Unix 0, got %d instead.", ticket.End.Unix()) + } + db.FinishTicket(ticket.ID) + ticket, _ = db.GetTicket(ticket.ID) + if breakpoint.After(ticket.End) { + t.Errorf("Expected ticket to get time updated after completion. got %d instead", ticket.End.Unix()) + } +} + +func TestCancelTicket(t *testing.T) { + db.UseInMemoryDatabase() + ticket := db.NewTicket(exampleVisitor()) + db.CancelTicket(ticket.ID) + + ticket, _ = db.GetTicket(ticket.ID) + if ticket.Start.Unix() != 0 { + t.Errorf("Expected ticket to get start time updated after cancel. got %d instead", ticket.Start.Unix()) + } +} \ No newline at end of file diff --git a/db/visitor.go b/db/visitor.go new file mode 100644 index 0000000..7dbd235 --- /dev/null +++ b/db/visitor.go @@ -0,0 +1,77 @@ + package db + +import ( + "JISQueueing/common" + "database/sql" + "errors" +) + +//ErrVisitorNotFound indicates that the email does not belong to an existing visitor (new account). +var ErrVisitorNotFound = errors.New("visitor email does not exist") + +//GetAllVisitors returns a list of all visitors in our record. +func GetAllVisitors() []common.Visitor { + rows, _ := db.Query("SELECT * FROM clients") + defer rows.Close() + + return convertIntoVisitors(rows) +} + +//GetVisitor retrieves the visitor with the specified ID in the form of `common.Visitor`. +func GetVisitor(email string) (common.Visitor, error) { + rows, _ := db.Query("SELECT * FROM clients WHERE email=?", email) + defer rows.Close() + visitors := convertIntoVisitors(rows) + if len(visitors) == 0 { + return common.Visitor{}, ErrVisitorNotFound + } + return visitors[0], nil +} + +//NewVisitor saves the provided visitor and returns whether the email is new (first time) or already used (old, and update name). +func NewVisitor(visitor common.Visitor) (bool, common.Visitor) { + exists, editVisitor := EditVisitor(visitor) + if exists { + return false, editVisitor + } + + result, _ := db.Exec("INSERT INTO clients (email, name, first_ticket) VALUES (?, ?, ?)", visitor.Email, visitor.Name, -1) + rowsAffected, _ := result.RowsAffected() + return rowsAffected > 0, visitor +} + +//EditVisitor updates the provided visitor with the new name associated with the email. +func EditVisitor(visitor common.Visitor) (bool, common.Visitor) { + result, _ := db.Exec("UPDATE clients SET name=? WHERE email=?", visitor.Name, visitor.Email) + rowsAffected, _ := result.RowsAffected() + + updated, _ := GetVisitor(visitor.Email) + return rowsAffected > 0, updated +} + +//SetFirstTicket updates the first ticket column for the provided visitor +func SetFirstTicket(visitor common.Visitor) bool { + result, _ := db.Exec("UPDATE clients SET first_ticket=? WHERE email=?", visitor.FirstTicket, visitor.Email) + rowsAffected, _ := result.RowsAffected() + + return rowsAffected > 0 +} + +//convertIntoVisitors converts the result set from a sql query of a rows of visitors into a []common.Visitor. +func convertIntoVisitors(rows *sql.Rows) []common.Visitor { + visitors := make([]common.Visitor, 0) + for rows.Next() { + var email string + var name string + var firstTicket int + rows.Scan(&email, &name, &firstTicket) + + visitor := common.Visitor{ + Email: email, + Name: name, + FirstTicket: firstTicket, + } + visitors = append(visitors, visitor) + } + return visitors +} diff --git a/db/visitor_test.go b/db/visitor_test.go new file mode 100644 index 0000000..d4cd072 --- /dev/null +++ b/db/visitor_test.go @@ -0,0 +1,102 @@ +package db_test + +import ( + "JISQueueing/common" + "JISQueueing/db" + "testing" +) + +func TestNewVisitor(t *testing.T) { + db.UseInMemoryDatabase() + db.NewVisitor(exampleVisitor()) + db.NewVisitor(exampleVisitor2()) + + success, _ := db.NewVisitor(exampleDuplicateVisitor()) + + if success { + t.Error("Uploading duplicate email visitor caused return to be true, when expected false") + } +} + +func TestGetVisitor(t *testing.T) { + db.UseInMemoryDatabase() + db.NewVisitor(exampleVisitor()) + db.NewVisitor(exampleVisitor2()) + + visitor, err := db.GetVisitor(exampleVisitor().Email) + if err != nil { + t.Error("Expected visitor to be fetched correctly") + } + if visitor.Name != exampleVisitor().Name { + t.Errorf("Expected sample visitor to have %s as it's name, got %s instead", exampleVisitor().Name, visitor.Name) + } + + visitor, err = db.GetVisitor(exampleVisitor2().Email) + if err != nil { + t.Error("Expected visitor to be fetched correctly") + } + if visitor.Name != exampleVisitor2().Name { + t.Errorf("Expected sample visitor to have %s as it's name, got %s instead", exampleVisitor2().Name, visitor.Name) + } + + db.EditVisitor(exampleDuplicateVisitor()) + visitor, err = db.GetVisitor(exampleDuplicateVisitor().Email) + if err != nil { + t.Error("Expected visitor to be fetched correctly") + } + if visitor.Name != exampleDuplicateVisitor().Name { + t.Errorf("Expected sample visitor to have %s as it's name, got %s instead", exampleDuplicateVisitor().Name, visitor.Name) + } +} + +func TestEditVisitor(t *testing.T) { + db.UseInMemoryDatabase() + + visitor := exampleVisitor() + db.NewVisitor(visitor) + visitor.Name = "New name" + success, _ := db.EditVisitor(visitor) + if !success { + t.Error("Expected visitor to be edited correctly") + } +} + +func TestSetFirstTicket(t *testing.T) { + db.UseInMemoryDatabase() + + visitor := exampleVisitor() + db.NewVisitor(visitor) + + visitor.FirstTicket = 2 + db.SetFirstTicket(visitor) + + updated, err := db.GetVisitor(visitor.Email) + if err != nil { + t.Error("Expected visitor to be fetched correctly") + } + + if updated.FirstTicket != 2 { + t.Error("Expected first ticket id to be updated correctly") + } +} + +func exampleVisitor() common.Visitor { + return common.Visitor{ + Email: "test@example.com", + Name: "Example Name", + } +} + +func exampleVisitor2() common.Visitor { + return common.Visitor{ + Email: "example@example.com", + Name: "John Doe", + } +} + +func exampleDuplicateVisitor() common.Visitor { + return common.Visitor{ + Email: "example@example.com", + Name: "Bob Smith", + } +} diff --git a/go.mod b/go.mod index 173cd9a..4462dd3 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ -module gitea.teamortix.com/Team-Ortix/JISQueueing +module JISQueueing go 1.12 + +require ( + github.com/gorilla/mux v1.7.3 + github.com/gorilla/websocket v1.4.1 + github.com/mattn/go-sqlite3 v1.11.0 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c2df016 --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q= +github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/main.go b/main.go index a297bc0..c9fd56c 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,22 @@ package main +import ( + "JISQueueing/common" + "JISQueueing/server" + "flag" + "net/http" + "strconv" +) + +var hashToStaff = make(map[string]common.Staff) + func main() { - //TODO: Populate with flags and calls to `http/` + var port int + var debug bool + flag.IntVar(&port, "port", 8080, "Specify port for socket and webserver") + flag.BoolVar(&debug, "debug", false, "Specify the debug mode status") + + hostLocation := ":" + strconv.Itoa(port) + mux := server.NewServerMux(debug) + http.ListenAndServe(hostLocation, mux) } diff --git a/server/auth.go b/server/auth.go new file mode 100644 index 0000000..c2aa763 --- /dev/null +++ b/server/auth.go @@ -0,0 +1,46 @@ +package server + +import ( + "JISQueueing/db" + "net/http" +) + +//Login handles api calls to /api/login +func Login(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + InvalidUserInput(w, r) + return + } + user := r.Form.Get("user") + pass := r.Form.Get("pass") + + if user == "" || pass == "" { + InvalidUserInput(w, r) + return + } + + token, result := db.Login(user, pass) + switch result { + case db.InvalidUser: + fallthrough + case db.InvalidPassword: + Unauthorized(w, r) + case db.InvalidInternalPasswordHash: + InternalServerError(w, r) + case db.ReloginRequest: + fallthrough + case db.LoginSuccessful: + writeJSONResponse(w, http.StatusOK, token) + } +} + +//Logout handles api calls to /api/logout +func Logout(w http.ResponseWriter, r *http.Request) { + if !handleToken(w, r) { + return + } + + db.Logout(r.Form.Get("token")) + writeJSONResponse(w, http.StatusOK, nil) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..e01840f --- /dev/null +++ b/server/server.go @@ -0,0 +1,37 @@ +package server + +import ( + "JISQueueing/server/socket" + "net/http" + + "github.com/gorilla/mux" +) + +//NewServerMux creates a new http handler object handling API requests +func NewServerMux(debug bool) http.Handler { + router := mux.NewRouter() + router.NotFoundHandler = http.HandlerFunc(NotFound) + + postRouter := router.Methods("POST").PathPrefix("/api").Subrouter() + postRouter.HandleFunc("/login", Login) + postRouter.HandleFunc("/logout", Logout) + + socketRouter := router.PathPrefix("/ws").Subrouter() + socketRouter.HandleFunc("/display", socket.DisplaySocket) + socketRouter.HandleFunc("/kiosk", socket.KioskSocket) + socketRouter.HandleFunc("/staff", socket.StaffSocket) + + router.PathPrefix("/api").Handler(http.HandlerFunc(NotFound)) + + router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static/"))) + router.PathPrefix("/css").Handler(http.FileServer(http.Dir("./static/"))) + //NotFound : A helper function that responds with a 404 status code and an error + router.PathPrefix("/js").Handler(http.FileServer(http.Dir("./static/"))) + router.PathPrefix("/").HandlerFunc(indexHandler) + + return router +} + +func indexHandler(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./static/index.html") +} \ No newline at end of file diff --git a/server/socket/display.go b/server/socket/display.go new file mode 100644 index 0000000..8a48b56 --- /dev/null +++ b/server/socket/display.go @@ -0,0 +1,24 @@ +package socket + +import ( + "github.com/gorilla/websocket" +) + +var displays = make(map[*websocket.Conn]bool) + +func onNewDisplay(who *websocket.Conn) { + displays[who] = true +} + +func onDisplayMessage(who *websocket.Conn, msg string) { +} + +func onDisplayDisconnect(who *websocket.Conn) { + delete(displays, who) +} + +func sendDisplayMessage(msg string) { + for display := range displays { + display.WriteMessage(websocket.TextMessage, []byte(msg)) + } +} \ No newline at end of file diff --git a/server/socket/hub.go b/server/socket/hub.go new file mode 100644 index 0000000..2c8844d --- /dev/null +++ b/server/socket/hub.go @@ -0,0 +1,79 @@ +package socket + +import ( + "JISQueueing/common" + "JISQueueing/db" + "encoding/json" + "github.com/gorilla/websocket" + "strconv" +) + +var tickets = make(map[common.Ticket]bool) + +func newTicket(ticket common.Ticket, who *websocket.Conn) { + tickets[ticket] = false + jsonTicket, _ := json.Marshal(ticket) + + sendStaffMessage("new " + string(jsonTicket)) + who.WriteMessage(websocket.TextMessage, []byte("success new "+string(jsonTicket))) +} + +func claimedTicket(ticket common.Ticket, staff common.Staff, table int, who *websocket.Conn) { + if !db.SetClaimer(ticket.ID, staff.Username) { + who.WriteMessage(websocket.TextMessage, []byte("error accept the ticket has already been completed or cancelled")) + return + } + delete(tickets, ticket) + ticket.Staff = staff.Username + tickets[ticket] = true + + jsonTicket, _ := json.Marshal(ticket) + sendDisplayMessage("claimed " + staff.Username + " " + strconv.Itoa(table) + " " + string(jsonTicket)) + who.WriteMessage(websocket.TextMessage, []byte("success claimed "+string(jsonTicket))) + for conn, s := range connToStaff { + if s.Username != staff.Username { + conn.WriteMessage(websocket.TextMessage, []byte("claimed "+string(jsonTicket))) + } + } +} + +func finishedTicket(ticket common.Ticket, id int, table int, who *websocket.Conn) { + delete(tickets, ticket) + + if success := db.FinishTicket(id); !success { + who.WriteMessage(websocket.TextMessage, []byte("error complete the ticket has already been completed or cancelled")) + return + } + who.WriteMessage(websocket.TextMessage, []byte("success complete "+strconv.Itoa(id))) + sendDisplayMessage("complete " + strconv.Itoa(table)) +} + +func cancelTicket(ticket common.Ticket, id int, table int, who *websocket.Conn) { + delete(tickets, ticket) + + if success := db.CancelTicket(id); !success { + who.WriteMessage(websocket.TextMessage, []byte("error cancel the ticket has already been completed or cancelled")) + return + } + who.WriteMessage(websocket.TextMessage, []byte("success cancel "+strconv.Itoa(id))) + sendDisplayMessage("cancel " + strconv.Itoa(table)) +} + +func newStaff(table int, who *websocket.Conn) { + who.WriteMessage(websocket.TextMessage, []byte("success pick "+strconv.Itoa(table))) + for member, num := range connToTable { + if num == -1 { + sendTaken(member) + } + } + sendDisplayMessage("pick " + strconv.Itoa(table)) +} + +func leaveStaff(table int) { + sendDisplayMessage("unpick " + strconv.Itoa(table)) + for member, num := range connToTable { + if num == -1 { + sendTaken(member) + } + } +} diff --git a/server/socket/kiosk.go b/server/socket/kiosk.go new file mode 100644 index 0000000..41aeb60 --- /dev/null +++ b/server/socket/kiosk.go @@ -0,0 +1,49 @@ +package socket + +import ( + "JISQueueing/common" + "JISQueueing/db" + "github.com/gorilla/websocket" + "strings" +) + +func onKioskMessage(who *websocket.Conn, msg string) { + message := strings.SplitN(msg, " ", 2) + switch message[0] { + case "query": + if len(message) < 2 { + return + } + email := strings.TrimSpace(message[1]) + visitor, err := db.GetVisitor(email) + if err == db.ErrVisitorNotFound { + who.WriteMessage(websocket.TextMessage, []byte("error query "+err.Error())) + return + } + + who.WriteMessage(websocket.TextMessage, []byte("success query "+visitor.Name)) + + case "new": + arg := strings.SplitN(msg, " ", 3) + if len(arg) < 3 { + return + } + + _, visitor := db.NewVisitor(common.Visitor{ + Email: strings.TrimSpace(arg[1]), + Name: strings.TrimSpace(arg[2]), + FirstTicket: -1, + }) + newTicket(db.NewTicket(visitor), who) + + default: + who.WriteMessage(websocket.TextMessage, []byte("error command not found")) + } +} + +func onKioskDisconnect(who *websocket.Conn) { + +} + +func onNewKiosk(who *websocket.Conn) { +} diff --git a/server/socket/socket.go b/server/socket/socket.go new file mode 100644 index 0000000..cfa5245 --- /dev/null +++ b/server/socket/socket.go @@ -0,0 +1,77 @@ +package socket + +import ( + "fmt" + "net/http" + + "github.com/gorilla/websocket" +) + +type WebsocketInterface struct { + Writer http.ResponseWriter + Request *http.Request + OnConnect func(who *websocket.Conn) + OnMessage func(who *websocket.Conn, msg string) + OnDisconnect func(who *websocket.Conn) +} + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +//DisplaySocket handles all socket calls from the display machine +func DisplaySocket(w http.ResponseWriter, r *http.Request) { + socketUpgrader(WebsocketInterface{ + Writer: w, + Request: r, + OnConnect: onNewDisplay, + OnMessage: onDisplayMessage, + OnDisconnect: onDisplayDisconnect, + }) +} + +//KioskSocket handles all socket calls from the user-facing machine +func KioskSocket(w http.ResponseWriter, r *http.Request) { + socketUpgrader(WebsocketInterface{ + Writer: w, + Request: r, + OnConnect: onNewKiosk, + OnMessage: onKioskMessage, + OnDisconnect: onKioskDisconnect, + }) +} + +//Staffsocket handles all socket calls from the connToTable machines +func StaffSocket(w http.ResponseWriter, r *http.Request) { + socketUpgrader(WebsocketInterface{ + Writer: w, + Request: r, + OnConnect: onNewStaff, + OnMessage: onStaffMessage, + OnDisconnect: onStaffDisconnect, + }) + +} + +func socketUpgrader(conn WebsocketInterface) { + ws, err := upgrader.Upgrade(conn.Writer, conn.Request, nil) + + if err != nil { + fmt.Fprint(conn.Writer, "You must use the socket protocol", err) + return + } + defer ws.Close() + + conn.OnConnect(ws) + for { + _, p, err := ws.ReadMessage() + if err != nil { + conn.OnDisconnect(ws) + break + } + + msg := string(p) + conn.OnMessage(ws, msg) + } +} diff --git a/server/socket/staff.go b/server/socket/staff.go new file mode 100644 index 0000000..72436d5 --- /dev/null +++ b/server/socket/staff.go @@ -0,0 +1,178 @@ +package socket + +import ( + "JISQueueing/common" + "JISQueueing/db" + "github.com/gorilla/websocket" + "strconv" + "strings" +) + +//Receive Commands: [status, pick, accept, complete] +//Send Commands: [new, + +var connToTable = make(map[*websocket.Conn]int) +var connToStaff = make(map[*websocket.Conn]common.Staff) + +func onNewStaff(who *websocket.Conn) { + connToTable[who] = -1 + sendTaken(who) +} + +func onStaffMessage(who *websocket.Conn, msg string) { + message := strings.SplitN(strings.TrimSpace(msg), " ", 2) + switch message[0] { + case "unpick": + if table, ok := connToTable[who]; !ok || table == -1 { + who.WriteMessage(websocket.TextMessage, []byte("error unpick you do not have a table assigned")) + return + } + + connToTable[who] = -1 + delete(connToStaff, who) + who.WriteMessage(websocket.TextMessage, []byte("success unpick")) + leaveStaff(connToTable[who]) + + case "pick": + args := strings.SplitN(msg, " ", 3) + if len(args) < 3 { + return + } + choice, err := strconv.Atoi(strings.TrimSpace(args[1])) + if err != nil { + return + } + + staff, err := db.GetTokenOwner(strings.TrimSpace(args[2])) + if err == db.ErrTokenNotFound { + who.WriteMessage(websocket.TextMessage, []byte("error pick "+err.Error())) + return + } + if isTaken(choice) { + who.WriteMessage(websocket.TextMessage, []byte("error the chosen table is not available")) + return + } + + connToStaff[who] = staff + connToTable[who] = choice + newStaff(choice, who) + + case "accept": + id, err := strconv.Atoi(strings.TrimSpace(message[1])) + if err != nil { + return + } + + staff, ok := connToStaff[who] + if !ok { + who.WriteMessage(websocket.TextMessage, []byte("error accept your session token was found to be invalid. please relogin")) + return + } + if connToTable[who] == -1 { + who.WriteMessage(websocket.TextMessage, []byte("error accept you do not have a table chosen")) + } + ticket, err := db.GetTicket(id) + if err == db.ErrTicketNotFound { + who.WriteMessage(websocket.TextMessage, []byte("error accept "+err.Error())) + return + } + if ticket.Staff != "{}" { + who.WriteMessage(websocket.TextMessage, []byte("error accept this ticket is already claimed")) + return + } + + claimedTicket(ticket, staff, connToTable[who], who) + + case "complete": + id, err := strconv.Atoi(strings.TrimSpace(message[1])) + staff := connToStaff[who] + table, ok := connToTable[who] + + if err != nil { + return + } + if !ok { + who.WriteMessage(websocket.TextMessage, []byte("error complete you are not assigned to any table")) + } + + ticket, err := db.GetTicket(id) + if err == db.ErrTicketNotFound { + who.WriteMessage(websocket.TextMessage, []byte("error complete "+err.Error())) + return + } + if staff.Username != ticket.Staff { + who.WriteMessage(websocket.TextMessage, []byte("error complete you do not own this ticket")) + return + } + finishedTicket(ticket, id, table, who) + + case "cancel": + id, err := strconv.Atoi(strings.TrimSpace(message[1])) + staff := connToStaff[who] + table, ok := connToTable[who] + + if err != nil { + return + } + if !ok { + who.WriteMessage(websocket.TextMessage, []byte("error cancel you are not assigned to any table")) + } + + ticket, err := db.GetTicket(id) + if err == db.ErrTicketNotFound { + who.WriteMessage(websocket.TextMessage, []byte("error cancel "+err.Error())) + return + } + if staff.Username != ticket.Staff { + who.WriteMessage(websocket.TextMessage, []byte("error cancel you do not own this ticket")) + return + } + cancelTicket(ticket, id, table, who) + + default: + who.WriteMessage(websocket.TextMessage, []byte("error "+message[0]+" not found")) + } +} + +func sendStaffMessage(msg string) { + for who, num := range connToTable { + if num == -1 { + continue + } + who.WriteMessage(websocket.TextMessage, []byte(msg)) + } +} + +func onStaffDisconnect(who *websocket.Conn) { + num, ok := connToTable[who] + delete(connToTable, who) + delete(connToStaff, who) + + if ok && num != -1 { + leaveStaff(connToTable[who]) + } +} + +func sendTaken(who *websocket.Conn) { + var taken = "" + for _, num := range connToTable { + if num != -1 { + taken = taken + " " + strconv.Itoa(num) + } + } + if taken != "" { + who.WriteMessage(websocket.TextMessage, []byte("taken"+taken)) + } +} + +func isTaken(table int) bool { + if table < 0 { + return true + } + for _, num := range connToTable { + if table == num { + return true + } + } + return false +} diff --git a/server/utilities.go b/server/utilities.go new file mode 100644 index 0000000..9985098 --- /dev/null +++ b/server/utilities.go @@ -0,0 +1,74 @@ +package server + +import ( + "JISQueueing/db" + "encoding/json" + nhttp "net/http" + +) + +type errorResponse struct { + Success bool `json:"success"` + Reason interface{} `json:"reason"` +} +type successResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data"` +} + +//InvalidUserInput : A helper function that tells the client that some request validation had failed. (Malformed requests) +func InvalidUserInput(w nhttp.ResponseWriter, r *nhttp.Request) { + writeJSONResponse(w, nhttp.StatusBadRequest, "Your request had failed checks.") +} + +//Unauthorized : A helper function that tells the client that they either forgot to provide `token` or it was invalid. +//This function is also used in `/api/login` to tell the client that the user/password combination is incorrect. +func Unauthorized(w nhttp.ResponseWriter, r *nhttp.Request) { + writeJSONResponse(w, nhttp.StatusUnauthorized, "Identifying details not provided or invalid.") +} + +//NotFound : A helper function that tells the client that the API endpoint they requested does not exist. +func NotFound(w nhttp.ResponseWriter, r *nhttp.Request) { + writeJSONResponse(w, nhttp.StatusNotFound, "The API endpoint was not found.") +} + +//InternalServerError : A helper function that tells the client that the server had encountered an error processing their request. +func InternalServerError(w nhttp.ResponseWriter, r *nhttp.Request) { + writeJSONResponse(w, nhttp.StatusInternalServerError, "An internal server error occurred.") +} + +//writeJSONResponse : A helper function that writes the given message into the standard response the client expects. +func writeJSONResponse(w nhttp.ResponseWriter, statusCode int, message interface{}) { + w.Header().Set("Content-Type", "application/json") + + var toWrite interface{} + if statusCode == nhttp.StatusOK { + toWrite = successResponse{ + Success: true, + Data: message, + } + } else { + w.WriteHeader(statusCode) + toWrite = errorResponse{ + Success: false, + Reason: message, + } + } + result, _ := json.Marshal(toWrite) + w.Write(result) +} + +//handleToken : A helper function that checks if the token provided by the client is valid. +//Returns if it is valid and sends the Unauthorized message if it isn't. +func handleToken(w nhttp.ResponseWriter, r *nhttp.Request) bool { + err := r.ParseForm() + if err != nil { + InvalidUserInput(w, r) + return false + } + if db.VerifyToken(r.Form.Get("token")) { + return true + } + Unauthorized(w, r) + return false +} \ No newline at end of file