diff --git a/.gitignore b/.gitignore index 4a58fe6..73f72c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store node_modules +.idea /build /.svelte-kit /package diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..ac865af --- /dev/null +++ b/api/api.go @@ -0,0 +1,86 @@ +//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen@v1.8.2 --package=api --generate types -o types.gen.go ../schema/schema.yaml +//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen@v1.8.2 --package=api --generate chi-server -o routes.gen.go ../schema/schema.yaml + +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/hhhapz/hackathon/models" + "golang.org/x/oauth2" +) + +// inProd tells the server whether it is in prod. +// It should be set using ldflags on build time. +var inProd bool + +type Server struct { + *AuthService + *UserService +} + +type OAuthStore interface { + Create(callback string) (code string) + Validate(code string) (string, bool) + Exchange(ctx context.Context, code string) (*oauth2.Token, error) + Remove(code string) +} + +type UserStore interface { + CreateToken(ctx context.Context, user *models.User) (*models.Token, error) + RevokeToken(ctx context.Context, token string) error + RevokeUserTokens(ctx context.Context, user *models.User) (int64, error) + + User(ctx context.Context, email string) (*models.User, error) + Users(ctx context.Context) ([]*models.User, error) + + DecodeUser(ctx context.Context, buf []byte) (*models.User, error) + UserByToken(ctx context.Context, token string) (*models.User, error) + + UpdateUser(ctx context.Context, user *models.User) error +} + +var _ ServerInterface = (*Server)(nil) + +func NewServer(oaStore OAuthStore, userStore UserStore) *Server { + return &Server{ + AuthService: &AuthService{ + oauthStore: oaStore, + userStore: userStore, + }, + UserService: &UserService{ + userStore: userStore, + }, + } +} + +func serverError(w http.ResponseWriter, code int, message string) { + w.WriteHeader(code) + json.NewEncoder(w).Encode(Error{ + Code: code, + Message: message, + }) +} + +type parseError struct { + typ string + value string + reason string + message string +} + +func newParseError(typ, value, reason string) error { + return parseError{ + typ: typ, + value: value, + reason: reason, + message: "invalid %s(%q): %s", + } +} + +func (err parseError) Error() string { + return fmt.Sprintf(err.message, err.typ, err.value, err.reason) +} diff --git a/api/auth.go b/api/auth.go index d988e24..ce409b2 100644 --- a/api/auth.go +++ b/api/auth.go @@ -3,20 +3,22 @@ package api import ( "context" "encoding/json" + "errors" "fmt" "io" "log" "net/http" + "strconv" + "strings" "time" - "github.com/hhhapz/hackathon/auth" + "github.com/hhhapz/hackathon/models" "golang.org/x/oauth2" ) type AuthService struct { - oauthConfig auth.OAuthConfig - oauthStore OAuthStore - userStore UserStore + oauthStore OAuthStore + userStore UserStore } func (as *AuthService) GenOauth(w http.ResponseWriter, r *http.Request, params GenOauthParams) { @@ -25,7 +27,7 @@ func (as *AuthService) GenOauth(w http.ResponseWriter, r *http.Request, params G serverError(w, http.StatusUnprocessableEntity, "invalid callback provided") } - page := as.oauthConfig.AuthCodeURL(as.oauthStore, params.Callback) + page := as.oauthStore.Create(params.Callback) w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") @@ -35,21 +37,30 @@ func (as *AuthService) GenOauth(w http.ResponseWriter, r *http.Request, params G var couldNotValidate = "Unable to authorize. Please try again.\nIf this issue persists, please contact an admin." func (as *AuthService) AuthorizeCallback(w http.ResponseWriter, r *http.Request, params AuthorizeCallbackParams) { - callback, token, err := as.oauthConfig.Exchange(r.Context(), as.oauthStore, params.State, params.Code) - if err == auth.ErrInvalidState { + callback, ok := as.oauthStore.Validate(params.State) + if !ok { serverError(w, http.StatusUnprocessableEntity, "Invalid state provided") return } + + token, err := as.oauthStore.Exchange(r.Context(), params.Code) if err != nil { - log.Printf("error: could not perform token: %v", err) + log.Printf("error: could not perform token exchange: %v", err) serverError(w, http.StatusInternalServerError, couldNotValidate) return } accessToken, err := as.consumeToken(r.Context(), token) + + reason := "serverError" + if errors.As(err, &parseError{}) { + reason = "email" + } + if err != nil { log.Printf("error: could not consume oauth token: %v", err) - http.Redirect(w, r, callback+"/fail", http.StatusFound) + uri := fmt.Sprintf("%s/fail?reason=%s", callback, reason) + http.Redirect(w, r, uri, http.StatusFound) return } @@ -58,7 +69,7 @@ func (as *AuthService) AuthorizeCallback(w http.ResponseWriter, r *http.Request, Value: accessToken, Path: "/", Expires: time.Now().Add(time.Hour * 24 * 14), // 2 weeks - SameSite: http.SameSiteNoneMode, // handled via CSRF + SameSite: http.SameSiteNoneMode, // handled via CORS Secure: inProd, }) http.Redirect(w, r, callback+"/success", http.StatusFound) @@ -68,27 +79,48 @@ const userinfoEndpoint = "https://www.googleapis.com/oauth2/v2/userinfo?access_t func (as *AuthService) consumeToken(ctx context.Context, token *oauth2.Token) (string, error) { endpoint := userinfoEndpoint + token.AccessToken - client := as.oauthConfig.Client(ctx, token) + client := http.Client{Timeout: time.Second * 5} res, err := client.Get(endpoint) if err != nil { - return "", fmt.Errorf("could not get userinfo: %v", err) + return "", fmt.Errorf("could not get userinfo: %w", err) } defer res.Body.Close() buf, err := io.ReadAll(res.Body) if err != nil { - return "", fmt.Errorf("could not read request body: %v", err) + return "", fmt.Errorf("could not read request body: %w", err) } - user, err := as.userStore.DecodeUser(ctx, buf) + user := &models.User{ + CreatedAt: models.NewTime(time.Now()), + } + if err := json.Unmarshal(buf, &user); err != nil { + return "", fmt.Errorf("could not decode userinfo json: %w", err) + } + + if u, err := as.userStore.User(ctx, user.Email); err == nil { + var tk *models.Token + if tk, err = as.userStore.CreateToken(ctx, u); err != nil { + return "", fmt.Errorf("could not create user token: %w", err) + } + return tk.Token, nil + } + + student, valid := parseEmail(user.Email) + if !valid { + return "", newParseError("email", user.Email, "invalid email domain") + } + + user.Teacher = !student + err = as.userStore.UpdateUser(ctx, user) if err != nil { - return "", fmt.Errorf("could not create or get user from body: %v", err) + return "", fmt.Errorf("could not create user: %w", err) } tk, err := as.userStore.CreateToken(ctx, user) if err != nil { - return "", fmt.Errorf("could not create user token: %v", err) + return "", fmt.Errorf("could not create user token: %w", err) } return tk.Token, nil @@ -121,3 +153,18 @@ func (as *AuthService) DeleteToken(w http.ResponseWriter, r *http.Request, param w.WriteHeader(http.StatusNoContent) } + +const domain = "jisedu.or.id" + +// parseEmail parses the provided email, and returns two bools; +// 1. if the email belongs to a student: it conforms to `\d+@[domain]`, and +// 2. if it's a valid internal email (ends with `domain`). +func parseEmail(email string) (bool, bool) { + parts := strings.Split(email, "@") + if len(parts) != 2 || parts[1] != domain { + return false, false + } + + _, err := strconv.Atoi(parts[1]) + return err == nil, true +} diff --git a/api/hackathon.gen.go b/api/hackathon.gen.go deleted file mode 100644 index aff7e27..0000000 --- a/api/hackathon.gen.go +++ /dev/null @@ -1,341 +0,0 @@ -// Package api provides primitives to interact with the openapi HTTP API. -// -// Code generated by github.com/deepmap/oapi-codegen version v1.8.2 DO NOT EDIT. -package api - -import ( - "bytes" - "compress/gzip" - "encoding/base64" - "fmt" - "net/http" - "net/url" - "path" - "strings" - - "github.com/deepmap/oapi-codegen/pkg/runtime" - "github.com/getkin/kin-openapi/openapi3" - "github.com/go-chi/chi/v5" -) - -// ConsentPage defines model for ConsentPage. -type ConsentPage struct { - Url string `json:"url"` -} - -// Error defines model for Error. -type Error struct { - Code int `json:"code"` - Message string `json:"message"` -} - -// DefaultResponse defines model for DefaultResponse. -type DefaultResponse Error - -// AuthorizeCallbackParams defines parameters for AuthorizeCallback. -type AuthorizeCallbackParams struct { - State string `json:"state"` - Code string `json:"code"` -} - -// GenOauthParams defines parameters for GenOauth. -type GenOauthParams struct { - Callback string `json:"callback"` -} - -// DeleteTokenParams defines parameters for DeleteToken. -type DeleteTokenParams struct { - All *bool `json:"all,omitempty"` - - // User authentication token - Token string `json:"token"` -} - -// ServerInterface represents all server handlers. -type ServerInterface interface { - - // (GET /auth/authorize) - AuthorizeCallback(w http.ResponseWriter, r *http.Request, params AuthorizeCallbackParams) - - // (GET /auth/code) - GenOauth(w http.ResponseWriter, r *http.Request, params GenOauthParams) - - // (DELETE /auth/token) - DeleteToken(w http.ResponseWriter, r *http.Request, params DeleteTokenParams) -} - -// ServerInterfaceWrapper converts contexts to parameters. -type ServerInterfaceWrapper struct { - Handler ServerInterface - HandlerMiddlewares []MiddlewareFunc -} - -type MiddlewareFunc func(http.HandlerFunc) http.HandlerFunc - -// AuthorizeCallback operation middleware -func (siw *ServerInterfaceWrapper) AuthorizeCallback(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params AuthorizeCallbackParams - - // ------------- Required query parameter "state" ------------- - if paramValue := r.URL.Query().Get("state"); paramValue != "" { - - } else { - http.Error(w, "Query argument state is required, but not found", http.StatusBadRequest) - return - } - - err = runtime.BindQueryParameter("form", true, true, "state", r.URL.Query(), ¶ms.State) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid format for parameter state: %s", err), http.StatusBadRequest) - return - } - - // ------------- Required query parameter "code" ------------- - if paramValue := r.URL.Query().Get("code"); paramValue != "" { - - } else { - http.Error(w, "Query argument code is required, but not found", http.StatusBadRequest) - return - } - - err = runtime.BindQueryParameter("form", true, true, "code", r.URL.Query(), ¶ms.Code) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid format for parameter code: %s", err), http.StatusBadRequest) - return - } - - var handler = func(w http.ResponseWriter, r *http.Request) { - siw.Handler.AuthorizeCallback(w, r, params) - } - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler(w, r.WithContext(ctx)) -} - -// GenOauth operation middleware -func (siw *ServerInterfaceWrapper) GenOauth(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params GenOauthParams - - // ------------- Required query parameter "callback" ------------- - if paramValue := r.URL.Query().Get("callback"); paramValue != "" { - - } else { - http.Error(w, "Query argument callback is required, but not found", http.StatusBadRequest) - return - } - - err = runtime.BindQueryParameter("form", true, true, "callback", r.URL.Query(), ¶ms.Callback) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid format for parameter callback: %s", err), http.StatusBadRequest) - return - } - - var handler = func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GenOauth(w, r, params) - } - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler(w, r.WithContext(ctx)) -} - -// DeleteToken operation middleware -func (siw *ServerInterfaceWrapper) DeleteToken(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params DeleteTokenParams - - // ------------- Optional query parameter "all" ------------- - if paramValue := r.URL.Query().Get("all"); paramValue != "" { - - } - - err = runtime.BindQueryParameter("form", true, false, "all", r.URL.Query(), ¶ms.All) - if err != nil { - http.Error(w, fmt.Sprintf("Invalid format for parameter all: %s", err), http.StatusBadRequest) - return - } - - var cookie *http.Cookie - - if cookie, err = r.Cookie("token"); err == nil { - var value string - err = runtime.BindStyledParameter("simple", true, "token", cookie.Value, &value) - if err != nil { - http.Error(w, "Invalid format for parameter token: %s", http.StatusBadRequest) - return - } - params.Token = value - - } else { - http.Error(w, "Query argument token is required, but not found", http.StatusBadRequest) - return - } - - var handler = func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteToken(w, r, params) - } - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler(w, r.WithContext(ctx)) -} - -// Handler creates http.Handler with routing matching OpenAPI spec. -func Handler(si ServerInterface) http.Handler { - return HandlerWithOptions(si, ChiServerOptions{}) -} - -type ChiServerOptions struct { - BaseURL string - BaseRouter chi.Router - Middlewares []MiddlewareFunc -} - -// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. -func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { - return HandlerWithOptions(si, ChiServerOptions{ - BaseRouter: r, - }) -} - -func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { - return HandlerWithOptions(si, ChiServerOptions{ - BaseURL: baseURL, - BaseRouter: r, - }) -} - -// HandlerWithOptions creates http.Handler with additional options -func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { - r := options.BaseRouter - - if r == nil { - r = chi.NewRouter() - } - wrapper := ServerInterfaceWrapper{ - Handler: si, - HandlerMiddlewares: options.Middlewares, - } - - r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/auth/authorize", wrapper.AuthorizeCallback) - }) - r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/auth/code", wrapper.GenOauth) - }) - r.Group(func(r chi.Router) { - r.Delete(options.BaseURL+"/auth/token", wrapper.DeleteToken) - }) - - return r -} - -// Base64 encoded, gzipped, json marshaled Swagger object -var swaggerSpec = []string{ - - "H4sIAAAAAAAC/7RVy1P7NhD+VzTbHl3bBU4+lYFOSYcODI8Tw0GRN7aILAlpDQ2Z/O+dlZ0HJC1Mh98F", - "7PU+vv2+3c0SlOu8s2gpQrWEgNE7GzG9nONM9oZuRhublLOElvhRem+0kqSdLZ6is2yLqsVO8tPPAWdQ", - "wU/FNn8xfI3F7yG4AKvVKoMaowracxKo4N7i3x4VYS0ihhcMAtlVuCC0fZFG16KPyC++J+D4MSVXPGOM", - "lq5lk5D64DwG0kMrfTD8b+ZCJwkq6IOGDGjhESqIFLRtUr6Az70OWEP1kGIeN05u+oSKYJXBAH+vhHJ1", - "KlwPrEF1UpabaG0JGwwc3mGMI8b/rp8Sbv33oXCAtjO3FkaqJAx2UhuooMPfWtm9SUvS5P4VMrCy4/gL", - "topTo2FPgVPRSjWX1DqbidZFlkJb8efkVpxeT0T0qPRsVF2ogJIdpguxSZlDBkYrHOdlrPjX5E5cjtZs", - "EANaIh+rokgY807TL2Nc7kJTMDTSZDj69lU2DQZxsYYGGbxgiAPkMv81L9ndebTSa6jgOC/zEjLwktqk", - "TSF7atMfF/RbQtZgIosVTN1Mam5/7XEmjZlKNU9JguyQMESoHpagueZzj2GxJTSSJG5sqx6FHrOdddhT", - "+nCiUfKv53nM3q/scXk0zOCuqDdY64CKBDnxilPP05RBi7JOTS3h0g2Kvl/hT5ZlWN9x1g8v/AZa8fGU", - "cDDJhilNpMMjWwaZ1ot0UKE/0F6x19eEUVsZ/53TT/r8SPFRWX7bJdy9WQfu4RVTI0YnwV7i/mYCP4p4", - "cnO0w/gYJNwn/zzZ75Lbl/iXxsAu1RvYM2kibrieOmdQ2mEvPvwk8L1ndGhpfXdoBJAqKufmGrcl1x//", - "/w4dlSf7O5RwxF4pjHHWG7MQxjUN1sL19O2CrFb/BAAA//9U4MuzmAcAAA==", -} - -// GetSwagger returns the content of the embedded swagger specification file -// or error if failed to decode -func decodeSpec() ([]byte, error) { - zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) - if err != nil { - return nil, fmt.Errorf("error base64 decoding spec: %s", err) - } - zr, err := gzip.NewReader(bytes.NewReader(zipped)) - if err != nil { - return nil, fmt.Errorf("error decompressing spec: %s", err) - } - var buf bytes.Buffer - _, err = buf.ReadFrom(zr) - if err != nil { - return nil, fmt.Errorf("error decompressing spec: %s", err) - } - - return buf.Bytes(), nil -} - -var rawSpec = decodeSpecCached() - -// a naive cached of a decoded swagger spec -func decodeSpecCached() func() ([]byte, error) { - data, err := decodeSpec() - return func() ([]byte, error) { - return data, err - } -} - -// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. -func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { - var res = make(map[string]func() ([]byte, error)) - if len(pathToFile) > 0 { - res[pathToFile] = rawSpec - } - - return res -} - -// GetSwagger returns the Swagger specification corresponding to the generated code -// in this file. The external references of Swagger specification are resolved. -// The logic of resolving external references is tightly connected to "import-mapping" feature. -// Externally referenced files must be embedded in the corresponding golang packages. -// Urls can be supported but this task was out of the scope. -func GetSwagger() (swagger *openapi3.T, err error) { - var resolvePath = PathToRawSpec("") - - loader := openapi3.NewLoader() - loader.IsExternalRefsAllowed = true - loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { - var pathToFile = url.String() - pathToFile = path.Clean(pathToFile) - getSpec, ok := resolvePath[pathToFile] - if !ok { - err1 := fmt.Errorf("path not found: %s", pathToFile) - return nil, err1 - } - return getSpec() - } - var specData []byte - specData, err = rawSpec() - if err != nil { - return - } - swagger, err = loader.LoadFromData(specData) - if err != nil { - return - } - return -} diff --git a/api/hackathon.go b/api/hackathon.go deleted file mode 100644 index fc394bc..0000000 --- a/api/hackathon.go +++ /dev/null @@ -1,55 +0,0 @@ -//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen@v1.8.2 --package=api --generate types,chi-server,spec -o hackathon.gen.go ../schema/schema.yaml - -package api - -import ( - "context" - "encoding/json" - "net/http" - - "github.com/hhhapz/hackathon/auth" - "github.com/hhhapz/hackathon/models" -) - -// inProd tells the server whether it is in prod. -// It should be set using ldflags on build time. -var inProd bool - -type Server struct { - *AuthService -} - -type OAuthStore interface { - Create(callback string) (code string) - Validate(code string) (callback string, valid bool) - Remove(code string) -} - -type UserStore interface { - CreateToken(ctx context.Context, user *models.User) (*models.Token, error) - RevokeToken(ctx context.Context, token string) error - RevokeUserTokens(ctx context.Context, user *models.User) (int64, error) - - DecodeUser(ctx context.Context, buf []byte) (*models.User, error) - UserByToken(ctx context.Context, token string) (*models.User, error) -} - -var _ ServerInterface = (*Server)(nil) - -func NewServer(oaConfig auth.OAuthConfig, oaStore OAuthStore, userStore UserStore) *Server { - return &Server{ - AuthService: &AuthService{ - oauthConfig: oaConfig, - oauthStore: oaStore, - userStore: userStore, - }, - } -} - -func serverError(w http.ResponseWriter, code int, message string) { - w.WriteHeader(code) - json.NewEncoder(w).Encode(Error{ - Code: code, - Message: message, - }) -} diff --git a/api/routes.gen.go b/api/routes.gen.go new file mode 100644 index 0000000..7e056a0 --- /dev/null +++ b/api/routes.gen.go @@ -0,0 +1,450 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.8.2 DO NOT EDIT. +package api + +import ( + "fmt" + "net/http" + + "github.com/deepmap/oapi-codegen/pkg/runtime" + "github.com/go-chi/chi/v5" +) + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (GET /auth/authorize) + AuthorizeCallback(w http.ResponseWriter, r *http.Request, params AuthorizeCallbackParams) + + // (GET /auth/code) + GenOauth(w http.ResponseWriter, r *http.Request, params GenOauthParams) + + // (DELETE /auth/token) + DeleteToken(w http.ResponseWriter, r *http.Request, params DeleteTokenParams) + + // (GET /users/all) + GetAllUsers(w http.ResponseWriter, r *http.Request, params GetAllUsersParams) + + // (GET /users/email) + GetUserByEmail(w http.ResponseWriter, r *http.Request, params GetUserByEmailParams) + + // (PATCH /users/email) + ModifyOtherUser(w http.ResponseWriter, r *http.Request, params ModifyOtherUserParams) + + // (GET /users/me) + GetMe(w http.ResponseWriter, r *http.Request, params GetMeParams) + + // (PATCH /users/me) + ModifyUser(w http.ResponseWriter, r *http.Request, params ModifyUserParams) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc +} + +type MiddlewareFunc func(http.HandlerFunc) http.HandlerFunc + +// AuthorizeCallback operation middleware +func (siw *ServerInterfaceWrapper) AuthorizeCallback(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params AuthorizeCallbackParams + + // ------------- Required query parameter "state" ------------- + if paramValue := r.URL.Query().Get("state"); paramValue != "" { + + } else { + http.Error(w, "Query argument state is required, but not found", http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameter("form", true, true, "state", r.URL.Query(), ¶ms.State) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter state: %s", err), http.StatusBadRequest) + return + } + + // ------------- Required query parameter "code" ------------- + if paramValue := r.URL.Query().Get("code"); paramValue != "" { + + } else { + http.Error(w, "Query argument code is required, but not found", http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameter("form", true, true, "code", r.URL.Query(), ¶ms.Code) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter code: %s", err), http.StatusBadRequest) + return + } + + var handler = func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthorizeCallback(w, r, params) + } + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler(w, r.WithContext(ctx)) +} + +// GenOauth operation middleware +func (siw *ServerInterfaceWrapper) GenOauth(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GenOauthParams + + // ------------- Required query parameter "callback" ------------- + if paramValue := r.URL.Query().Get("callback"); paramValue != "" { + + } else { + http.Error(w, "Query argument callback is required, but not found", http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameter("form", true, true, "callback", r.URL.Query(), ¶ms.Callback) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter callback: %s", err), http.StatusBadRequest) + return + } + + var handler = func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GenOauth(w, r, params) + } + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler(w, r.WithContext(ctx)) +} + +// DeleteToken operation middleware +func (siw *ServerInterfaceWrapper) DeleteToken(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params DeleteTokenParams + + // ------------- Optional query parameter "all" ------------- + if paramValue := r.URL.Query().Get("all"); paramValue != "" { + + } + + err = runtime.BindQueryParameter("form", true, false, "all", r.URL.Query(), ¶ms.All) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter all: %s", err), http.StatusBadRequest) + return + } + + var cookie *http.Cookie + + if cookie, err = r.Cookie("token"); err == nil { + var value string + err = runtime.BindStyledParameter("simple", true, "token", cookie.Value, &value) + if err != nil { + http.Error(w, "Invalid format for parameter token: %s", http.StatusBadRequest) + return + } + params.Token = value + + } else { + http.Error(w, "Query argument token is required, but not found", http.StatusBadRequest) + return + } + + var handler = func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DeleteToken(w, r, params) + } + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler(w, r.WithContext(ctx)) +} + +// GetAllUsers operation middleware +func (siw *ServerInterfaceWrapper) GetAllUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetAllUsersParams + + var cookie *http.Cookie + + if cookie, err = r.Cookie("token"); err == nil { + var value string + err = runtime.BindStyledParameter("simple", true, "token", cookie.Value, &value) + if err != nil { + http.Error(w, "Invalid format for parameter token: %s", http.StatusBadRequest) + return + } + params.Token = value + + } else { + http.Error(w, "Query argument token is required, but not found", http.StatusBadRequest) + return + } + + var handler = func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetAllUsers(w, r, params) + } + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler(w, r.WithContext(ctx)) +} + +// GetUserByEmail operation middleware +func (siw *ServerInterfaceWrapper) GetUserByEmail(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetUserByEmailParams + + // ------------- Required query parameter "email" ------------- + if paramValue := r.URL.Query().Get("email"); paramValue != "" { + + } else { + http.Error(w, "Query argument email is required, but not found", http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameter("form", true, true, "email", r.URL.Query(), ¶ms.Email) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter email: %s", err), http.StatusBadRequest) + return + } + + var cookie *http.Cookie + + if cookie, err = r.Cookie("token"); err == nil { + var value string + err = runtime.BindStyledParameter("simple", true, "token", cookie.Value, &value) + if err != nil { + http.Error(w, "Invalid format for parameter token: %s", http.StatusBadRequest) + return + } + params.Token = value + + } else { + http.Error(w, "Query argument token is required, but not found", http.StatusBadRequest) + return + } + + var handler = func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetUserByEmail(w, r, params) + } + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler(w, r.WithContext(ctx)) +} + +// ModifyOtherUser operation middleware +func (siw *ServerInterfaceWrapper) ModifyOtherUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params ModifyOtherUserParams + + // ------------- Required query parameter "email" ------------- + if paramValue := r.URL.Query().Get("email"); paramValue != "" { + + } else { + http.Error(w, "Query argument email is required, but not found", http.StatusBadRequest) + return + } + + err = runtime.BindQueryParameter("form", true, true, "email", r.URL.Query(), ¶ms.Email) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid format for parameter email: %s", err), http.StatusBadRequest) + return + } + + var cookie *http.Cookie + + if cookie, err = r.Cookie("token"); err == nil { + var value string + err = runtime.BindStyledParameter("simple", true, "token", cookie.Value, &value) + if err != nil { + http.Error(w, "Invalid format for parameter token: %s", http.StatusBadRequest) + return + } + params.Token = value + + } else { + http.Error(w, "Query argument token is required, but not found", http.StatusBadRequest) + return + } + + var handler = func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ModifyOtherUser(w, r, params) + } + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler(w, r.WithContext(ctx)) +} + +// GetMe operation middleware +func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetMeParams + + var cookie *http.Cookie + + if cookie, err = r.Cookie("token"); err == nil { + var value string + err = runtime.BindStyledParameter("simple", true, "token", cookie.Value, &value) + if err != nil { + http.Error(w, "Invalid format for parameter token: %s", http.StatusBadRequest) + return + } + params.Token = value + + } else { + http.Error(w, "Query argument token is required, but not found", http.StatusBadRequest) + return + } + + var handler = func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetMe(w, r, params) + } + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler(w, r.WithContext(ctx)) +} + +// ModifyUser operation middleware +func (siw *ServerInterfaceWrapper) ModifyUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params ModifyUserParams + + var cookie *http.Cookie + + if cookie, err = r.Cookie("token"); err == nil { + var value string + err = runtime.BindStyledParameter("simple", true, "token", cookie.Value, &value) + if err != nil { + http.Error(w, "Invalid format for parameter token: %s", http.StatusBadRequest) + return + } + params.Token = value + + } else { + http.Error(w, "Query argument token is required, but not found", http.StatusBadRequest) + return + } + + var handler = func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ModifyUser(w, r, params) + } + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler(w, r.WithContext(ctx)) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + } + + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/auth/authorize", wrapper.AuthorizeCallback) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/auth/code", wrapper.GenOauth) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/auth/token", wrapper.DeleteToken) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/users/all", wrapper.GetAllUsers) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/users/email", wrapper.GetUserByEmail) + }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/users/email", wrapper.ModifyOtherUser) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/users/me", wrapper.GetMe) + }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/users/me", wrapper.ModifyUser) + }) + + return r +} diff --git a/api/types.gen.go b/api/types.gen.go new file mode 100644 index 0000000..a4ec406 --- /dev/null +++ b/api/types.gen.go @@ -0,0 +1,115 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.8.2 DO NOT EDIT. +package api + +import ( + "time" + + openapi_types "github.com/deepmap/oapi-codegen/pkg/types" +) + +// ConsentPage defines model for ConsentPage. +type ConsentPage struct { + Url string `json:"url"` +} + +// Error defines model for Error. +type Error struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// User defines model for User. +type User struct { + Admin bool `json:"admin"` + CreatedAt time.Time `json:"created_at"` + Email openapi_types.Email `json:"email"` + + // GradeLevel is only present if teacher is false. + GradeLevel *int `json:"grade_level,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + Picture string `json:"picture"` + Teacher bool `json:"teacher"` +} + +// DefaultResponse defines model for DefaultResponse. +type DefaultResponse Error + +// AuthorizeCallbackParams defines parameters for AuthorizeCallback. +type AuthorizeCallbackParams struct { + State string `json:"state"` + Code string `json:"code"` +} + +// GenOauthParams defines parameters for GenOauth. +type GenOauthParams struct { + Callback string `json:"callback"` +} + +// DeleteTokenParams defines parameters for DeleteToken. +type DeleteTokenParams struct { + All *bool `json:"all,omitempty"` + + // User authentication token. + Token string `json:"token"` +} + +// GetAllUsersParams defines parameters for GetAllUsers. +type GetAllUsersParams struct { + // User authentication token. + Token string `json:"token"` +} + +// GetUserByEmailParams defines parameters for GetUserByEmail. +type GetUserByEmailParams struct { + // User email. + Email openapi_types.Email `json:"email"` + + // User authentication token. + Token string `json:"token"` +} + +// ModifyOtherUserJSONBody defines parameters for ModifyOtherUser. +type ModifyOtherUserJSONBody struct { + Admin bool `json:"admin"` + GradeLevel int `json:"grade_level"` + Name string `json:"name"` + NewEmail *openapi_types.Email `json:"new_email,omitempty"` + Picture string `json:"picture"` + Teacher bool `json:"teacher"` +} + +// ModifyOtherUserParams defines parameters for ModifyOtherUser. +type ModifyOtherUserParams struct { + // User email. + Email openapi_types.Email `json:"email"` + + // User authentication token. + Token string `json:"token"` +} + +// GetMeParams defines parameters for GetMe. +type GetMeParams struct { + // User authentication token. + Token string `json:"token"` +} + +// ModifyUserJSONBody defines parameters for ModifyUser. +type ModifyUserJSONBody struct { + GradeLevel int `json:"grade_level"` + Name string `json:"name"` +} + +// ModifyUserParams defines parameters for ModifyUser. +type ModifyUserParams struct { + // User authentication token. + Token string `json:"token"` +} + +// ModifyOtherUserJSONRequestBody defines body for ModifyOtherUser for application/json ContentType. +type ModifyOtherUserJSONRequestBody ModifyOtherUserJSONBody + +// ModifyUserJSONRequestBody defines body for ModifyUser for application/json ContentType. +type ModifyUserJSONRequestBody ModifyUserJSONBody diff --git a/api/user.go b/api/user.go new file mode 100644 index 0000000..0cfbe8e --- /dev/null +++ b/api/user.go @@ -0,0 +1,210 @@ +package api + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + "strings" + + "github.com/deepmap/oapi-codegen/pkg/types" + "github.com/hhhapz/hackathon/models" +) + +type UserService struct { + userStore UserStore +} + +func (us *UserService) GetMe(w http.ResponseWriter, r *http.Request, params GetMeParams) { + u, err := us.userStore.UserByToken(r.Context(), params.Token) + if err != nil { + serverError(w, http.StatusUnauthorized, "Invalid token provided") + return + } + + writeUser(w, u) +} + +func (us *UserService) ModifyUser(w http.ResponseWriter, r *http.Request, params ModifyUserParams) { + u, err := us.userStore.UserByToken(r.Context(), params.Token) + if err != nil { + serverError(w, http.StatusUnauthorized, "Invalid token provided") + return + } + + var body ModifyUserJSONBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + serverError(w, http.StatusBadRequest, "Invalid JSON body for ModifyUser") + return + } + + u.Name = strings.TrimSpace(body.Name) + if len(u.Name) < 3 || len(u.Name) > 30 { + serverError(w, http.StatusUnprocessableEntity, "Name must be between 3 and 30 characters long") + return + } + if !u.Teacher { + if body.GradeLevel < 9 || body.GradeLevel > 12 { + serverError(w, http.StatusUnprocessableEntity, "Grade Level must be between 9 and 12") + return + } + u.GradeLevel = sql.NullInt64{Int64: int64(body.GradeLevel), Valid: true} + } + + err = us.userStore.UpdateUser(r.Context(), u) + if err != nil { + log.Printf("Could not modify user %s: %v", u.Email, err) + serverError(w, http.StatusInternalServerError, "Could not update user") + return + } + + writeUser(w, u) +} + +func (us *UserService) GetUserByEmail(w http.ResponseWriter, r *http.Request, params GetUserByEmailParams) { + u, err := us.userStore.UserByToken(r.Context(), params.Token) + if err != nil { + serverError(w, http.StatusUnauthorized, "Invalid token provided") + return + } + + email := string(params.Email) + if u.Email == email { + writeUser(w, u) + return + } + + if !u.Admin { + log.Printf("user accessing unauthorized endpoint: %s", u.Email) + serverError(w, http.StatusForbidden, "You are not authorized to do this") + return + } + + user, err := us.userStore.User(r.Context(), email) + if err != nil { + log.Printf("Could not fetch user(%q): %v", email, err) + serverError(w, http.StatusNotFound, "Could not find user with the specified email") + return + } + + writeUser(w, user) +} + +func (us *UserService) ModifyOtherUser(w http.ResponseWriter, r *http.Request, params ModifyOtherUserParams) { + u, err := us.userStore.UserByToken(r.Context(), params.Token) + if err != nil { + serverError(w, http.StatusUnauthorized, "Invalid token provided") + return + } + + if !u.Admin { + log.Printf("user accessing unauthorized endpoint: %s", u.Email) + serverError(w, http.StatusForbidden, "You are not authorized to do this") + return + } + + user, err := us.userStore.User(r.Context(), string(params.Email)) + if err != nil { + log.Printf("Could not fetch user(%q): %v", params.Email, err) + serverError(w, http.StatusNotFound, "Could not find user with the specified email") + return + } + + var body ModifyOtherUserJSONBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + serverError(w, http.StatusBadRequest, "Invalid JSON body for ModifyOtherUser") + return + } + + user.Name = strings.TrimSpace(body.Name) + user.Email = string(*body.NewEmail) + user.Picture = body.Picture + user.Teacher = body.Teacher + user.Admin = body.Admin + + if len(user.Name) < 3 || len(user.Name) > 30 { + serverError(w, http.StatusUnprocessableEntity, "Name must be between 3 and 30 characters long") + return + } + user.GradeLevel = sql.NullInt64{} + if !user.Teacher { + if body.GradeLevel < 9 || body.GradeLevel > 12 { + serverError(w, http.StatusUnprocessableEntity, "Grade Level must be between 9 and 12") + return + } + user.GradeLevel = sql.NullInt64{Int64: int64(body.GradeLevel), Valid: true} + } + + writeUser(w, user) +} + +func (us *UserService) GetAllUsers(w http.ResponseWriter, r *http.Request, params GetAllUsersParams) { + u, err := us.userStore.UserByToken(r.Context(), params.Token) + if err != nil { + serverError(w, http.StatusUnauthorized, "Invalid token provided") + return + } + + if !u.Admin { + log.Printf("user accessing unauthorized endpoint: %s", u.Email) + serverError(w, http.StatusForbidden, "You are not authorized to do this") + return + } + + users, err := us.userStore.Users(r.Context()) + if err != nil { + log.Printf("user accessing unauthorized endpoint: %s", u.Email) + serverError(w, http.StatusInternalServerError, "Could not fetch users") + return + } + + writeUsers(w, users) +} + +func writeUser(w http.ResponseWriter, u *models.User) { + var grade *int + if u.GradeLevel.Valid { + g := int(u.GradeLevel.Int64) + grade = &g + } + + w.WriteHeader(200) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(User{ + Admin: u.Admin, + CreatedAt: u.CreatedAt.Time(), + Email: types.Email(u.Email), + GradeLevel: grade, + Id: u.ID, + Name: u.Name, + Picture: u.Picture, + Teacher: u.Teacher, + }) +} + +func writeUsers(w http.ResponseWriter, us []*models.User) { + var users []User + + for _, u := range us { + var grade *int + if u.GradeLevel.Valid { + g := int(u.GradeLevel.Int64) + grade = &g + } + + users = append(users, User{ + Admin: u.Admin, + CreatedAt: u.CreatedAt.Time(), + Email: types.Email(u.Email), + GradeLevel: grade, + Id: u.ID, + Name: u.Name, + Picture: u.Picture, + Teacher: u.Teacher, + }) + } + + w.WriteHeader(200) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(users) +} diff --git a/auth/oauth.go b/auth/oauth.go deleted file mode 100644 index 8752516..0000000 --- a/auth/oauth.go +++ /dev/null @@ -1,62 +0,0 @@ -package auth - -import ( - "context" - "errors" - "fmt" - "os" - - "golang.org/x/oauth2" - "golang.org/x/oauth2/google" -) - -type OAuthConfig struct { - *oauth2.Config -} - -const ( - base = "https://www.googleapis.com" - scopeEmail = base + "/auth/userinfo.email" - scopeProfile = base + "/auth/userinfo.profile" -) - -func NewOauthConfig(path string) (OAuthConfig, error) { - key, err := os.ReadFile(path) - if err != nil { - return OAuthConfig{}, fmt.Errorf("could not open file: %w", err) - } - - config, err := google.ConfigFromJSON(key, scopeEmail, scopeProfile) - if err != nil { - return OAuthConfig{}, fmt.Errorf("could not load config: %w", err) - } - - return OAuthConfig{ - Config: config, - }, nil -} - -var ErrInvalidState = errors.New("invalid ouath state provided") - -type OAuthManager interface { - Create(callback string) (code string) - Validate(code string) (callback string, valid bool) -} - -func (cfg OAuthConfig) AuthCodeURL(manager OAuthManager, callback string) string { - state := manager.Create(callback) - fmt.Printf("state: %v\n", state) - - return cfg.Config.AuthCodeURL(state, oauth2.AccessTypeOffline) -} - -func (cfg OAuthConfig) Exchange(ctx context.Context, manager OAuthManager, state, code string) (string, *oauth2.Token, error) { - var ok bool - var callback string - if callback, ok = manager.Validate(state); !ok { - return "", nil, ErrInvalidState - } - - tk, err := cfg.Config.Exchange(ctx, code, oauth2.AccessTypeOffline) - return callback, tk, err -} diff --git a/cmd/hackathon/main.go b/cmd/hackathon/main.go index 79f58b0..693f0a6 100644 --- a/cmd/hackathon/main.go +++ b/cmd/hackathon/main.go @@ -11,7 +11,6 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/hhhapz/hackathon/api" - "github.com/hhhapz/hackathon/auth" "github.com/hhhapz/hackathon/db" "github.com/peterbourgon/ff/v3" ) @@ -34,13 +33,12 @@ func run() error { return fmt.Errorf("could not open db: %v", err) } - oaStore := db.NewOAuthState() - oaConfig, err := auth.NewOauthConfig(*secretFile) + oaStore, err := db.NewOAuthState(*secretFile) if err != nil { return fmt.Errorf("could not create oauth config: %w", err) } - server := api.NewServer(oaConfig, oaStore, database) + server := api.NewServer(oaStore, database) r := chi.NewRouter() r.Use(middleware.Logger) diff --git a/db/oauth.go b/db/oauth.go index cefa60f..c119dc0 100644 --- a/db/oauth.go +++ b/db/oauth.go @@ -1,25 +1,49 @@ package db import ( + "context" + "fmt" + "os" "sync" "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" ) -type oauthEntry struct { - created time.Time - callback string -} +const ( + base = "https://www.googleapis.com" + scopeEmail = base + "/auth/userinfo.email" + scopeProfile = base + "/auth/userinfo.profile" +) type OAuthState struct { + *oauth2.Config states map[string]oauthEntry m sync.Mutex } -func NewOAuthState() *OAuthState { +type oauthEntry struct { + created time.Time + callback string +} + +func NewOAuthState(path string) (*OAuthState, error) { + key, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("could not open file: %w", err) + } + + config, err := google.ConfigFromJSON(key, scopeEmail, scopeProfile) + if err != nil { + return nil, fmt.Errorf("could not load config: %w", err) + } + return &OAuthState{ + Config: config, states: make(map[string]oauthEntry), m: sync.Mutex{}, - } + }, nil } func (o *OAuthState) Create(callback string) string { @@ -38,15 +62,23 @@ func (o *OAuthState) Create(callback string) string { created: time.Now(), callback: callback, } - return state + return o.Config.AuthCodeURL(state, oauth2.AccessTypeOffline) } -func (o *OAuthState) Validate(code string) (string, bool) { +func (o *OAuthState) Validate(state string) (string, bool) { o.m.Lock() defer o.m.Unlock() - entry, ok := o.states[code] - return entry.callback, ok + entry, ok := o.states[state] + if !ok { + return "", false + } + return entry.callback, true +} + +func (o *OAuthState) Exchange(ctx context.Context, code string) (*oauth2.Token, error) { + tk, err := o.Config.Exchange(ctx, code, oauth2.AccessTypeOffline) + return tk, err } func (o *OAuthState) Remove(code string) { diff --git a/db/user.go b/db/user.go index cc29c8e..94867dc 100644 --- a/db/user.go +++ b/db/user.go @@ -2,41 +2,22 @@ package db import ( "context" - "database/sql" - "encoding/json" - "errors" "fmt" - "time" "github.com/hhhapz/hackathon/models" ) -func (db *DB) DecodeUser(ctx context.Context, buf []byte) (*models.User, error) { - user := &models.User{ - Completed: false, - CreatedAt: models.NewTime(time.Now()), - } - if err := json.Unmarshal(buf, &user); err != nil { - return nil, fmt.Errorf("could not decode userinfo json: %v", err) - } - - if user.ID == "" { - return nil, errors.New("no id found in userinfo") - } +func (db *DB) Users(ctx context.Context) ([]*models.User, error) { + return models.Users(ctx, db.DB) +} - dbUser, err := models.UserByID(ctx, db.DB, user.ID) - switch err { - case sql.ErrNoRows: - err = user.Insert(ctx, db.DB) - if err != nil { - return nil, fmt.Errorf("could not create user: %v", err) - } - dbUser = user - fallthrough - case nil: - return dbUser, nil +func (db *DB) User(ctx context.Context, email string) (*models.User, error) { + return models.UserByEmail(ctx, db.DB, email) +} - default: - return nil, fmt.Errorf("could not check for user %#v: %v", user, err) +func (db *DB) UpdateUser(ctx context.Context, user *models.User) error { + if err := user.Upsert(ctx, db.DB); err != nil { + return fmt.Errorf("could not upsert user: %w", err) } + return nil } diff --git a/gen.sh b/gen.sh index 4a355da..7afa37a 100755 --- a/gen.sh +++ b/gen.sh @@ -31,6 +31,11 @@ set -ex xo schema $DB -o $DEST xo schema $DB --template yaml -o $DEST +# get all users +xo query $DB -M -B -T User -2 -a -F Users -o $DEST << ENDSQL +SELECT u.* FROM users u +ENDSQL + # get user by token xo query $DB -M -B -T User -1 -2 -a -F UserByToken -o $DEST << ENDSQL SELECT diff --git a/go.mod b/go.mod index cc172ea..3188790 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/hhhapz/hackathon go 1.17 require ( - github.com/deepmap/oapi-codegen v1.7.1 + github.com/deepmap/oapi-codegen v1.8.2 github.com/getkin/kin-openapi v0.61.0 github.com/go-chi/chi/v5 v5.0.4 github.com/go-chi/cors v1.2.0 diff --git a/go.sum b/go.sum index 5004544..19d6940 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deepmap/oapi-codegen v1.7.1 h1:fBWh5YlHQbH0lCY+CPVlEV1fmqqPU0kBBVN71QiOyCI= github.com/deepmap/oapi-codegen v1.7.1/go.mod h1:YLgSKSDv/bZQB7N4ws6luhozi3cEdRktEqrX88CvjIw= +github.com/deepmap/oapi-codegen v1.8.2 h1:SegyeYGcdi0jLLrpbCMoJxnUUn8GBXHsvr4rbzjuhfU= +github.com/deepmap/oapi-codegen v1.8.2/go.mod h1:YLgSKSDv/bZQB7N4ws6luhozi3cEdRktEqrX88CvjIw= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= diff --git a/models/user.xo.go b/models/user.xo.go index 3c6f183..47d5979 100644 --- a/models/user.xo.go +++ b/models/user.xo.go @@ -4,16 +4,19 @@ package models import ( "context" + "database/sql" ) // User represents a row from 'users'. type User struct { - ID string `json:"id"` // id - Name string `json:"name"` // name - Email string `json:"email"` // email - Picture string `json:"picture"` // picture - Completed bool `json:"completed"` // completed - CreatedAt Time `json:"created_at"` // created_at + ID string `json:"id"` // id + Name string `json:"name"` // name + Email string `json:"email"` // email + Picture string `json:"picture"` // picture + GradeLevel sql.NullInt64 `json:"grade_level"` // grade_level + Teacher bool `json:"teacher"` // teacher + Admin bool `json:"admin"` // admin + CreatedAt Time `json:"created_at"` // created_at // xo fields _exists, _deleted bool } @@ -39,13 +42,13 @@ func (u *User) Insert(ctx context.Context, db DB) error { } // insert (manual) const sqlstr = `INSERT INTO users (` + - `id, name, email, picture, completed, created_at` + + `id, name, email, picture, grade_level, teacher, admin, created_at` + `) VALUES (` + - `$1, $2, $3, $4, $5, $6` + + `$1, $2, $3, $4, $5, $6, $7, $8` + `)` // run - logf(sqlstr, u.ID, u.Name, u.Email, u.Picture, u.Completed, u.CreatedAt) - if _, err := db.ExecContext(ctx, sqlstr, u.ID, u.Name, u.Email, u.Picture, u.Completed, u.CreatedAt); err != nil { + logf(sqlstr, u.ID, u.Name, u.Email, u.Picture, u.GradeLevel, u.Teacher, u.Admin, u.CreatedAt) + if _, err := db.ExecContext(ctx, sqlstr, u.ID, u.Name, u.Email, u.Picture, u.GradeLevel, u.Teacher, u.Admin, u.CreatedAt); err != nil { return logerror(err) } // set exists @@ -63,11 +66,11 @@ func (u *User) Update(ctx context.Context, db DB) error { } // update with primary key const sqlstr = `UPDATE users SET ` + - `name = $1, email = $2, picture = $3, completed = $4, created_at = $5 ` + - `WHERE id = $6` + `name = $1, email = $2, picture = $3, grade_level = $4, teacher = $5, admin = $6, created_at = $7 ` + + `WHERE id = $8` // run - logf(sqlstr, u.Name, u.Email, u.Picture, u.Completed, u.CreatedAt, u.ID) - if _, err := db.ExecContext(ctx, sqlstr, u.Name, u.Email, u.Picture, u.Completed, u.CreatedAt, u.ID); err != nil { + logf(sqlstr, u.Name, u.Email, u.Picture, u.GradeLevel, u.Teacher, u.Admin, u.CreatedAt, u.ID) + if _, err := db.ExecContext(ctx, sqlstr, u.Name, u.Email, u.Picture, u.GradeLevel, u.Teacher, u.Admin, u.CreatedAt, u.ID); err != nil { return logerror(err) } return nil @@ -89,16 +92,16 @@ func (u *User) Upsert(ctx context.Context, db DB) error { } // upsert const sqlstr = `INSERT INTO users (` + - `id, name, email, picture, completed, created_at` + + `id, name, email, picture, grade_level, teacher, admin, created_at` + `) VALUES (` + - `$1, $2, $3, $4, $5, $6` + + `$1, $2, $3, $4, $5, $6, $7, $8` + `)` + ` ON CONFLICT (id) DO ` + `UPDATE SET ` + - `name = EXCLUDED.name, email = EXCLUDED.email, picture = EXCLUDED.picture, completed = EXCLUDED.completed, created_at = EXCLUDED.created_at ` + `name = EXCLUDED.name, email = EXCLUDED.email, picture = EXCLUDED.picture, grade_level = EXCLUDED.grade_level, teacher = EXCLUDED.teacher, admin = EXCLUDED.admin, created_at = EXCLUDED.created_at ` // run - logf(sqlstr, u.ID, u.Name, u.Email, u.Picture, u.Completed, u.CreatedAt) - if _, err := db.ExecContext(ctx, sqlstr, u.ID, u.Name, u.Email, u.Picture, u.Completed, u.CreatedAt); err != nil { + logf(sqlstr, u.ID, u.Name, u.Email, u.Picture, u.GradeLevel, u.Teacher, u.Admin, u.CreatedAt) + if _, err := db.ExecContext(ctx, sqlstr, u.ID, u.Name, u.Email, u.Picture, u.GradeLevel, u.Teacher, u.Admin, u.CreatedAt); err != nil { return logerror(err) } // set exists @@ -133,7 +136,7 @@ func (u *User) Delete(ctx context.Context, db DB) error { func UserByID(ctx context.Context, db DB, id string) (*User, error) { // query const sqlstr = `SELECT ` + - `id, name, email, picture, completed, created_at ` + + `id, name, email, picture, grade_level, teacher, admin, created_at ` + `FROM users ` + `WHERE id = $1` // run @@ -141,7 +144,7 @@ func UserByID(ctx context.Context, db DB, id string) (*User, error) { u := User{ _exists: true, } - if err := db.QueryRowContext(ctx, sqlstr, id).Scan(&u.ID, &u.Name, &u.Email, &u.Picture, &u.Completed, &u.CreatedAt); err != nil { + if err := db.QueryRowContext(ctx, sqlstr, id).Scan(&u.ID, &u.Name, &u.Email, &u.Picture, &u.GradeLevel, &u.Teacher, &u.Admin, &u.CreatedAt); err != nil { return nil, logerror(err) } return &u, nil @@ -153,7 +156,7 @@ func UserByID(ctx context.Context, db DB, id string) (*User, error) { func UserByEmail(ctx context.Context, db DB, email string) (*User, error) { // query const sqlstr = `SELECT ` + - `id, name, email, picture, completed, created_at ` + + `id, name, email, picture, grade_level, teacher, admin, created_at ` + `FROM users ` + `WHERE email = $1` // run @@ -161,12 +164,39 @@ func UserByEmail(ctx context.Context, db DB, email string) (*User, error) { u := User{ _exists: true, } - if err := db.QueryRowContext(ctx, sqlstr, email).Scan(&u.ID, &u.Name, &u.Email, &u.Picture, &u.Completed, &u.CreatedAt); err != nil { + if err := db.QueryRowContext(ctx, sqlstr, email).Scan(&u.ID, &u.Name, &u.Email, &u.Picture, &u.GradeLevel, &u.Teacher, &u.Admin, &u.CreatedAt); err != nil { return nil, logerror(err) } return &u, nil } +// Users runs a custom query, returning results as User. +func Users(ctx context.Context, db DB) ([]*User, error) { + // query + const sqlstr = `SELECT u.* FROM users u` + // run + logf(sqlstr) + rows, err := db.QueryContext(ctx, sqlstr) + if err != nil { + return nil, logerror(err) + } + defer rows.Close() + // load results + var res []*User + for rows.Next() { + var u User + // scan + if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Picture, &u.GradeLevel, &u.Teacher, &u.Admin, &u.CreatedAt); err != nil { + return nil, logerror(err) + } + res = append(res, &u) + } + if err := rows.Err(); err != nil { + return nil, logerror(err) + } + return res, nil +} + // UserByToken runs a custom query, returning results as User. func UserByToken(ctx context.Context, db DB, token string) (*User, error) { // query @@ -178,7 +208,7 @@ func UserByToken(ctx context.Context, db DB, token string) (*User, error) { // run logf(sqlstr, token) var u User - if err := db.QueryRowContext(ctx, sqlstr, token).Scan(&u.ID, &u.Name, &u.Email, &u.Picture, &u.Completed, &u.CreatedAt); err != nil { + if err := db.QueryRowContext(ctx, sqlstr, token).Scan(&u.ID, &u.Name, &u.Email, &u.Picture, &u.GradeLevel, &u.Teacher, &u.Admin, &u.CreatedAt); err != nil { return nil, logerror(err) } return &u, nil diff --git a/models/userinfo.xo.go b/models/userinfo.xo.go deleted file mode 100644 index 7381ac8..0000000 --- a/models/userinfo.xo.go +++ /dev/null @@ -1,153 +0,0 @@ -package models - -// Code generated by xo. DO NOT EDIT. - -import ( - "context" -) - -// UserInfo represents a row from 'user_info'. -type UserInfo struct { - UserID string `json:"user_id"` // user_id - GradeLevel int `json:"grade_level"` // grade_level - Teacher bool `json:"teacher"` // teacher - Admin bool `json:"admin"` // admin - // xo fields - _exists, _deleted bool -} - -// Exists returns true when the UserInfo exists in the database. -func (ui *UserInfo) Exists() bool { - return ui._exists -} - -// Deleted returns true when the UserInfo has been marked for deletion from -// the database. -func (ui *UserInfo) Deleted() bool { - return ui._deleted -} - -// Insert inserts the UserInfo to the database. -func (ui *UserInfo) Insert(ctx context.Context, db DB) error { - switch { - case ui._exists: // already exists - return logerror(&ErrInsertFailed{ErrAlreadyExists}) - case ui._deleted: // deleted - return logerror(&ErrInsertFailed{ErrMarkedForDeletion}) - } - // insert (manual) - const sqlstr = `INSERT INTO user_info (` + - `user_id, grade_level, teacher, admin` + - `) VALUES (` + - `$1, $2, $3, $4` + - `)` - // run - logf(sqlstr, ui.UserID, ui.GradeLevel, ui.Teacher, ui.Admin) - if _, err := db.ExecContext(ctx, sqlstr, ui.UserID, ui.GradeLevel, ui.Teacher, ui.Admin); err != nil { - return logerror(err) - } - // set exists - ui._exists = true - return nil -} - -// Update updates a UserInfo in the database. -func (ui *UserInfo) Update(ctx context.Context, db DB) error { - switch { - case !ui._exists: // doesn't exist - return logerror(&ErrUpdateFailed{ErrDoesNotExist}) - case ui._deleted: // deleted - return logerror(&ErrUpdateFailed{ErrMarkedForDeletion}) - } - // update with primary key - const sqlstr = `UPDATE user_info SET ` + - `grade_level = $1, teacher = $2, admin = $3 ` + - `WHERE user_id = $4` - // run - logf(sqlstr, ui.GradeLevel, ui.Teacher, ui.Admin, ui.UserID) - if _, err := db.ExecContext(ctx, sqlstr, ui.GradeLevel, ui.Teacher, ui.Admin, ui.UserID); err != nil { - return logerror(err) - } - return nil -} - -// Save saves the UserInfo to the database. -func (ui *UserInfo) Save(ctx context.Context, db DB) error { - if ui.Exists() { - return ui.Update(ctx, db) - } - return ui.Insert(ctx, db) -} - -// Upsert performs an upsert for UserInfo. -func (ui *UserInfo) Upsert(ctx context.Context, db DB) error { - switch { - case ui._deleted: // deleted - return logerror(&ErrUpsertFailed{ErrMarkedForDeletion}) - } - // upsert - const sqlstr = `INSERT INTO user_info (` + - `user_id, grade_level, teacher, admin` + - `) VALUES (` + - `$1, $2, $3, $4` + - `)` + - ` ON CONFLICT (user_id) DO ` + - `UPDATE SET ` + - `grade_level = EXCLUDED.grade_level, teacher = EXCLUDED.teacher, admin = EXCLUDED.admin ` - // run - logf(sqlstr, ui.UserID, ui.GradeLevel, ui.Teacher, ui.Admin) - if _, err := db.ExecContext(ctx, sqlstr, ui.UserID, ui.GradeLevel, ui.Teacher, ui.Admin); err != nil { - return logerror(err) - } - // set exists - ui._exists = true - return nil -} - -// Delete deletes the UserInfo from the database. -func (ui *UserInfo) Delete(ctx context.Context, db DB) error { - switch { - case !ui._exists: // doesn't exist - return nil - case ui._deleted: // deleted - return nil - } - // delete with single primary key - const sqlstr = `DELETE FROM user_info ` + - `WHERE user_id = $1` - // run - logf(sqlstr, ui.UserID) - if _, err := db.ExecContext(ctx, sqlstr, ui.UserID); err != nil { - return logerror(err) - } - // set deleted - ui._deleted = true - return nil -} - -// UserInfoByUserID retrieves a row from 'user_info' as a UserInfo. -// -// Generated from index 'sqlite_autoindex_user_info_1'. -func UserInfoByUserID(ctx context.Context, db DB, userID string) (*UserInfo, error) { - // query - const sqlstr = `SELECT ` + - `user_id, grade_level, teacher, admin ` + - `FROM user_info ` + - `WHERE user_id = $1` - // run - logf(sqlstr, userID) - ui := UserInfo{ - _exists: true, - } - if err := db.QueryRowContext(ctx, sqlstr, userID).Scan(&ui.UserID, &ui.GradeLevel, &ui.Teacher, &ui.Admin); err != nil { - return nil, logerror(err) - } - return &ui, nil -} - -// User returns the User associated with the UserInfo's (UserID). -// -// Generated from foreign key 'user_info_user_id_fkey'. -func (ui *UserInfo) User(ctx context.Context, db DB) (*User, error) { - return UserByID(ctx, db, ui.UserID) -} diff --git a/models/xo.xo.yaml b/models/xo.xo.yaml index c78362e..240bda5 100644 --- a/models/xo.xo.yaml +++ b/models/xo.xo.yaml @@ -60,50 +60,6 @@ schemas: datatype: type: text is_primary: true - - type: table - name: user_info - columns: - - name: user_id - datatype: - type: text - is_primary: true - - name: grade_level - datatype: - type: integer - - name: teacher - datatype: - type: boolean - - name: admin - datatype: - type: boolean - primary_keys: - - name: user_id - datatype: - type: text - is_primary: true - indexes: - - name: sqlite_autoindex_user_info_1 - fields: - - name: user_id - datatype: - type: text - is_primary: true - is_unique: true - is_primary: true - foreign_keys: - - name: user_info_user_id_fkey - column: - - name: user_id - datatype: - type: text - is_primary: true - ref_table: users - ref_column: - - name: id - datatype: - type: text - is_primary: true - manual: true - type: table name: users columns: @@ -120,7 +76,14 @@ schemas: - name: picture datatype: type: text - - name: completed + - name: grade_level + datatype: + type: integer + nullable: true + - name: teacher + datatype: + type: boolean + - name: admin datatype: type: boolean - name: created_at diff --git a/schema/schema.yaml b/schema/schema.yaml index a7a87d2..f4beb27 100644 --- a/schema/schema.yaml +++ b/schema/schema.yaml @@ -13,6 +13,7 @@ info: paths: /auth/code: get: + description: Generate oauth exchange url. tags: ["Auth"] operationId: gen oauth parameters: @@ -24,7 +25,7 @@ paths: format: uri responses: '200': - description: OAuth Consent Page URI + description: OAuth Consent Page URI. content: application/json: schema: @@ -34,6 +35,7 @@ paths: /auth/authorize: get: + description: Authorization response callback location. tags: ["Auth"] operationId: authorize callback parameters: @@ -49,7 +51,7 @@ paths: type: string responses: '302': - description: Redirect to webpage + description: Redirect to webpage. headers: Location: schema: @@ -71,20 +73,185 @@ paths: default: false - name: token in: cookie - description: User authentication token + description: User authentication token. required: true schema: type: string responses: '204': - description: User successfully logged out + description: User successfully logged out. + default: + $ref: "#/components/responses/DefaultResponse" + + /users/me: + get: + description: Get self user information. + tags: ["Users"] + operationId: get me + parameters: + - name: token + in: cookie + description: User authentication token. + required: true + schema: + type: string + responses: + '200': + description: User information. + content: + application/json: + schema: + $ref: "#/components/schemas/User" + default: + $ref: "#/components/responses/DefaultResponse" + patch: + description: Update self user. + tags: ["Users"] + operationId: modify user + parameters: + - name: token + in: cookie + description: User authentication token. + required: true + schema: + type: string + requestBody: + description: Modified user information. + content: + application/json: + schema: + type: object + required: + - name + - grade_level + properties: + name: + type: string + grade_level: + type: integer + responses: + '200': + description: New user data + content: + application/json: + schema: + $ref: "#/components/schemas/User" + default: + $ref: "#/components/responses/DefaultResponse" + + /users/email: + get: + description: |- + Get user info by email. + Requires admin to get user info not equal to the owner of the token. + tags: ["Users"] + operationId: get user by email + parameters: + - name: token + in: cookie + description: User authentication token. + required: true + schema: + type: string + - name: email + in: query + description: User email. + required: true + schema: + type: string + format: email + responses: + '200': + description: User information. + content: + application/json: + schema: + $ref: "#/components/schemas/User" + default: + $ref: "#/components/responses/DefaultResponse" + patch: + description: Update another user. Requires admin. + tags: ["Users"] + operationId: modify other user + parameters: + - name: token + in: cookie + description: User authentication token. + required: true + schema: + type: string + - name: email + in: query + description: User email. + required: true + schema: + type: string + format: email + requestBody: + description: Modified user information. + content: + application/json: + schema: + type: object + required: + - name + - email + - picture + - grade_level + - teacher + - admin + properties: + name: + type: string + new_email: + type: string + format: email + picture: + type: string + format: uri + grade_level: + type: integer + teacher: + type: boolean + admin: + type: boolean + + responses: + '200': + description: User information. + content: + application/json: + schema: + $ref: "#/components/schemas/User" + default: + $ref: "#/components/responses/DefaultResponse" + + /users/all: + get: + description: Get all users. Requires admin. + tags: ["Users"] + operationId: get all users + parameters: + - name: token + in: cookie + description: User authentication token. + required: true + schema: + type: string + responses: + '200': + description: All user information. + content: + application/json: + schema: + $ref: "#/components/schemas/User" default: $ref: "#/components/responses/DefaultResponse" components: responses: DefaultResponse: - description: Unexpected server error or invalid user input + description: Unexpected server error or invalid user input. content: application/json: schema: @@ -99,6 +266,37 @@ components: url: type: string format: uri + User: + type: object + required: + - id + - name + - email + - picture + - teacher + - admin + - created_at + properties: + id: + type: string + name: + type: string + email: + type: string + format: email + picture: + type: string + format: uri + grade_level: + type: integer + description: GradeLevel is only present if teacher is false. + teacher: + type: boolean + admin: + type: boolean + created_at: + type: string + format: date-time Error: type: object diff --git a/sql/schema.sql b/sql/schema.sql index 3096adb..6454fca 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -3,15 +3,10 @@ CREATE TABLE users ( name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, picture TEXT NOT NULL, - completed BOOLEAN NOT NULL, - created_at DATETIME NOT NULL -); - -CREATE TABLE user_info ( - user_id TEXT NOT NULL PRIMARY KEY REFERENCES users (id), - grade_level INTEGER NOT NULL, + grade_level INTEGER, teacher BOOLEAN NOT NULL, - admin BOOLEAN NOT NULL + admin BOOLEAN NOT NULL, + created_at DATETIME NOT NULL ); CREATE TABLE tokens (