diff --git a/http/api/api.go b/http/api/api.go index 335b56d..46e08ee 100644 --- a/http/api/api.go +++ b/http/api/api.go @@ -6,6 +6,8 @@ import ( "zxq.co/ripple/misirlou-api/http" ) +// Home returns the homepage of the Misirlou API, which simply gives the +// software URL and the ID of the Ripple user, if a Session is present. func Home(c *http.Context) { c.WriteString("Misirlou API 2.0\nhttps://zxq.co/ripple/misirlou-api\n") s := c.Session() diff --git a/http/api/oauth.go b/http/api/oauth.go index 70c5825..f642746 100644 --- a/http/api/oauth.go +++ b/http/api/oauth.go @@ -3,7 +3,9 @@ package api import ( "context" "crypto/rand" + "crypto/sha256" "encoding/base64" + "encoding/hex" "time" "golang.org/x/oauth2" @@ -91,9 +93,13 @@ func OAuthFinish(c *http.Context) { return } + // token is a random set of characters which is stored in DB as a sha256 hash + token := randomStr(30) + token256 := sha256.Sum256([]byte(token)) + // Save session sess := &models.Session{ - ID: randomStr(15), + ID: hex.EncodeToString(token256[:]), UserID: u.ID, AccessToken: code.AccessToken, } @@ -103,7 +109,7 @@ func OAuthFinish(c *http.Context) { return } - c.Redirect(302, c.StoreTokensURL+"?session="+sess.ID+"&access="+sess.AccessToken) + c.Redirect(302, c.StoreTokensURL+"?session="+token+"&access="+sess.AccessToken) } func init() { diff --git a/http/api/teams.go b/http/api/teams.go index 9dc3b72..5e259b9 100644 --- a/http/api/teams.go +++ b/http/api/teams.go @@ -3,6 +3,7 @@ package api import ( "zxq.co/ripple/misirlou-api/http" "zxq.co/ripple/misirlou-api/models" + "zxq.co/x/ripple" ) // Teams fetches the teams that are playing in the given tournament ID. @@ -41,6 +42,137 @@ func Team(c *http.Context) { c.SetJSON(team, team == nil) } +// CreateTeamData is the JSON data that is passed to CreateTeam. +type CreateTeamData struct { + Tournament models.ID `json:"tournament"` + Members []int `json:"members"` + Name string `json:"name"` +} + +// CreateTeam creates a new team and verifies that the team can actually +// take part in the tournament. +func CreateTeam(c *http.Context) { + // Get session data + sess := c.Session() + if sess == nil { + c.SetCode(401) + c.WriteString("Missing or invalid access token.") + return + } + + // Get client; fetch current user. + cl := sess.RippleClient() + user, err := validateUser(cl) + if err != nil { + c.Error(err) + return + } + + // Get the data submitted by the user. + var d CreateTeamData + err = c.JSON(&d) + if err != nil { + c.Error(err) + return + } + tourn, err := userCanRegister(c.DB, d.Tournament, user.ID) + if err != nil { + c.Error(err) + return + } + + // Single-user tournament; we force the team name and members. + if tourn.TeamSize == 1 { + t := &models.Team{ + Name: user.Username, + Tournament: tourn.ID, + Captain: user.ID, + } + err = c.DB.CreateTeam(t) + if err != nil { + c.Error(err) + return + } + err = c.DB.AddTeamMembers([]models.TeamMember{{ + Team: t.ID, + User: user.ID, + Attributes: models.TeamAttributeCaptain, + }}) + if err != nil { + c.Error(err) + return + } + c.SetHeader("Location", c.BaseURL+"/teams/"+t.ID.String()) + c.SetJSONWithCode(t, 201) + return + } + + c.WriteString("Not implemented") +} + +// validateUser checks that the user has a valid access token and that the user +// has privileges UserNormal and UserPublic. (is not banned/locked/restricted). +func validateUser(cl *ripple.Client) (*ripple.User, error) { + user, err := cl.User(ripple.Self) + if err != nil { + return nil, err + } + + if user == nil { + return nil, &http.ResponseError{ + Code: 401, + Message: "Access token is invalid - did you revoke the token?", + } + } + if user.Privileges&3 != 3 { + return nil, &http.ResponseError{ + Code: 403, + Message: "You don't have the required privileges to register for a tournament.", + } + } + + return user, nil +} + +func userCanRegister(db *models.DB, tournID models.ID, uid int) (*models.Tournament, error) { + tourn, err := db.Tournament(tournID) + if err != nil { + return nil, err + } + if tourn == nil || tourn.Status == models.StatusOrganising { + return nil, &http.ResponseError{ + Code: 404, + Message: "That tournament does not exist.", + } + } + + // Check whether user in another team of this tournament. + in, err := db.UserInTournament(tourn.ID, uid) + if err != nil { + return nil, err + } + if in { + return nil, &http.ResponseError{ + Code: 409, + Message: "You are already in this tournament", + } + } + + // Check whether we're busy with another tournament + busy, err := db.UserIsBusy(tourn, uid) + if err != nil { + return nil, err + } + if busy { + return nil, &http.ResponseError{ + Code: 409, + Message: "You can't join another tournament that overlaps with a tournament you're already in!", + } + } + + return tourn, nil +} + // TeamMembers retrieves all the members of a team. func TeamMembers(c *http.Context) { members, err := c.DB.TeamMembers(c.ParamID("id"), c.QueryInt("p")) @@ -53,6 +185,7 @@ func TeamMembers(c *http.Context) { func init() { http.GET("/tournaments/:id/teams", TeamsInTournament) + http.POST("/tournaments/:id/teams", CreateTeam) http.GET("/teams", Teams) http.GET("/teams/:id", Team) http.GET("/teams/:id/members", TeamMembers) diff --git a/http/http.go b/http/http.go index 7364d80..64c3879 100644 --- a/http/http.go +++ b/http/http.go @@ -134,10 +134,26 @@ func (c *Context) reportError(err error) { fmt.Fprintln(os.Stderr, err) } +// ResponseError can be passed to Error, and instead of returning a 500, it will +// return a response with the code and the message specified. +type ResponseError struct { + Code int + Message string +} + +func (re *ResponseError) Error() string { + return re.Message +} + // Error closes the request with a 500 code and prints the error to stderr. func (c *Context) Error(err error) { + if re, ok := err.(*ResponseError); ok { + c.SetCode(re.Code) + c.WriteString(re.Message + "\n") + return + } c.SetCode(500) - c.WriteString("Internal Server Error") + c.WriteString("Internal Server Error\n") c.reportError(err) } diff --git a/models/id.go b/models/id.go index 0acda8e..d9eed80 100644 --- a/models/id.go +++ b/models/id.go @@ -2,8 +2,11 @@ package models import ( "encoding/base64" + "reflect" "sync/atomic" "time" + + "github.com/jinzhu/gorm" ) // ID is the ID of a single resource. IDs are similar to Snowflake IDs, in that @@ -118,3 +121,31 @@ func (i ID) String() string { func (i ID) Time() time.Time { return time.Unix(0, int64((i<<4)&timeBits)) } + +// Register gorm callback for generating IDs when they are blank. +func init() { + gorm.DefaultCallback.Create().Before("gorm:before_create"). + Register("snowflake:generate_id", generateID) +} + +var idType = reflect.TypeOf(ID(0)) + +func generateID(scope *gorm.Scope) { + if scope.HasError() { + return + } + field, ok := scope.FieldByName("ID") + if !ok { + return + } + if !field.IsBlank { + return + } + if !idType.AssignableTo(field.Field.Type()) { + return + } + err := field.Set(GenerateID()) + if err != nil { + scope.Err(err) + } +} diff --git a/models/session.go b/models/session.go index 2f50832..2ad9f2d 100644 --- a/models/session.go +++ b/models/session.go @@ -1,6 +1,10 @@ package models -import "errors" +import ( + "errors" + + "zxq.co/x/ripple" +) // Session represents a single user session of an user who has authenticated // on Misirlou. @@ -28,3 +32,12 @@ func (db *DB) SetSession(sess *Session) error { } return db.db.Save(sess).Error } + +// RippleClient obtains a new ripple.Client to fetch information about the +// user. +func (s Session) RippleClient() *ripple.Client { + return &ripple.Client{ + IsBearer: true, + Token: s.AccessToken, + } +} diff --git a/models/team.go b/models/team.go index ddd6fdc..fc68175 100644 --- a/models/team.go +++ b/models/team.go @@ -4,12 +4,10 @@ package models type Team struct { ID ID `json:"id"` Name string `json:"name"` - Tournament int `json:"tournament"` + Tournament ID `json:"tournament"` Captain int `json:"captain"` } -// TODO: we should check that the team's tournament status is not 0. - // TeamFilters are options that can be passed to Teams for filtering teams. type TeamFilters struct { Tournament ID @@ -41,12 +39,28 @@ func (db *DB) Team(id ID) (*Team, error) { return &t, nil } +// CreateTeam creates a new team, which is to say, registers in a tournament. +func (db *DB) CreateTeam(t *Team) error { + return db.db.Create(t).Error +} + +// TeamAttributes represents the attributes a user may have inside of a team, +// such as being invited, a member, or a captain. +type TeamAttributes int + +// Various TeamAttributes a team member might have. +const ( + TeamAttributeInvited TeamAttributes = iota + TeamAttributeMember + TeamAttributeCaptain +) + // TeamMember represents a member of a team and information about their // relationship to the team. type TeamMember struct { - Team int `json:"team"` - User int `json:"user"` - Attributes int `json:"attributes"` + Team ID `json:"team"` + User int `json:"user"` + Attributes TeamAttributes `json:"attributes"` } // TableName returns the correct table name so that it can correctly be used @@ -62,3 +76,15 @@ func (db *DB) TeamMembers(teamID ID, page int) ([]TeamMember, error) { Find(&members, "team = ?", teamID).Error return members, err } + +// AddTeamMembers adds the given team members to the database. +func (db *DB) AddTeamMembers(ms []TeamMember) error { + // not using for-range because this way we can keep the reference + // straight into the slice + for i := 0; i < len(ms); i++ { + if err := db.db.Create(&ms[i]).Error; err != nil { + return err + } + } + return nil +} diff --git a/models/tournament.go b/models/tournament.go index 717eb67..311100b 100644 --- a/models/tournament.go +++ b/models/tournament.go @@ -104,10 +104,45 @@ func (db *DB) Tournament(id ID) (*Tournament, error) { return &t, nil } +// UserInTournament checks whether an user is taking part in a tournament. +func (db *DB) UserInTournament(tournID ID, userID int) (bool, error) { + var i []int + err := db.db.Raw(`SELECT COUNT(*) as c FROM teams + INNER JOIN team_users ON teams.id = team_users.team + WHERE teams.tournament = ? AND team_users.user = ? AND team_users.attributes > 0 + LIMIT 1`, tournID, userID).Pluck("c", &i).Error + return len(i) > 0 && i[0] > 0, err +} + +// UserIsBusy checks whether the user is 'busy' with another tournament during +// the period in which they would play in the tournament. +func (db *DB) UserIsBusy(tourn *Tournament, userID int) (bool, error) { + var i []int + // We check if another tournament overlaps with ours. + // In plain words: we check if the given user (team_users.user = ?) + // takes part (team_users.attributes > 0) in another (tournaments.id != ?) + // tournament that begins while our tournament is in session OR + // our tournament begins while another tournament is in session. + err := db.db.Raw( + `SELECT COUNT(*) as c FROM team_users + INNER JOIN teams ON teams.id = team_users.team + INNER JOIN tournaments ON teams.tournament = tournaments.id + WHERE + team_users.user = ? AND team_users.attributes > 0 AND + tournaments.id != ? AND + ((tournaments.exclusivity_starts >= ? AND tournaments.exclusivity_starts <= ?) OR + (tournaments.exclusivity_ends >= ? AND tournaments.exclusivity_starts <= ?)) + LIMIT 1`, + userID, tourn.ID, tourn.ExclusivityStarts, tourn.ExclusivityEnds, + tourn.ExclusivityStarts, tourn.ExclusivityStarts, + ).Pluck("c", &i).Error + return len(i) > 0 && i[0] > 0, err +} + // TournamentRules represents a collection rules set out for a given tournament, // which is represented by the ID field in the struct. type TournamentRules struct { - ID int `json:"id"` + ID ID `json:"id"` Rules string `json:"rules"` } @@ -136,6 +171,6 @@ func (db *DB) TournamentRules(id ID) (*TournamentRules, error) { // tournament. type TournamentStaff struct { ID int `json:"id"` - Tournament int `json:"tournament"` + Tournament ID `json:"tournament"` Privileges int `json:"privileges"` }