From b9ca3b4b5d63e44f8d451f02832d22923562cc9b Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Tue, 3 Jun 2025 01:13:21 +0530 Subject: [PATCH 01/32] feat: add github oAuth --- .env.example | 4 + .gitignore | 1 + cmd/main.go | 33 ++++++++ go.mod | 22 ++++++ go.sum | 60 +++++++++++++++ internal/controllers/auth.go | 136 +++++++++++++++++++++++++++++++++ internal/middlewares/logger.go | 35 +++++++++ internal/routes/auth.go | 12 +++ internal/routes/router.go | 23 ++++++ 9 files changed, 326 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/controllers/auth.go create mode 100644 internal/middlewares/logger.go create mode 100644 internal/routes/auth.go create mode 100644 internal/routes/router.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0c9b1ca --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +GITHUB_CLIENT_ID=*** +GITHUB_CLIENT_SECRET=*** +LOG_LEVEL=debug # debug, info, warn, error, fatal +PROD_DOMAIN=http://example.com # The domain where the app is hosted \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..50858fa --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,33 @@ +package main + +import ( + log "github.com/sirupsen/logrus" + "net/http" + "os" + + "github.com/joho/godotenv" + "github.com/var-code-5/devboard-backend/internal/routes" +) + +func init() { + log.SetOutput(os.Stdout) + // log.SetFormatter(&log.JSONFormatter{}) // Uncomment this line to use JSON format for logs + + logLevel, err := log.ParseLevel(os.Getenv("LOG_LEVEL")) + if err != nil { + logLevel = log.InfoLevel + } + + log.SetLevel(logLevel) +} + +func main() { + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + + router := routes.Router() + log.Info("Starting server on :3000") + log.Fatal(http.ListenAndServe(":3000",router)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..087c02f --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/var-code-5/devboard-backend + +go 1.24.3 + +require github.com/gorilla/mux v1.8.1 + +require github.com/joho/godotenv v1.5.1 + +require ( + github.com/golang/snappy v1.0.0 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.mongodb.org/mongo-driver/v2 v2.2.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d202299 --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.2.1 h1:w5xra3yyu/sGrziMzK1D0cRRaH/b7lWCSsoN6+WV6AM= +go.mongodb.org/mongo-driver/v2 v2.2.1/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/controllers/auth.go b/internal/controllers/auth.go new file mode 100644 index 0000000..9975b78 --- /dev/null +++ b/internal/controllers/auth.go @@ -0,0 +1,136 @@ +package controllers + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + log "github.com/sirupsen/logrus" + "net/http" + "net/url" + "os" + "strings" +) + +func generateRandomString(length int) (string, error) { + randomBytes := make([]byte, length) + _, err := rand.Read(randomBytes) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(randomBytes), nil +} + +func Login(w http.ResponseWriter, r *http.Request) { + + // setting a random string for state param for CSRF protection + randomString, err := generateRandomString(8) + if err != nil { + log.Error("Error generating random string:", err) + return + } + + cookie := &http.Cookie{ + Name: "state", + Value: randomString, + HttpOnly: true, + Path: "/api/auth/", + MaxAge: 60 * 5, // 5 minutes + SameSite: http.SameSiteStrictMode, + } + http.SetCookie(w, cookie) + + prodDomain := os.Getenv("PROD_DOMAIN") + if prodDomain == "" { + prodDomain = "http://127.0.0.1:3000" + } + + oauth_url := url.URL{ + Scheme: "https", + Host: "github.com", + Path: "/login/oauth/authorize", + RawQuery: url.Values{ + "client_id": {os.Getenv("GITHUB_CLIENT_ID")}, + "response_type": {"code"}, + "scope": {"repo"}, + "redirect_uri": {fmt.Sprintf("%s/api/auth/github/oauth2/callback", prodDomain)}, + "state": {randomString}, + }.Encode(), + } + + http.Redirect(w, r, oauth_url.String(), 302) +} + +type CallbackResponse struct { + AccessToken string `json:"access_token"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` +} + +func jsonErrorResponse(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{ + "error": message, + }) +} + +func Callback(w http.ResponseWriter, r *http.Request) { + //verifying the state parameter to prevent CSRF attacks + queries := r.URL.Query() + state := queries.Get("state") + cookie, err := r.Cookie("state") + if err != nil { + jsonErrorResponse(w, http.StatusBadRequest, "State cookie not found - CSRF protection failed") + log.Error("Error retrieving state cookie:", err) + return + } + if state != cookie.Value { + jsonErrorResponse(w, http.StatusBadRequest, "State parameter does not match cookie value - CSRF protection failed") + log.Warn("State parameter does not match cookie value") + return + } + + // Exchanging the code for an access token + code := queries.Get("code") + github_clientID := os.Getenv("GITHUB_CLIENT_ID") + github_clientSecret := os.Getenv("GITHUB_CLIENT_SECRET") + if code == "" || github_clientID == "" || github_clientSecret == "" { + jsonErrorResponse(w, http.StatusInternalServerError, "Missing code or GitHub client credentials") + log.Error("Missing code or GitHub client credentials") + return + } + data := url.Values{ + "client_id": {github_clientID}, + "client_secret": {github_clientSecret}, + "code": {code}, + } + req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(data.Encode())) + if err != nil { + jsonErrorResponse(w, http.StatusInternalServerError, "Failed to create request") + log.Error("Error creating request to exchange code for access token:", err) + return + } + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + jsonErrorResponse(w, http.StatusInternalServerError, "Failed to exchange code for access token") + log.Error("Error exchanging code for access token:", err) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + jsonErrorResponse(w, resp.StatusCode, "Failed to exchange code for access token") + log.Error("Error response from GitHub:", resp.Status) + return + } + var response CallbackResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + jsonErrorResponse(w, http.StatusInternalServerError, "Failed to decode access token response") + log.Error("Error decoding access token response:", err) + return + } +} diff --git a/internal/middlewares/logger.go b/internal/middlewares/logger.go new file mode 100644 index 0000000..d553d70 --- /dev/null +++ b/internal/middlewares/logger.go @@ -0,0 +1,35 @@ +package middlewares + +import ( + "net/http" + "time" + + log "github.com/sirupsen/logrus" +) + +type CustomResponseWriter struct { + http.ResponseWriter + StatusCode int +} + +func (crw *CustomResponseWriter) WriteHeader(statusCode int) { + crw.StatusCode = statusCode + crw.ResponseWriter.WriteHeader(statusCode) +} + +func Logger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + crw := &CustomResponseWriter{ResponseWriter: w, StatusCode: http.StatusOK} + + next.ServeHTTP(crw, r) + + log.WithFields(log.Fields{ + "method": r.Method, + "path": r.URL.Path, + "status": crw.StatusCode, + "latency_ns": time.Since(start).Nanoseconds(), + }).Info("request details") + + }) +} diff --git a/internal/routes/auth.go b/internal/routes/auth.go new file mode 100644 index 0000000..784df50 --- /dev/null +++ b/internal/routes/auth.go @@ -0,0 +1,12 @@ +package routes + +import ( + "github.com/gorilla/mux" + "github.com/var-code-5/devboard-backend/internal/controllers" +) + +func RegisterAuth(r *mux.Router) { + s := r.PathPrefix("/auth").Subrouter() + s.HandleFunc("/login", controllers.Login).Methods("GET") + s.HandleFunc("/github/oauth2/callback", controllers.Callback).Methods("GET") +} diff --git a/internal/routes/router.go b/internal/routes/router.go new file mode 100644 index 0000000..c12f602 --- /dev/null +++ b/internal/routes/router.go @@ -0,0 +1,23 @@ +package routes + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/var-code-5/devboard-backend/internal/middlewares" +) + +func Router() *mux.Router { + r := mux.NewRouter() + r.Use(middlewares.Logger) + + apiRouter := r.PathPrefix("/api").Subrouter() + apiRouter.HandleFunc("/ping", ping).Methods("GET") + RegisterAuth(apiRouter) + + return r +} + +func ping(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("pong")) +} From 115d4fa4b8d37c9905d804ca589eadebf03c3292 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Fri, 13 Jun 2025 18:07:32 +0530 Subject: [PATCH 02/32] feat:add jwt auth --- .env.example | 12 ++-- cmd/main.go | 15 ++-- go.mod | 11 ++- go.sum | 12 ++++ internal/controllers/auth.go | 131 +++++++++++++++++++++++++++++++--- internal/controllers/utils.go | 122 +++++++++++++++++++++++++++++++ internal/middlewares/auth.go | 28 ++++++++ internal/models/auth.go | 85 ++++++++++++++++++++++ internal/models/utils.go | 58 +++++++++++++++ internal/routes/auth.go | 5 ++ 10 files changed, 455 insertions(+), 24 deletions(-) create mode 100644 internal/controllers/utils.go create mode 100644 internal/middlewares/auth.go create mode 100644 internal/models/auth.go create mode 100644 internal/models/utils.go diff --git a/.env.example b/.env.example index 0c9b1ca..555c898 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ -GITHUB_CLIENT_ID=*** -GITHUB_CLIENT_SECRET=*** -LOG_LEVEL=debug # debug, info, warn, error, fatal -PROD_DOMAIN=http://example.com # The domain where the app is hosted \ No newline at end of file +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret +PROD_DOMAIN=http://localhost:3000 +LOG_LEVEL=debug +MONGODB_URI=mongodb+srv://username:password@host/database +MONGODB_DB_NAME=devBoard +JWT_REFRESH_SECRET=your_refresh_secret_key +JWT_ACCESS_SECRET=your_access_secret_key \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 50858fa..dd02e80 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,15 +1,21 @@ package main import ( - log "github.com/sirupsen/logrus" "net/http" "os" + log "github.com/sirupsen/logrus" + "github.com/joho/godotenv" "github.com/var-code-5/devboard-backend/internal/routes" ) func init() { + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + log.SetOutput(os.Stdout) // log.SetFormatter(&log.JSONFormatter{}) // Uncomment this line to use JSON format for logs @@ -22,12 +28,7 @@ func init() { } func main() { - err := godotenv.Load() - if err != nil { - log.Fatal("Error loading .env file") - } - router := routes.Router() log.Info("Starting server on :3000") - log.Fatal(http.ListenAndServe(":3000",router)) + log.Fatal(http.ListenAndServe(":3000", router)) } diff --git a/go.mod b/go.mod index 087c02f..24d9c99 100644 --- a/go.mod +++ b/go.mod @@ -4,17 +4,22 @@ go 1.24.3 require github.com/gorilla/mux v1.8.1 -require github.com/joho/godotenv v1.5.1 +require ( + github.com/joho/godotenv v1.5.1 + github.com/sirupsen/logrus v1.9.3 + go.mongodb.org/mongo-driver v1.17.3 + go.mongodb.org/mongo-driver/v2 v2.2.1 +) require ( + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/klauspost/compress v1.16.7 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - go.mongodb.org/mongo-driver/v2 v2.2.1 // indirect golang.org/x/crypto v0.33.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.33.0 // indirect diff --git a/go.sum b/go.sum index d202299..fcde996 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,26 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= @@ -22,6 +31,8 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.mongodb.org/mongo-driver/v2 v2.2.1 h1:w5xra3yyu/sGrziMzK1D0cRRaH/b7lWCSsoN6+WV6AM= go.mongodb.org/mongo-driver/v2 v2.2.1/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -57,4 +68,5 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/controllers/auth.go b/internal/controllers/auth.go index 9975b78..c9bdf51 100644 --- a/internal/controllers/auth.go +++ b/internal/controllers/auth.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" log "github.com/sirupsen/logrus" + "github.com/var-code-5/devboard-backend/internal/models" "net/http" "net/url" "os" @@ -36,7 +37,7 @@ func Login(w http.ResponseWriter, r *http.Request) { HttpOnly: true, Path: "/api/auth/", MaxAge: 60 * 5, // 5 minutes - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, } http.SetCookie(w, cookie) @@ -62,9 +63,11 @@ func Login(w http.ResponseWriter, r *http.Request) { } type CallbackResponse struct { - AccessToken string `json:"access_token"` - Scope string `json:"scope"` - TokenType string `json:"token_type"` + AccessToken string `json:"access_token"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` } func jsonErrorResponse(w http.ResponseWriter, status int, message string) { @@ -105,13 +108,14 @@ func Callback(w http.ResponseWriter, r *http.Request) { "client_secret": {github_clientSecret}, "code": {code}, } + req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(data.Encode())) if err != nil { jsonErrorResponse(w, http.StatusInternalServerError, "Failed to create request") log.Error("Error creating request to exchange code for access token:", err) return } - req.Header.Add("Accept", "application/json") + req.Header.Add("Accept", "application/vnd.github+json") req.Header.Add("Content-Type", "application/x-www-form-urlencoded") client := &http.Client{} @@ -122,15 +126,122 @@ func Callback(w http.ResponseWriter, r *http.Request) { return } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - jsonErrorResponse(w, resp.StatusCode, "Failed to exchange code for access token") - log.Error("Error response from GitHub:", resp.Status) - return - } var response CallbackResponse if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { jsonErrorResponse(w, http.StatusInternalServerError, "Failed to decode access token response") log.Error("Error decoding access token response:", err) return } + + if resp.StatusCode != http.StatusOK { + jsonErrorResponse(w, resp.StatusCode, "Failed to exchange code for access token") + log.Error("Error response from GitHub:", response.Error, response.ErrorDescription) + return + } + + //todo: remove the log in the production environment + log.Debug("Access token received successfully:", response.AccessToken) + + req, err = http.NewRequest("GET", "https://api.github.com/user", nil) + if err != nil { + jsonErrorResponse(w, http.StatusInternalServerError, "Failed to create request to get user info") + log.Error("Error creating request to get user info:", err) + return + } + req.Header.Add("Authorization", "Bearer "+response.AccessToken) + client = &http.Client{} + resp, err = client.Do(req) + if err != nil { + jsonErrorResponse(w, http.StatusInternalServerError, "Failed to get user info") + log.Error("Error getting user info from GitHub:", err) + return + } + defer resp.Body.Close() + + var user models.User + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + jsonErrorResponse(w, http.StatusInternalServerError, "Failed to decode user info response") + log.Error("Error decoding user info response:", err) + return + } + + user.AccessToken = response.AccessToken + if err := models.AddUser(user); err != nil { + jsonErrorResponse(w, http.StatusInternalServerError, "Failed to add user to database") + log.Error("Error adding user to database:", err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + jwtAccessToken, err := CreateAccessToken(user.Login) + if err != nil { + jsonErrorResponse(w, http.StatusInternalServerError, "Failed to create JWT access token") + log.Error("Error creating JWT access token:", err) + return + } + jwtRefreshToken, err := CreateRefreshToken(user.Login) + if err != nil { + jsonErrorResponse(w, http.StatusInternalServerError, "Failed to create JWT refresh token") + log.Error("Error creating JWT refresh token:", err) + return + } + + json.NewEncoder(w).Encode(map[string]string{ + "message": "User authenticated successfully", + "access_token": jwtAccessToken, + "refresh_token": jwtRefreshToken, + "login": user.Login, + "avatar_url": user.AvatarURL, + }) + + log.Info("User authenticated successfully:", user.Login) +} + +func RefreshAccessToken(w http.ResponseWriter, r *http.Request) { + refreshToken := r.Header.Get("Authorization") + if refreshToken == "" { + jsonErrorResponse(w, http.StatusUnauthorized, "Refresh token is required") + log.Warn("Refresh token is missing in the request header") + return + } + + token, err := VerifyRefreshToken(strings.Split(refreshToken, " ")[1]) + if err != nil { + jsonErrorResponse(w, http.StatusUnauthorized, "Invalid refresh token") + log.Error("Error verifying refresh token:", err) + return + } + + newAccessToken, err := CreateAccessToken(token.Username) + if err != nil { + jsonErrorResponse(w, http.StatusInternalServerError, "Failed to create new access token") + log.Error("Error creating new access token:", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Access token refreshed successfully", + "access_token": newAccessToken, + }) + log.Info("Access token refreshed successfully for user:", token.Username) } + +func Logout(w http.ResponseWriter, r *http.Request) { + username := r.Context().Value("user").( string ) + log.Debug("Logging out user:", username) + err := models.Logout(username) + if err != nil { + jsonErrorResponse(w, http.StatusInternalServerError, "Failed to logout user") + log.Error("Error logging out user:", err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "message": "User logged out successfully", + }) + log.Info("User logged out successfully:", username) +} \ No newline at end of file diff --git a/internal/controllers/utils.go b/internal/controllers/utils.go new file mode 100644 index 0000000..bac16d3 --- /dev/null +++ b/internal/controllers/utils.go @@ -0,0 +1,122 @@ +package controllers + +import ( + "os" + "time" + + "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" +) + +type Token struct { + Username string `json:"username"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + Iss string `json:"iss"` +} + +func CreateAccessToken(username string) (string, error) { + var secretKey = os.Getenv("JWT_ACCESS_SECRET") + if secretKey == "" { + log.Error("JWT_ACCESS_SECRET environment variable is not set") + return "", nil + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, + jwt.MapClaims{ + "username": username, + "exp": time.Now().Add(time.Minute * 5).Unix(), // Token valid for 5 minutes + "iat": time.Now().Unix(), // Issued at time + "iss": "devboard-backend", // Issuer + }) + + tokenString, err := token.SignedString([]byte(secretKey)) + if err != nil { + log.Error("Error signing access token:", err) + return "", err + } + + return tokenString, nil +} + +func CreateRefreshToken(username string) (string, error) { + var secretKey = os.Getenv("JWT_REFRESH_SECRET") + if secretKey == "" { + log.Error("JWT_REFRESH_SECRET environment variable is not set") + return "", nil + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, + jwt.MapClaims{ + "username": username, + "exp": time.Now().Add(time.Hour * 24 * 30).Unix(), // Token valid for 30 days + "iat": time.Now().Unix(), // Issued at time + "iss": "devboard-backend", // Issuer + }) + + tokenString, err := token.SignedString([]byte(secretKey)) + if err != nil { + log.Error("Error signing refresh token:", err) + return "", err + } + + return tokenString, nil +} + +func VerifyAccessToken(tokenString string) (Token, error) { + var secretKey = os.Getenv("JWT_ACCESS_SECRET") + if secretKey == "" { + log.Error("JWT_ACCESS_SECRET environment variable is not set") + return Token{}, nil + } + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(secretKey), nil + }) + if err != nil { + log.Error("Error parsing access token:", err) + return Token{}, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + + if !ok || !token.Valid { + log.Error("Invalid access token") + return Token{}, err + } + + return Token{ + Username: claims["username"].(string), + Exp: int64(claims["exp"].(float64)), + Iat: int64(claims["iat"].(float64)), + Iss: claims["iss"].(string), + }, nil +} + +func VerifyRefreshToken(tokenString string) (Token, error) { + var secretKey = os.Getenv("JWT_REFRESH_SECRET") + if secretKey == "" { + log.Error("JWT_REFRESH_SECRET environment variable is not set") + return Token{}, nil + } + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(secretKey), nil + }) + if err != nil { + log.Error("Error parsing refresh token:", err) + return Token{}, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + log.Error("Invalid refresh token") + return Token{}, err + } + + return Token{ + Username: claims["username"].(string), + Exp: int64(claims["exp"].(float64)), + Iat: int64(claims["iat"].(float64)), + Iss: claims["iss"].(string), + }, nil +} + diff --git a/internal/middlewares/auth.go b/internal/middlewares/auth.go new file mode 100644 index 0000000..29ba7e8 --- /dev/null +++ b/internal/middlewares/auth.go @@ -0,0 +1,28 @@ +package middlewares + +import ( + "context" + "net/http" + "strings" + + "github.com/var-code-5/devboard-backend/internal/controllers" +) + +func VerifyAccessToken(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenString := r.Header.Get("Authorization") + if tokenString == "" { + http.Error(w, "Authorization header is missing", http.StatusUnauthorized) + return + } + token, err := controllers.VerifyAccessToken(strings.Split(tokenString, " ")[1]) + if err != nil { + http.Error(w, "Invalid access token", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), "user", token.Username) + next.ServeHTTP(w, r.WithContext(ctx)) + + }) +} diff --git a/internal/models/auth.go b/internal/models/auth.go new file mode 100644 index 0000000..dab7dac --- /dev/null +++ b/internal/models/auth.go @@ -0,0 +1,85 @@ +package models + +import ( + "context" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" +) + +type User struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"-"` // Remove from JSON unmarshaling + GithubID int `json:"id" bson:"github_id"` // Map GitHub's "id" directly to GithubID + Login string `json:"login"` + NodeID string `json:"node_id"` + AvatarURL string `json:"avatar_url"` + GravatarID string `json:"gravatar_id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + FollowersURL string `json:"followers_url"` + FollowingURL string `json:"following_url"` + GistsURL string `json:"gists_url"` + StarredURL string `json:"starred_url"` + SubscriptionsURL string `json:"subscriptions_url"` + OrganizationsURL string `json:"organizations_url"` + ReposURL string `json:"repos_url"` + EventsURL string `json:"events_url"` + ReceivedEventsURL string `json:"received_events_url"` + Type string `json:"type"` + UserViewType string `json:"user_view_type"` + SiteAdmin bool `json:"site_admin"` + Name string `json:"name"` + Company *string `json:"company"` + Blog string `json:"blog"` + Location *string `json:"location"` + Email *string `json:"email"` + Hireable *bool `json:"hireable"` + Bio *string `json:"bio"` + TwitterUsername *string `json:"twitter_username"` + NotificationEmail *string `json:"notification_email"` + PublicRepos int `json:"public_repos"` + PublicGists int `json:"public_gists"` + Followers int `json:"followers"` + Following int `json:"following"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + AccessToken string `json:"access_token,omitempty" bson:"access_token,omitempty"` +} + +func AddUser(user User) error { + collection, err := GetCollection("users") + if err != nil { + return err + } + + // Check if user already exists in the database by GitHub ID + filter := bson.M{"github_id": user.GithubID} + existingUser := collection.FindOne(context.Background(), filter) + if existingUser.Err() == nil { + // User already exists upadte the access token + update := bson.M{"$set": bson.M{"access_token": user.AccessToken}} + _, err := collection.UpdateOne(context.Background(), filter, update) + if err != nil { + return err + } + return nil + } else if existingUser.Err() != mongo.ErrNoDocuments { + return existingUser.Err() + } + + _, err = collection.InsertOne(context.Background(), user) + return err +} + +func Logout(username string) error { + collection, err := GetCollection("users") + if err != nil { + return err + } + + filter := bson.M{"login": username} + update := bson.M{"$set": bson.M{"access_token": ""}} + _, err = collection.UpdateOne(context.Background(), filter, update) + return err +} \ No newline at end of file diff --git a/internal/models/utils.go b/internal/models/utils.go new file mode 100644 index 0000000..3822bbc --- /dev/null +++ b/internal/models/utils.go @@ -0,0 +1,58 @@ +package models + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/joho/godotenv" + log "github.com/sirupsen/logrus" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var ( + client *mongo.Client + dbName string + database *mongo.Database +) + +func init() { + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + + connectionURL := os.Getenv("MONGODB_URI") + dbName := os.Getenv("MONGODB_DB_NAME") + + if connectionURL == "" || dbName == "" { + log.Fatal("MONGODB_URI or MONGODB_DB_NAME environment variable is not set") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + clientOptions := options.Client().ApplyURI(connectionURL) + client, err := mongo.Connect(ctx, clientOptions) + if err != nil { + log.Fatalf("Failed to connect to MongoDB: %v", err) + return + } + + if err := client.Ping(ctx, nil); err != nil { + log.Fatalf("MongoDB ping failed: %v", err) + } + + database = client.Database(dbName) + log.Info("Successfully connected to MongoDB") +} + +func GetCollection(collectionName string) (*mongo.Collection, error) { + if database == nil { + return nil, fmt.Errorf("database connection is not initialized") + } + return database.Collection(collectionName), nil +} diff --git a/internal/routes/auth.go b/internal/routes/auth.go index 784df50..84cd7cf 100644 --- a/internal/routes/auth.go +++ b/internal/routes/auth.go @@ -1,12 +1,17 @@ package routes import ( + "net/http" + "github.com/gorilla/mux" "github.com/var-code-5/devboard-backend/internal/controllers" + "github.com/var-code-5/devboard-backend/internal/middlewares" ) func RegisterAuth(r *mux.Router) { s := r.PathPrefix("/auth").Subrouter() s.HandleFunc("/login", controllers.Login).Methods("GET") s.HandleFunc("/github/oauth2/callback", controllers.Callback).Methods("GET") + s.Handle("/logout",middlewares.VerifyAccessToken(http.HandlerFunc(controllers.Logout))).Methods("GET") + s.HandleFunc("/access-token", controllers.RefreshAccessToken).Methods("GET") } From 1320a96c5607fd9f6dd303fb9587b8590c7ace87 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Fri, 13 Jun 2025 23:18:43 +0530 Subject: [PATCH 03/32] feat: add docker file --- docker-compose.yml | 11 +++++++++++ dockerfile | 34 ++++++++++++++++++++++++++++++++++ internal/controllers/auth.go | 29 ++++++++++++++++++++++------- 3 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 docker-compose.yml create mode 100644 dockerfile diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c9c070b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + app: + build: + context: . + dockerfile: dockerfile + ports: + - "3000:3000" + env_file: + - .env + volumes: + - .env:/app/.env \ No newline at end of file diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..4393c82 --- /dev/null +++ b/dockerfile @@ -0,0 +1,34 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o app ./cmd + +# Use a minimal Alpine image for the final container +FROM alpine:3.19 + +# Add ca-certificates for HTTPS and timezone data +RUN apk --no-cache add ca-certificates tzdata + +# Create a non-root user and group +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +# Set working directory +WORKDIR /app + +# Copy the binary from the builder stage +COPY --from=builder /app/app . + +# Use the non-root user +USER appuser + +# Expose the port your application runs on (adjust as needed) +EXPOSE 3000 + +# Command to run the application +CMD ["./app"] \ No newline at end of file diff --git a/internal/controllers/auth.go b/internal/controllers/auth.go index c9bdf51..b0bef46 100644 --- a/internal/controllers/auth.go +++ b/internal/controllers/auth.go @@ -187,14 +187,29 @@ func Callback(w http.ResponseWriter, r *http.Request) { return } - json.NewEncoder(w).Encode(map[string]string{ - "message": "User authenticated successfully", - "access_token": jwtAccessToken, - "refresh_token": jwtRefreshToken, - "login": user.Login, - "avatar_url": user.AvatarURL, - }) + // json.NewEncoder(w).Encode(map[string]string{ + // "message": "User authenticated successfully", + // "access_token": jwtAccessToken, + // "refresh_token": jwtRefreshToken, + // "login": user.Login, + // "avatar_url": user.AvatarURL, + // }) + + frontendURL := os.Getenv("FRONTEND_URL") + if frontendURL == "" { + log.Warn("FRONTEND_URL environment variable is not set") + jsonErrorResponse(w, http.StatusInternalServerError, "Frontend URL is not configured") + return + } + redirectURL := fmt.Sprintf("%s?access_token=%s&refresh_token=%s&login=%s&avatar_url=%s", + frontendURL, + url.QueryEscape(jwtAccessToken), + url.QueryEscape(jwtRefreshToken), + url.QueryEscape(user.Login), + url.QueryEscape(user.AvatarURL), + ) + http.Redirect(w, r, redirectURL, http.StatusSeeOther) log.Info("User authenticated successfully:", user.Login) } From 1df87d93d460f33cc349ede56a5573c58d257ae6 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sat, 14 Jun 2025 13:11:10 +0530 Subject: [PATCH 04/32] feat: add github featch readme --- docker-compose.yml | 7 ++- internal/controllers/auth.go | 46 ++++++-------- internal/controllers/github.go | 111 +++++++++++++++++++++++++++++++++ internal/controllers/utils.go | 11 +++- internal/middlewares/auth.go | 29 +++++++-- internal/models/auth.go | 16 +++++ internal/models/utils.go | 2 +- internal/routes/github.go | 14 +++++ internal/routes/router.go | 1 + 9 files changed, 202 insertions(+), 35 deletions(-) create mode 100644 internal/controllers/github.go create mode 100644 internal/routes/github.go diff --git a/docker-compose.yml b/docker-compose.yml index c9c070b..d7ec7fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,11 @@ services: context: . dockerfile: dockerfile ports: - - "3000:3000" + - "80:3000" + # expose port 3000 on the container to port 80 on the host + #todo: add reverse proxy if needed env_file: - .env volumes: - - .env:/app/.env \ No newline at end of file + - .env:/app/.env + \ No newline at end of file diff --git a/internal/controllers/auth.go b/internal/controllers/auth.go index b0bef46..06cd08a 100644 --- a/internal/controllers/auth.go +++ b/internal/controllers/auth.go @@ -70,26 +70,18 @@ type CallbackResponse struct { ErrorDescription string `json:"error_description,omitempty"` } -func jsonErrorResponse(w http.ResponseWriter, status int, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(map[string]string{ - "error": message, - }) -} - func Callback(w http.ResponseWriter, r *http.Request) { //verifying the state parameter to prevent CSRF attacks queries := r.URL.Query() state := queries.Get("state") cookie, err := r.Cookie("state") if err != nil { - jsonErrorResponse(w, http.StatusBadRequest, "State cookie not found - CSRF protection failed") + JsonErrorResponse(w, http.StatusBadRequest, "State cookie not found - CSRF protection failed") log.Error("Error retrieving state cookie:", err) return } if state != cookie.Value { - jsonErrorResponse(w, http.StatusBadRequest, "State parameter does not match cookie value - CSRF protection failed") + JsonErrorResponse(w, http.StatusBadRequest, "State parameter does not match cookie value - CSRF protection failed") log.Warn("State parameter does not match cookie value") return } @@ -99,7 +91,7 @@ func Callback(w http.ResponseWriter, r *http.Request) { github_clientID := os.Getenv("GITHUB_CLIENT_ID") github_clientSecret := os.Getenv("GITHUB_CLIENT_SECRET") if code == "" || github_clientID == "" || github_clientSecret == "" { - jsonErrorResponse(w, http.StatusInternalServerError, "Missing code or GitHub client credentials") + JsonErrorResponse(w, http.StatusInternalServerError, "Missing code or GitHub client credentials") log.Error("Missing code or GitHub client credentials") return } @@ -111,7 +103,7 @@ func Callback(w http.ResponseWriter, r *http.Request) { req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(data.Encode())) if err != nil { - jsonErrorResponse(w, http.StatusInternalServerError, "Failed to create request") + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to create request") log.Error("Error creating request to exchange code for access token:", err) return } @@ -121,20 +113,20 @@ func Callback(w http.ResponseWriter, r *http.Request) { client := &http.Client{} resp, err := client.Do(req) if err != nil { - jsonErrorResponse(w, http.StatusInternalServerError, "Failed to exchange code for access token") + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to exchange code for access token") log.Error("Error exchanging code for access token:", err) return } defer resp.Body.Close() var response CallbackResponse if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - jsonErrorResponse(w, http.StatusInternalServerError, "Failed to decode access token response") + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to decode access token response") log.Error("Error decoding access token response:", err) return } if resp.StatusCode != http.StatusOK { - jsonErrorResponse(w, resp.StatusCode, "Failed to exchange code for access token") + JsonErrorResponse(w, resp.StatusCode, "Failed to exchange code for access token") log.Error("Error response from GitHub:", response.Error, response.ErrorDescription) return } @@ -144,7 +136,7 @@ func Callback(w http.ResponseWriter, r *http.Request) { req, err = http.NewRequest("GET", "https://api.github.com/user", nil) if err != nil { - jsonErrorResponse(w, http.StatusInternalServerError, "Failed to create request to get user info") + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to create request to get user info") log.Error("Error creating request to get user info:", err) return } @@ -152,7 +144,7 @@ func Callback(w http.ResponseWriter, r *http.Request) { client = &http.Client{} resp, err = client.Do(req) if err != nil { - jsonErrorResponse(w, http.StatusInternalServerError, "Failed to get user info") + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to get user info") log.Error("Error getting user info from GitHub:", err) return } @@ -160,14 +152,14 @@ func Callback(w http.ResponseWriter, r *http.Request) { var user models.User if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { - jsonErrorResponse(w, http.StatusInternalServerError, "Failed to decode user info response") + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to decode user info response") log.Error("Error decoding user info response:", err) return } user.AccessToken = response.AccessToken if err := models.AddUser(user); err != nil { - jsonErrorResponse(w, http.StatusInternalServerError, "Failed to add user to database") + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to add user to database") log.Error("Error adding user to database:", err) return } @@ -176,13 +168,13 @@ func Callback(w http.ResponseWriter, r *http.Request) { jwtAccessToken, err := CreateAccessToken(user.Login) if err != nil { - jsonErrorResponse(w, http.StatusInternalServerError, "Failed to create JWT access token") + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to create JWT access token") log.Error("Error creating JWT access token:", err) return } jwtRefreshToken, err := CreateRefreshToken(user.Login) if err != nil { - jsonErrorResponse(w, http.StatusInternalServerError, "Failed to create JWT refresh token") + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to create JWT refresh token") log.Error("Error creating JWT refresh token:", err) return } @@ -198,7 +190,7 @@ func Callback(w http.ResponseWriter, r *http.Request) { frontendURL := os.Getenv("FRONTEND_URL") if frontendURL == "" { log.Warn("FRONTEND_URL environment variable is not set") - jsonErrorResponse(w, http.StatusInternalServerError, "Frontend URL is not configured") + JsonErrorResponse(w, http.StatusInternalServerError, "Frontend URL is not configured") return } @@ -216,21 +208,21 @@ func Callback(w http.ResponseWriter, r *http.Request) { func RefreshAccessToken(w http.ResponseWriter, r *http.Request) { refreshToken := r.Header.Get("Authorization") if refreshToken == "" { - jsonErrorResponse(w, http.StatusUnauthorized, "Refresh token is required") + JsonErrorResponse(w, http.StatusUnauthorized, "Refresh token is required") log.Warn("Refresh token is missing in the request header") return } token, err := VerifyRefreshToken(strings.Split(refreshToken, " ")[1]) if err != nil { - jsonErrorResponse(w, http.StatusUnauthorized, "Invalid refresh token") + JsonErrorResponse(w, http.StatusUnauthorized, "Invalid refresh token") log.Error("Error verifying refresh token:", err) return } newAccessToken, err := CreateAccessToken(token.Username) if err != nil { - jsonErrorResponse(w, http.StatusInternalServerError, "Failed to create new access token") + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to create new access token") log.Error("Error creating new access token:", err) return } @@ -245,11 +237,11 @@ func RefreshAccessToken(w http.ResponseWriter, r *http.Request) { } func Logout(w http.ResponseWriter, r *http.Request) { - username := r.Context().Value("user").( string ) + username := r.Context().Value("userName").( string ) log.Debug("Logging out user:", username) err := models.Logout(username) if err != nil { - jsonErrorResponse(w, http.StatusInternalServerError, "Failed to logout user") + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to logout user") log.Error("Error logging out user:", err) return } diff --git a/internal/controllers/github.go b/internal/controllers/github.go new file mode 100644 index 0000000..df09987 --- /dev/null +++ b/internal/controllers/github.go @@ -0,0 +1,111 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type GitHubFile struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Size int `json:"size"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + GitURL string `json:"git_url"` + DownloadURL string `json:"download_url"` + Type string `json:"type"` + Content string `json:"content"` + Encoding string `json:"encoding"` +} + +func checkProfileRepo(userName string, token string) bool { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s", userName, userName) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false + } + + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("Accept", "application/vnd.github.v3+json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK +} + +func fetchReadMeFile(userName string, token string) (*GitHubFile, error) { + if !checkProfileRepo(userName, token) { + return nil, fmt.Errorf("repository %s/%s does not exist or is not accessible", userName, userName) + } + + readmeNames := []string{"README.md", "readme.md", "Readme.md", "README.MD"} + + for _, readmeName := range readmeNames { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s", userName, userName, readmeName) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + continue + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + continue + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + var file GitHubFile + if err := json.NewDecoder(resp.Body).Decode(&file); err != nil { + continue + } + return &file, nil + } + } + + return nil, fmt.Errorf("README file not found in repository %s/%s", userName, userName) +} + +func GetReadMe(w http.ResponseWriter, r *http.Request) { + userName := r.Context().Value("userName").(string) + token := r.Context().Value("accessToken").(string) + + if userName == "" || token == "" { + http.Error(w, "Missing user or token parameter", http.StatusBadRequest) + return + } + + readmeFile, err := fetchReadMeFile(userName, token) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(readmeFile); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +type UpdateFileRequest struct { + Content string `json:"content" binding:"required"` + SHA string `json:"sha" binding:"required"` + Owner string `json:"owner" binding:"required"` + Repo string `json:"repo" binding:"required"` + Message string `json:"message,omitempty"` +} + +func UpdateFile(w http.ResponseWriter, r *http.Request) { + +} \ No newline at end of file diff --git a/internal/controllers/utils.go b/internal/controllers/utils.go index bac16d3..9dcca33 100644 --- a/internal/controllers/utils.go +++ b/internal/controllers/utils.go @@ -1,6 +1,8 @@ package controllers import ( + "encoding/json" + "net/http" "os" "time" @@ -15,6 +17,14 @@ type Token struct { Iss string `json:"iss"` } +func JsonErrorResponse(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{ + "error": message, + }) +} + func CreateAccessToken(username string) (string, error) { var secretKey = os.Getenv("JWT_ACCESS_SECRET") if secretKey == "" { @@ -119,4 +129,3 @@ func VerifyRefreshToken(tokenString string) (Token, error) { Iss: claims["iss"].(string), }, nil } - diff --git a/internal/middlewares/auth.go b/internal/middlewares/auth.go index 29ba7e8..8e92729 100644 --- a/internal/middlewares/auth.go +++ b/internal/middlewares/auth.go @@ -6,23 +6,44 @@ import ( "strings" "github.com/var-code-5/devboard-backend/internal/controllers" + "github.com/var-code-5/devboard-backend/internal/models" ) +func GetAccessToken(userName string) (string, error) { + + accessToken, err := models.GetAccessToken(userName) + if err != nil { + return "", err + } + return accessToken, nil +} + func VerifyAccessToken(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenString := r.Header.Get("Authorization") if tokenString == "" { - http.Error(w, "Authorization header is missing", http.StatusUnauthorized) + controllers.JsonErrorResponse(w, http.StatusUnauthorized, "Authorization header is missing") return } token, err := controllers.VerifyAccessToken(strings.Split(tokenString, " ")[1]) if err != nil { - http.Error(w, "Invalid access token", http.StatusUnauthorized) + controllers.JsonErrorResponse(w, http.StatusUnauthorized, "Invalid access token") return } - ctx := context.WithValue(r.Context(), "user", token.Username) - next.ServeHTTP(w, r.WithContext(ctx)) + githubAccessToken, err := GetAccessToken(token.Username) + if err != nil { + controllers.JsonErrorResponse(w, http.StatusUnauthorized, "Failed to retrieve GitHub access token") + return + } + if githubAccessToken == "" { + controllers.JsonErrorResponse(w, http.StatusUnauthorized, "GitHub access token is missing") + return + } + ctx := context.WithValue(r.Context(), "userName", token.Username) + ctx = context.WithValue(ctx, "accessToken", githubAccessToken) + next.ServeHTTP(w, r.WithContext(ctx)) }) } + diff --git a/internal/models/auth.go b/internal/models/auth.go index dab7dac..75cfa3e 100644 --- a/internal/models/auth.go +++ b/internal/models/auth.go @@ -82,4 +82,20 @@ func Logout(username string) error { update := bson.M{"$set": bson.M{"access_token": ""}} _, err = collection.UpdateOne(context.Background(), filter, update) return err +} + +func GetAccessToken(username string) (string, error) { + collection, err := GetCollection("users") + if err != nil { + return "", err + } + + filter := bson.M{"login": username} + var user User + err = collection.FindOne(context.Background(), filter).Decode(&user) + if err != nil { + return "", err + } + + return user.AccessToken, nil } \ No newline at end of file diff --git a/internal/models/utils.go b/internal/models/utils.go index 3822bbc..0df9ca6 100644 --- a/internal/models/utils.go +++ b/internal/models/utils.go @@ -55,4 +55,4 @@ func GetCollection(collectionName string) (*mongo.Collection, error) { return nil, fmt.Errorf("database connection is not initialized") } return database.Collection(collectionName), nil -} +} \ No newline at end of file diff --git a/internal/routes/github.go b/internal/routes/github.go new file mode 100644 index 0000000..ebc3336 --- /dev/null +++ b/internal/routes/github.go @@ -0,0 +1,14 @@ +package routes + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/var-code-5/devboard-backend/internal/controllers" + "github.com/var-code-5/devboard-backend/internal/middlewares" +) + +func RegisterGitHub(r *mux.Router) { + s := r.PathPrefix("/github").Subrouter() + s.Handle("/readme", middlewares.VerifyAccessToken(http.HandlerFunc(controllers.GetReadMe))).Methods("GET") +} diff --git a/internal/routes/router.go b/internal/routes/router.go index c12f602..bf092a0 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -14,6 +14,7 @@ func Router() *mux.Router { apiRouter := r.PathPrefix("/api").Subrouter() apiRouter.HandleFunc("/ping", ping).Methods("GET") RegisterAuth(apiRouter) + RegisterGitHub(apiRouter) return r } From 418937f9aac6d023be67eddf0e7b9fc3acf1fdc5 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sat, 14 Jun 2025 13:38:46 +0530 Subject: [PATCH 05/32] fix: frontend redirection --- internal/controllers/auth.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/controllers/auth.go b/internal/controllers/auth.go index 06cd08a..abcbcc6 100644 --- a/internal/controllers/auth.go +++ b/internal/controllers/auth.go @@ -163,8 +163,7 @@ func Callback(w http.ResponseWriter, r *http.Request) { log.Error("Error adding user to database:", err) return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) + jwtAccessToken, err := CreateAccessToken(user.Login) if err != nil { @@ -179,6 +178,8 @@ func Callback(w http.ResponseWriter, r *http.Request) { return } + // w.Header().Set("Content-Type", "application/json") + // w.WriteHeader(http.StatusOK) // json.NewEncoder(w).Encode(map[string]string{ // "message": "User authenticated successfully", // "access_token": jwtAccessToken, @@ -201,7 +202,8 @@ func Callback(w http.ResponseWriter, r *http.Request) { url.QueryEscape(user.Login), url.QueryEscape(user.AvatarURL), ) - http.Redirect(w, r, redirectURL, http.StatusSeeOther) + + http.Redirect(w, r, redirectURL, 302) log.Info("User authenticated successfully:", user.Login) } From 055402d78aa9a0ac3e8d4e0b162894a6a5f4f6c1 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sat, 14 Jun 2025 14:06:41 +0530 Subject: [PATCH 06/32] feat: add cors --- go.mod | 2 ++ go.sum | 4 ++++ internal/routes/router.go | 19 +++++++++++++++---- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 24d9c99..0a9dbc8 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,10 @@ require ( ) require ( + github.com/felixge/httpsnoop v1.0.3 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect + github.com/gorilla/handlers v1.5.2 // indirect github.com/klauspost/compress v1.16.7 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/go.sum b/go.sum index fcde996..b3b0752 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,16 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/internal/routes/router.go b/internal/routes/router.go index bf092a0..7ebbd77 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -2,23 +2,34 @@ package routes import ( "net/http" - "github.com/gorilla/mux" + "github.com/gorilla/handlers" "github.com/var-code-5/devboard-backend/internal/middlewares" ) func Router() *mux.Router { r := mux.NewRouter() + + // Add CORS middleware + corsHandler := handlers.CORS( + handlers.AllowedOrigins([]string{"http://localhost:3000"}), + handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), + handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Requested-With"}), + handlers.AllowCredentials(), + ) + + r.Use(corsHandler) r.Use(middlewares.Logger) - + apiRouter := r.PathPrefix("/api").Subrouter() apiRouter.HandleFunc("/ping", ping).Methods("GET") + RegisterAuth(apiRouter) RegisterGitHub(apiRouter) - + return r } func ping(w http.ResponseWriter, r *http.Request) { w.Write([]byte("pong")) -} +} \ No newline at end of file From 7122e2cdef968a8a2720b808d068a1e2d71f0aec Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sat, 14 Jun 2025 15:08:42 +0530 Subject: [PATCH 07/32] feat: add options for the preflight req --- .gitignore | 1 + internal/routes/router.go | 27 +++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 4c49bd7..335a441 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .env +cors-test.html \ No newline at end of file diff --git a/internal/routes/router.go b/internal/routes/router.go index 7ebbd77..9b0c0ca 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -10,17 +10,36 @@ import ( func Router() *mux.Router { r := mux.NewRouter() - // Add CORS middleware + // Add CORS middleware with more permissive settings for development corsHandler := handlers.CORS( - handlers.AllowedOrigins([]string{"http://localhost:3000"}), - handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), - handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Requested-With"}), + handlers.AllowedOrigins([]string{"http://localhost:3000", "http://127.0.0.1:3000", "http://127.0.0.1:5500"}), + handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}), + handlers.AllowedHeaders([]string{ + "Content-Type", + "Authorization", + "X-Requested-With", + "Accept", + "Origin", + "Cache-Control", + "X-File-Name", + }), handlers.AllowCredentials(), + handlers.ExposedHeaders([]string{"*"}), // Add this for better compatibility ) + // Apply CORS middleware to the entire router r.Use(corsHandler) r.Use(middlewares.Logger) + // Handle preflight requests explicitly + r.Methods("OPTIONS").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, Origin, Cache-Control, X-File-Name") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.WriteHeader(http.StatusOK) + }) + apiRouter := r.PathPrefix("/api").Subrouter() apiRouter.HandleFunc("/ping", ping).Methods("GET") From f943c2d72376a2826efe965fcfb8ad5089e4ed91 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sat, 14 Jun 2025 18:01:16 +0530 Subject: [PATCH 08/32] feat: add cd on oracle --- .github/workflows/deploy.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a145ec8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,32 @@ +name: "Deploy to Oracle Cloud" + +on: workflow_dispatch + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Set up SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts + + - name: Deploy to Oracle Cloud + run: | + echo "Deploying to Oracle Cloud..." + ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF' + echo "Stopping existing services..." + docker-compose down + + echo "Navigating to devboard-backend and pulling latest changes..." + cd devboard-backend + git pull + + echo "Starting services with fresh build..." + docker-compose up -d --build + EOF + + - name: Notify Deployment Success + run: echo "Deployment to Oracle Cloud completed successfully." From 987b6352842e281bd7d556436cb1547877940e2f Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sat, 14 Jun 2025 18:36:40 +0530 Subject: [PATCH 09/32] fix: cd workflow --- .github/workflows/deploy.yml | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a145ec8..403246f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,16 +17,37 @@ jobs: run: | echo "Deploying to Oracle Cloud..." ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF' - echo "Stopping existing services..." - docker-compose down echo "Navigating to devboard-backend and pulling latest changes..." cd devboard-backend git pull + echo "Stopping existing services..." + docker compose down + echo "Starting services with fresh build..." - docker-compose up -d --build + docker compose up -d --build EOF + - name: Cleanup SSH + if: always() + run: | + rm -f ~/.ssh/id_rsa + - name: Notify Deployment Success - run: echo "Deployment to Oracle Cloud completed successfully." + run: | + cat << 'EOF' + Deployment to Oracle Cloud completed successfully. + + _____ _ _ + | __ \ | | | | + | | | | ___ _ __ | | ___ _ _ ___ __| | + | | | |/ _ \ '_ \| |/ _ \| | | |/ _ \/ _` | + | |__| | __/ |_) | | (_) | |_| | __/ (_| | + |_____/ \___| .__/|_|\___/ \__, |\___|\__,_| + | | __/ | + |_| |___/ + EOF + + + From a914759af1970021955e84ece85ada927b613d4e Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sat, 14 Jun 2025 18:42:51 +0530 Subject: [PATCH 10/32] fix: yml formating --- .github/workflows/deploy.yml | 46 +++++++++++++++--------------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 403246f..5f87a52 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,5 +1,4 @@ name: "Deploy to Oracle Cloud" - on: workflow_dispatch jobs: @@ -12,42 +11,35 @@ jobs: echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts - + - name: Deploy to Oracle Cloud run: | echo "Deploying to Oracle Cloud..." ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF' - - echo "Navigating to devboard-backend and pulling latest changes..." - cd devboard-backend - git pull - - echo "Stopping existing services..." - docker compose down - - echo "Starting services with fresh build..." - docker compose up -d --build + echo "Navigating to devboard-backend and pulling latest changes..." + cd devboard-backend + git pull + echo "Stopping existing services..." + docker compose down + echo "Starting services with fresh build..." + docker compose up -d --build EOF - + - name: Cleanup SSH if: always() run: | rm -f ~/.ssh/id_rsa - + - name: Notify Deployment Success run: | cat << 'EOF' Deployment to Oracle Cloud completed successfully. - - _____ _ _ - | __ \ | | | | - | | | | ___ _ __ | | ___ _ _ ___ __| | - | | | |/ _ \ '_ \| |/ _ \| | | |/ _ \/ _` | - | |__| | __/ |_) | | (_) | |_| | __/ (_| | - |_____/ \___| .__/|_|\___/ \__, |\___|\__,_| - | | __/ | - |_| |___/ - EOF - - - + _____ _ _ + | __ \ | | | | + | | | | ___ _ __| | ___ _ _ ___ __| | + | | | |/ _ \ '_ \ |/ _ \| | | |/ _ \/ _` | + | |__| | __/ |_) | | (_) | |_| | __/ (_| | + |_____/ \___| .__/|_|\___/ \__, |\___|\__,_| + | | __/ | + |_| |___/ + EOF \ No newline at end of file From 7da87ce5af5cb43711e5663c5a9f104c95673f2d Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 15 Jun 2025 09:30:12 +0530 Subject: [PATCH 11/32] feat: add redis cache --- docker-compose.yml | 15 +++++- go.mod | 3 ++ go.sum | 6 +++ internal/cache/auth.go | 90 ++++++++++++++++++++++++++++++++++++ internal/cache/utils.go | 33 +++++++++++++ internal/controllers/auth.go | 66 +++++++++++++++++--------- internal/middlewares/auth.go | 20 +++----- internal/models/auth.go | 23 +++++++-- 8 files changed, 215 insertions(+), 41 deletions(-) create mode 100644 internal/cache/auth.go create mode 100644 internal/cache/utils.go diff --git a/docker-compose.yml b/docker-compose.yml index d7ec7fa..eb6ed6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,4 +11,17 @@ services: - .env volumes: - .env:/app/.env - \ No newline at end of file + depends_on: + - redis + + redis: + image: redis:alpine + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + restart: always + +volumes: + redis-data: \ No newline at end of file diff --git a/go.mod b/go.mod index 0a9dbc8..7801251 100644 --- a/go.mod +++ b/go.mod @@ -12,12 +12,15 @@ require ( ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/felixge/httpsnoop v1.0.3 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/klauspost/compress v1.16.7 // indirect github.com/montanaflynn/stats v0.7.1 // indirect + github.com/redis/go-redis/v9 v9.10.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/go.sum b/go.sum index b3b0752..edf8ec8 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= @@ -21,6 +25,8 @@ github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8 github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= +github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/cache/auth.go b/internal/cache/auth.go new file mode 100644 index 0000000..b4fdea5 --- /dev/null +++ b/internal/cache/auth.go @@ -0,0 +1,90 @@ +package cache + +import ( + "context" + "encoding/json" + "time" + + "github.com/redis/go-redis/v9" + "github.com/var-code-5/devboard-backend/internal/models" +) + +var ctx = context.Background() + +type TokenData struct { + GithubAccessToken string `json:"github_access_token"` + RefreshToken string `json:"refresh_token"` +} + +func SetTokens(username string, accessToken string, refreshToken string) error { + client := GetClient() + tokenData := TokenData{ + GithubAccessToken: accessToken, + RefreshToken: refreshToken, + } + + jsonData, err := json.Marshal(tokenData) + if err != nil { + return err + } + + err = client.Set(ctx, username, jsonData, time.Hour*1).Err() + if err != nil { + return err + } + return nil +} + +func GetTokens(username string) (*TokenData, error) { + client := GetClient() + + // Get JSON data from Redis + jsonData, err := client.Get(ctx, username).Result() + if err != nil { + if err == redis.Nil { + // Fallback to database + accessToken, err := models.GetAccessToken(username) + if err != nil { + return nil, err + } + refreshToken, err := models.GetRefreshToken(username) + if err != nil { + return nil, err + } + + if accessToken == "" && refreshToken == "" { + return nil, nil // No tokens found + } + + tokenData := &TokenData{ + GithubAccessToken: accessToken, + RefreshToken: refreshToken, + } + + err = SetTokens(username, accessToken, refreshToken) + if err != nil { + return nil, err + } + return tokenData, nil + } + return nil, err + } + + var tokenData TokenData + err = json.Unmarshal([]byte(jsonData), &tokenData) + if err != nil { + return nil, err + } + + return &tokenData, nil +} + +func Logout(username string) error { + client := GetClient() + // Remove the token from cache + err := client.Del(ctx, username).Err() + if err != nil { + return err + } + return models.Logout(username) +} \ No newline at end of file diff --git a/internal/cache/utils.go b/internal/cache/utils.go new file mode 100644 index 0000000..f75ebb3 --- /dev/null +++ b/internal/cache/utils.go @@ -0,0 +1,33 @@ +package cache + +import ( + "os" + + "github.com/joho/godotenv" + "github.com/redis/go-redis/v9" +) + +var client *redis.Client + +func init() { + err := godotenv.Load() + if err != nil { + panic("Error loading .env file") + } + + redisUrl := os.Getenv("REDIS_URL") + opt, err := redis.ParseURL(redisUrl) + if err != nil { + panic(err) + } + + client = redis.NewClient(opt) +} + + +func GetClient() *redis.Client { + if client == nil { + panic("Redis client is not initialized") + } + return client +} diff --git a/internal/controllers/auth.go b/internal/controllers/auth.go index abcbcc6..ccc0a36 100644 --- a/internal/controllers/auth.go +++ b/internal/controllers/auth.go @@ -5,12 +5,14 @@ import ( "encoding/base64" "encoding/json" "fmt" - log "github.com/sirupsen/logrus" - "github.com/var-code-5/devboard-backend/internal/models" "net/http" "net/url" "os" "strings" + + log "github.com/sirupsen/logrus" + "github.com/var-code-5/devboard-backend/internal/cache" + "github.com/var-code-5/devboard-backend/internal/models" ) func generateRandomString(length int) (string, error) { @@ -157,12 +159,6 @@ func Callback(w http.ResponseWriter, r *http.Request) { return } - user.AccessToken = response.AccessToken - if err := models.AddUser(user); err != nil { - JsonErrorResponse(w, http.StatusInternalServerError, "Failed to add user to database") - log.Error("Error adding user to database:", err) - return - } jwtAccessToken, err := CreateAccessToken(user.Login) @@ -178,15 +174,20 @@ func Callback(w http.ResponseWriter, r *http.Request) { return } - // w.Header().Set("Content-Type", "application/json") - // w.WriteHeader(http.StatusOK) - // json.NewEncoder(w).Encode(map[string]string{ - // "message": "User authenticated successfully", - // "access_token": jwtAccessToken, - // "refresh_token": jwtRefreshToken, - // "login": user.Login, - // "avatar_url": user.AvatarURL, - // }) + // set db and cache + user.AccessToken = response.AccessToken + user.RefreshToken = jwtRefreshToken + if err := models.AddUser(user); err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to add user to database") + log.Error("Error adding user to database:", err) + return + } + + if err := cache.SetTokens(user.Login, user.AccessToken, jwtRefreshToken); err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to store tokens in cache") + log.Error("Error storing tokens in cache:", err) + return + } frontendURL := os.Getenv("FRONTEND_URL") if frontendURL == "" { @@ -202,7 +203,7 @@ func Callback(w http.ResponseWriter, r *http.Request) { url.QueryEscape(user.Login), url.QueryEscape(user.AvatarURL), ) - + http.Redirect(w, r, redirectURL, 302) log.Info("User authenticated successfully:", user.Login) } @@ -222,6 +223,25 @@ func RefreshAccessToken(w http.ResponseWriter, r *http.Request) { return } + if token.Username == "" { + JsonErrorResponse(w, http.StatusUnauthorized, "Invalid refresh token - username not found") + log.Warn("Invalid refresh token - username not found") + return + } + + tokens, err := cache.GetTokens(token.Username) + if err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to fetch tokens") + log.Error("Error getting tokens from cache:", err) + return + } + + if tokens.RefreshToken != strings.Split(refreshToken, " ")[1] { + JsonErrorResponse(w, http.StatusUnauthorized, "Invalid refresh token") + log.Warn("Invalid refresh token") + return + } + newAccessToken, err := CreateAccessToken(token.Username) if err != nil { JsonErrorResponse(w, http.StatusInternalServerError, "Failed to create new access token") @@ -232,16 +252,16 @@ func RefreshAccessToken(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{ - "message": "Access token refreshed successfully", - "access_token": newAccessToken, + "message": "Access token refreshed successfully", + "access_token": newAccessToken, }) log.Info("Access token refreshed successfully for user:", token.Username) } func Logout(w http.ResponseWriter, r *http.Request) { - username := r.Context().Value("userName").( string ) + username := r.Context().Value("userName").(string) log.Debug("Logging out user:", username) - err := models.Logout(username) + err := cache.Logout(username) if err != nil { JsonErrorResponse(w, http.StatusInternalServerError, "Failed to logout user") log.Error("Error logging out user:", err) @@ -253,4 +273,4 @@ func Logout(w http.ResponseWriter, r *http.Request) { "message": "User logged out successfully", }) log.Info("User logged out successfully:", username) -} \ No newline at end of file +} diff --git a/internal/middlewares/auth.go b/internal/middlewares/auth.go index 8e92729..c08cb6d 100644 --- a/internal/middlewares/auth.go +++ b/internal/middlewares/auth.go @@ -5,19 +5,10 @@ import ( "net/http" "strings" + "github.com/var-code-5/devboard-backend/internal/cache" "github.com/var-code-5/devboard-backend/internal/controllers" - "github.com/var-code-5/devboard-backend/internal/models" ) -func GetAccessToken(userName string) (string, error) { - - accessToken, err := models.GetAccessToken(userName) - if err != nil { - return "", err - } - return accessToken, nil -} - func VerifyAccessToken(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenString := r.Header.Get("Authorization") @@ -31,18 +22,19 @@ func VerifyAccessToken(next http.Handler) http.Handler { return } - githubAccessToken, err := GetAccessToken(token.Username) + tokens, err := cache.GetTokens(token.Username) + if err != nil { controllers.JsonErrorResponse(w, http.StatusUnauthorized, "Failed to retrieve GitHub access token") return } - if githubAccessToken == "" { - controllers.JsonErrorResponse(w, http.StatusUnauthorized, "GitHub access token is missing") + if tokens.GithubAccessToken == "" { + controllers.JsonErrorResponse(w, http.StatusUnauthorized, "GitHub access token is missing please login") return } ctx := context.WithValue(r.Context(), "userName", token.Username) - ctx = context.WithValue(ctx, "accessToken", githubAccessToken) + ctx = context.WithValue(ctx, "accessToken", tokens.GithubAccessToken) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/internal/models/auth.go b/internal/models/auth.go index 75cfa3e..92f2584 100644 --- a/internal/models/auth.go +++ b/internal/models/auth.go @@ -45,6 +45,7 @@ type User struct { CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` AccessToken string `json:"access_token,omitempty" bson:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty" bson:"refresh_token,omitempty"` } func AddUser(user User) error { @@ -57,8 +58,8 @@ func AddUser(user User) error { filter := bson.M{"github_id": user.GithubID} existingUser := collection.FindOne(context.Background(), filter) if existingUser.Err() == nil { - // User already exists upadte the access token - update := bson.M{"$set": bson.M{"access_token": user.AccessToken}} + // User already exists update the access token + update := bson.M{"$set": bson.M{"access_token": user.AccessToken, "refresh_token": user.RefreshToken}} _, err := collection.UpdateOne(context.Background(), filter, update) if err != nil { return err @@ -79,7 +80,7 @@ func Logout(username string) error { } filter := bson.M{"login": username} - update := bson.M{"$set": bson.M{"access_token": ""}} + update := bson.M{"$set": bson.M{"access_token": "", "refresh_token": ""}} _, err = collection.UpdateOne(context.Background(), filter, update) return err } @@ -98,4 +99,20 @@ func GetAccessToken(username string) (string, error) { } return user.AccessToken, nil +} + +func GetRefreshToken(username string) (string, error) { + collection, err := GetCollection("users") + if err != nil { + return "", err + } + + filter := bson.M{"login": username} + var user User + err = collection.FindOne(context.Background(), filter).Decode(&user) + if err != nil { + return "", err + } + + return user.RefreshToken, nil } \ No newline at end of file From 59cd247397c2c4558a2bd403b1298912a8f4ed2a Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 15 Jun 2025 11:01:50 +0530 Subject: [PATCH 12/32] refactor: to use docker hub image --- docker-compose.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index eb6ed6f..0b9c03b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,9 @@ services: app: - build: - context: . - dockerfile: dockerfile + # build: + # context: . + # dockerfile: dockerfile + image: varcode05/devboard-backend:latest ports: - "80:3000" # expose port 3000 on the container to port 80 on the host From dc39a7cb0b59daa8e33f04fda6b5dda1f581097f Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 15 Jun 2025 11:21:04 +0530 Subject: [PATCH 13/32] feat: add image autobuild workflow --- .github/workflows/deploy.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5f87a52..b4aa99e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,29 @@ jobs: deploy: runs-on: ubuntu-latest steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + varcode05/devboard-backend:latest + varcode05/devboard-backend:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Set up SSH run: | mkdir -p ~/.ssh From d1dbb62691b4c01d5cf7b617c869af1fe5aaf8f5 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 15 Jun 2025 13:10:16 +0530 Subject: [PATCH 14/32] feat: add upadte and create readme routes --- internal/controllers/github.go | 229 ++++++++++++++++++++++++++++++++- internal/routes/github.go | 7 +- 2 files changed, 228 insertions(+), 8 deletions(-) diff --git a/internal/controllers/github.go b/internal/controllers/github.go index df09987..1849b55 100644 --- a/internal/controllers/github.go +++ b/internal/controllers/github.go @@ -1,9 +1,12 @@ package controllers import ( + "bytes" "encoding/json" "fmt" "net/http" + + log "github.com/sirupsen/logrus" ) type GitHubFile struct { @@ -99,13 +102,229 @@ func GetReadMe(w http.ResponseWriter, r *http.Request) { } type UpdateFileRequest struct { - Content string `json:"content" binding:"required"` - SHA string `json:"sha" binding:"required"` - Owner string `json:"owner" binding:"required"` - Repo string `json:"repo" binding:"required"` - Message string `json:"message,omitempty"` + Content string `json:"content" binding:"required"` + SHA string `json:"sha" binding:"required"` + Message string `json:"message,omitempty"` +} + +type GitHubUpdateResponse struct { + Content struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Size int `json:"size"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + GitURL string `json:"git_url"` + DownloadURL string `json:"download_url"` + Type string `json:"type"` + } `json:"content"` + Commit struct { + SHA string `json:"sha"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + Author struct { + Date string `json:"date"` + Name string `json:"name"` + Email string `json:"email"` + } `json:"author"` + Committer struct { + Date string `json:"date"` + Name string `json:"name"` + Email string `json:"email"` + } `json:"committer"` + Message string `json:"message"` + } `json:"commit"` } func UpdateFile(w http.ResponseWriter, r *http.Request) { + var updateReq UpdateFileRequest + if err := json.NewDecoder(r.Body).Decode(&updateReq); err != nil { + JsonErrorResponse(w, http.StatusBadRequest, "Invalid request body") + return + } + + if updateReq.Content == "" || updateReq.SHA == "" { + JsonErrorResponse(w, http.StatusBadRequest, "Missing required fields") + return + } + + userName := r.Context().Value("userName").(string) + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/README.md", userName, userName) + if updateReq.Message == "" { + updateReq.Message = "feat: Update README.md" + } + + reqBody := map[string]interface{}{ + "message": updateReq.Message, + "content": updateReq.Content, + "sha": updateReq.SHA, + } + + reqBodyJSON, _ := json.Marshal(reqBody) + + req, err := http.NewRequest("PUT", url, bytes.NewBuffer(reqBodyJSON)) + if err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to create request") + log.Errorf("Failed to create request: %v", err) + return + } + + token := r.Context().Value("accessToken").(string) + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("Accept", "application/vnd.github.v3+json") + req.Header.Add("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to update file") + log.Errorf("Failed to update file: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Error("Error updating README file:", err) + + // Check if it's a specific GitHub API error + if resp.StatusCode == http.StatusConflict { + JsonErrorResponse(w, http.StatusConflict, "File has been modified. Please refresh and try again.") + return + } else if resp.StatusCode == http.StatusNotFound { + JsonErrorResponse(w, http.StatusNotFound, "Repository or file not found") + return + } else if resp.StatusCode == http.StatusForbidden { + JsonErrorResponse(w, http.StatusForbidden, "Permission denied. Check repository access.") + return + } + + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to update README file") + return + } + + var updateResponse GitHubUpdateResponse + if err := json.NewDecoder(resp.Body).Decode(&updateResponse); err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to decode response") + log.Errorf("Failed to decode response: %v", err) + return + } + + log.Info("README file updated successfully for user:", userName) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(updateResponse); err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to encode response") + log.Errorf("Failed to encode response: %v", err) + return + } +} + +func createRepo(userName string, token string) error { + url := fmt.Sprintf("https://api.github.com/user/repos") + reqBody := map[string]interface{}{ + "name": userName, + "private": false, + } + + reqBodyJSON, _ := json.Marshal(reqBody) + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBodyJSON)) + if err != nil { + return fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("Accept", "application/vnd.github.v3+json") + req.Header.Add("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to create repository: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("failed to create repository: %v", err) + } + return nil +} + +type CreateFileRequest struct { + Content string `json:"content" binding:"required"` + Message string `json:"message,omitempty"` +} + +func CreateFile(w http.ResponseWriter, r *http.Request) { + userName := r.Context().Value("userName").(string) + token := r.Context().Value("accessToken").(string) + + if userName == "" || token == "" { + JsonErrorResponse(w, http.StatusBadRequest, "Missing user or token parameter") + return + } + + if !checkProfileRepo(userName, token) { + if err := createRepo(userName, token); err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + } + + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/README.md", userName, userName) + var createReq CreateFileRequest + if err := json.NewDecoder(r.Body).Decode(&createReq); err != nil { + JsonErrorResponse(w, http.StatusBadRequest, "Invalid request body") + return + } + + if createReq.Message == "" { + createReq.Message = "feat: Create README.md" + } + + reqBody := map[string]interface{}{ + "message": createReq.Message, + "content": createReq.Content, + } + reqBodyJSON, _ := json.Marshal(reqBody) + + req,err := http.NewRequest("PUT", url, bytes.NewBuffer(reqBodyJSON)) + if err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to create request") + log.Errorf("Failed to create request: %v", err) + return + } + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("Accept", "application/vnd.github.v3+json") + req.Header.Add("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to create file") + log.Errorf("Failed to create file: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to create file") + log.Errorf("Failed to create file: %v", err) + return + } + var createResponse GitHubUpdateResponse + if err := json.NewDecoder(resp.Body).Decode(&createResponse); err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to decode response") + log.Errorf("Failed to decode response: %v", err) + return + } + log.Info("README file created successfully for user:", userName) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(createResponse); err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to encode response") + log.Errorf("Failed to encode response: %v", err) + return + } } \ No newline at end of file diff --git a/internal/routes/github.go b/internal/routes/github.go index ebc3336..e241e15 100644 --- a/internal/routes/github.go +++ b/internal/routes/github.go @@ -1,8 +1,6 @@ package routes import ( - "net/http" - "github.com/gorilla/mux" "github.com/var-code-5/devboard-backend/internal/controllers" "github.com/var-code-5/devboard-backend/internal/middlewares" @@ -10,5 +8,8 @@ import ( func RegisterGitHub(r *mux.Router) { s := r.PathPrefix("/github").Subrouter() - s.Handle("/readme", middlewares.VerifyAccessToken(http.HandlerFunc(controllers.GetReadMe))).Methods("GET") + s.Use(middlewares.VerifyAccessToken) + s.HandleFunc("/readme", controllers.GetReadMe).Methods("GET") + s.HandleFunc("/readme", controllers.UpdateFile).Methods("PATCH") + s.HandleFunc("/readme", controllers.CreateFile).Methods("POST") } From 90dac400b9ab4d2d07f09c57ad9c3a41c5d160c8 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 15 Jun 2025 13:25:53 +0530 Subject: [PATCH 15/32] fix: error handling for readme post --- internal/controllers/github.go | 4 ++++ internal/routes/github.go | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/controllers/github.go b/internal/controllers/github.go index 1849b55..caa0ce0 100644 --- a/internal/controllers/github.go +++ b/internal/controllers/github.go @@ -271,6 +271,10 @@ func CreateFile(w http.ResponseWriter, r *http.Request) { JsonErrorResponse(w, http.StatusInternalServerError, err.Error()) return } + }else{ + log.Info("Repository already exists for user:", userName) + JsonErrorResponse(w, http.StatusConflict, "Repository already exists use PATCH method to update README.md") + return } url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/README.md", userName, userName) diff --git a/internal/routes/github.go b/internal/routes/github.go index e241e15..fabc8bf 100644 --- a/internal/routes/github.go +++ b/internal/routes/github.go @@ -9,7 +9,9 @@ import ( func RegisterGitHub(r *mux.Router) { s := r.PathPrefix("/github").Subrouter() s.Use(middlewares.VerifyAccessToken) + + // Add OPTIONS method to handle CORS preflight requests s.HandleFunc("/readme", controllers.GetReadMe).Methods("GET") - s.HandleFunc("/readme", controllers.UpdateFile).Methods("PATCH") - s.HandleFunc("/readme", controllers.CreateFile).Methods("POST") + s.HandleFunc("/readme", controllers.UpdateFile).Methods("PATCH", "OPTIONS") + s.HandleFunc("/readme", controllers.CreateFile).Methods("POST", "OPTIONS") } From 18623d112cc2a3c8bb3bb6902a4a4a3ad4b82649 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 15 Jun 2025 14:29:57 +0530 Subject: [PATCH 16/32] fix: option token checking in middleare --- internal/cache/utils.go | 17 +++++++++++++---- internal/controllers/github.go | 6 ++++-- internal/middlewares/auth.go | 8 ++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/internal/cache/utils.go b/internal/cache/utils.go index f75ebb3..ccde9ce 100644 --- a/internal/cache/utils.go +++ b/internal/cache/utils.go @@ -1,8 +1,10 @@ package cache import ( + "context" "os" + log "github.com/sirupsen/logrus" "github.com/joho/godotenv" "github.com/redis/go-redis/v9" ) @@ -12,22 +14,29 @@ var client *redis.Client func init() { err := godotenv.Load() if err != nil { - panic("Error loading .env file") + log.Error("Error loading .env file") } redisUrl := os.Getenv("REDIS_URL") opt, err := redis.ParseURL(redisUrl) if err != nil { - panic(err) + log.Error(err) } client = redis.NewClient(opt) -} + // Ping the Redis server to check connection + ctx := context.Background() + _, err = client.Ping(ctx).Result() + if err != nil { + log.Error("Failed to connect to Redis: " + err.Error()) + } +} func GetClient() *redis.Client { if client == nil { - panic("Redis client is not initialized") + log.Error("Redis client is not initialized") } return client } + diff --git a/internal/controllers/github.go b/internal/controllers/github.go index caa0ce0..b5a8d1c 100644 --- a/internal/controllers/github.go +++ b/internal/controllers/github.go @@ -91,13 +91,15 @@ func GetReadMe(w http.ResponseWriter, r *http.Request) { readmeFile, err := fetchReadMeFile(userName, token) if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) + JsonErrorResponse(w, http.StatusNotFound, "README file not found in repository") + log.Errorf("Failed to fetch README file for user %s: %v", userName, err) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(readmeFile); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to encode response") + log.Errorf("Failed to encode response: %v", err) } } diff --git a/internal/middlewares/auth.go b/internal/middlewares/auth.go index c08cb6d..f0d0e59 100644 --- a/internal/middlewares/auth.go +++ b/internal/middlewares/auth.go @@ -11,6 +11,14 @@ import ( func VerifyAccessToken(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + // Handle CORS preflight requests + // This allows the browser to make requests without being blocked by CORS policy + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + tokenString := r.Header.Get("Authorization") if tokenString == "" { controllers.JsonErrorResponse(w, http.StatusUnauthorized, "Authorization header is missing") From 6552dca01705384eafd3ec1816a6b17782179108 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 15 Jun 2025 15:55:11 +0530 Subject: [PATCH 17/32] fix: exiting repo but no readme --- internal/controllers/github.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/controllers/github.go b/internal/controllers/github.go index b5a8d1c..63fe9bb 100644 --- a/internal/controllers/github.go +++ b/internal/controllers/github.go @@ -275,8 +275,12 @@ func CreateFile(w http.ResponseWriter, r *http.Request) { } }else{ log.Info("Repository already exists for user:", userName) - JsonErrorResponse(w, http.StatusConflict, "Repository already exists use PATCH method to update README.md") - return + _, err := fetchReadMeFile(userName, token) + if err == nil { + JsonErrorResponse(w, http.StatusConflict, "README file already exists in the repository use PATCH method to update it") + log.Warnf("README file already exists for user %s", userName) + return + } } url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/README.md", userName, userName) From 3684c4f26bea660e1f11bddfa87440d5121acce9 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 15 Jun 2025 16:21:46 +0530 Subject: [PATCH 18/32] fix: better logging --- internal/routes/router.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/routes/router.go b/internal/routes/router.go index 9b0c0ca..dcd8e85 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -27,9 +27,9 @@ func Router() *mux.Router { handlers.ExposedHeaders([]string{"*"}), // Add this for better compatibility ) + r.Use(middlewares.Logger) // Apply CORS middleware to the entire router r.Use(corsHandler) - r.Use(middlewares.Logger) // Handle preflight requests explicitly r.Methods("OPTIONS").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 0f8f0178d026a83055afe7624abbb918dce9c3ab Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 15 Jun 2025 16:30:57 +0530 Subject: [PATCH 19/32] test: docker image --- cmd/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/main.go b/cmd/main.go index dd02e80..66ec07b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -29,6 +29,7 @@ func init() { func main() { router := routes.Router() + log.Info("Router initialized successfully") log.Info("Starting server on :3000") log.Fatal(http.ListenAndServe(":3000", router)) } From 886440a896aa8c8ff19e42b2173bc8f2f66003ff Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 15 Jun 2025 16:42:25 +0530 Subject: [PATCH 20/32] fix: docker image issue --- .github/workflows/deploy.yml | 22 +++++++++++++--------- docker-compose.yml | 12 ++++++------ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b4aa99e..0fc5d3e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,22 +1,21 @@ name: "Deploy to Oracle Cloud" on: workflow_dispatch - jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - + - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - + - name: Build and push Docker image uses: docker/build-push-action@v5 with: @@ -26,8 +25,8 @@ jobs: varcode05/devboard-backend:latest varcode05/devboard-backend:${{ github.sha }} cache-from: type=gha - cache-to: type=gha,mode=max - + cache-to: type=gha,mode=max + - name: Set up SSH run: | mkdir -p ~/.ssh @@ -38,14 +37,19 @@ jobs: - name: Deploy to Oracle Cloud run: | echo "Deploying to Oracle Cloud..." - ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF' + ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << EOF echo "Navigating to devboard-backend and pulling latest changes..." cd devboard-backend git pull + echo "Stopping existing services..." docker compose down - echo "Starting services with fresh build..." - docker compose up -d --build + + echo "Setting IMAGE_TAG environment variable..." + export IMAGE_TAG=${{ github.sha }} + + echo "Starting services with specific SHA tag..." + IMAGE_TAG=${{ github.sha }} docker compose up -d EOF - name: Cleanup SSH diff --git a/docker-compose.yml b/docker-compose.yml index 0b9c03b..d91484d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,13 @@ services: app: # build: - # context: . - # dockerfile: dockerfile - image: varcode05/devboard-backend:latest + # context: . + # dockerfile: dockerfile + image: varcode05/devboard-backend:${IMAGE_TAG:-latest} ports: - - "80:3000" - # expose port 3000 on the container to port 80 on the host - #todo: add reverse proxy if needed + - "80:3000" + # expose port 3000 on the container to port 80 on the host + #todo: add reverse proxy if needed env_file: - .env volumes: From 78c3a52145a0047ff76a785cd5cea7d213aaaa89 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Wed, 2 Jul 2025 17:12:45 +0530 Subject: [PATCH 21/32] feat: add traefik ssl --- docker-compose.yml | 50 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d91484d..145b220 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,51 @@ +version: "3.8" + services: + traefik: + image: traefik:v2.10 + command: + - --api.dashboard=true + - --api.insecure=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --providers.docker=true + - --certificatesresolvers.myresolver.acme.httpchallenge=true + - --certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web + - --certificatesresolvers.myresolver.acme.email=you@example.com + - --certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./letsencrypt:/letsencrypt + labels: + - "traefik.enable=true" + - "traefik.http.routers.traefik.rule=Host(`devboard.varshith.tech`) && PathPrefix(`/traefik-dashboard`)" + - "traefik.http.routers.traefik.entrypoints=websecure" + - "traefik.http.routers.traefik.tls.certresolver=myresolver" + - "traefik.http.routers.traefik.service=api@internal" + - "traefik.http.routers.traefik.middlewares=auth" + - "traefik.http.middlewares.auth.basicauth.users=varcode:$apr1$ufU1xjii$psczrBqtSdyyHJVKh2khL1" + + app: - # build: - # context: . - # dockerfile: dockerfile image: varcode05/devboard-backend:${IMAGE_TAG:-latest} - ports: - - "80:3000" - # expose port 3000 on the container to port 80 on the host - #todo: add reverse proxy if needed env_file: - .env volumes: - .env:/app/.env depends_on: - redis - + labels: + - "traefik.enable=true" + - "traefik.http.routers.app.rule=Host(`devboard.varshith.tech`)" + - "traefik.http.routers.app.entrypoints=websecure" + - "traefik.http.routers.app.tls.certresolver=myresolver" + - "traefik.http.services.app.loadbalancer.server.port=3000" + restart: always + + redis: image: redis:alpine ports: @@ -25,4 +56,5 @@ services: restart: always volumes: - redis-data: \ No newline at end of file + redis-data: + letsencrypt: From fa66557ec09af4bf7624ee31b2afe5ea0ea8252d Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Wed, 2 Jul 2025 17:17:57 +0530 Subject: [PATCH 22/32] fix: add email --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 145b220..0c8011b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - --providers.docker=true - --certificatesresolvers.myresolver.acme.httpchallenge=true - --certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web - - --certificatesresolvers.myresolver.acme.email=you@example.com + - --certificatesresolvers.myresolver.acme.email=varshithisgod@gmail.com - --certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json ports: - "80:80" From 952c158f1e890f2b0a50c3eb123231fa7a04bf3f Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Wed, 2 Jul 2025 17:34:57 +0530 Subject: [PATCH 23/32] fix: docker-compose indenting --- docker-compose.yml | 55 ++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0c8011b..74ffef7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,33 +1,31 @@ version: "3.8" - services: traefik: - image: traefik:v2.10 - command: - - --api.dashboard=true - - --api.insecure=false - - --entrypoints.web.address=:80 - - --entrypoints.websecure.address=:443 - - --providers.docker=true - - --certificatesresolvers.myresolver.acme.httpchallenge=true - - --certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web - - --certificatesresolvers.myresolver.acme.email=varshithisgod@gmail.com - - --certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json - ports: - - "80:80" - - "443:443" - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - - ./letsencrypt:/letsencrypt - labels: - - "traefik.enable=true" - - "traefik.http.routers.traefik.rule=Host(`devboard.varshith.tech`) && PathPrefix(`/traefik-dashboard`)" - - "traefik.http.routers.traefik.entrypoints=websecure" - - "traefik.http.routers.traefik.tls.certresolver=myresolver" - - "traefik.http.routers.traefik.service=api@internal" - - "traefik.http.routers.traefik.middlewares=auth" - - "traefik.http.middlewares.auth.basicauth.users=varcode:$apr1$ufU1xjii$psczrBqtSdyyHJVKh2khL1" - + image: traefik:v2.10 + command: + - --api.dashboard=true + - --api.insecure=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --providers.docker=true + - --certificatesresolvers.myresolver.acme.httpchallenge=true + - --certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web + - --certificatesresolvers.myresolver.acme.email=varshithisgod@gmail.com + - --certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./letsencrypt:/letsencrypt + labels: + - "traefik.enable=true" + - "traefik.http.routers.traefik.rule=Host(`devboard.varshith.tech`) && PathPrefix(`/traefik-dashboard`)" + - "traefik.http.routers.traefik.entrypoints=websecure" + - "traefik.http.routers.traefik.tls.certresolver=myresolver" + - "traefik.http.routers.traefik.service=api@internal" + - "traefik.http.routers.traefik.middlewares=auth" + - "traefik.http.middlewares.auth.basicauth.users=varcode:$$apr1$$3msTBU5l$$sH0xSDeZajSQ0QeV1.u1Y/" app: image: varcode05/devboard-backend:${IMAGE_TAG:-latest} @@ -45,7 +43,6 @@ services: - "traefik.http.services.app.loadbalancer.server.port=3000" restart: always - redis: image: redis:alpine ports: @@ -57,4 +54,4 @@ services: volumes: redis-data: - letsencrypt: + letsencrypt: \ No newline at end of file From 2be96db0f098448d9e56b322fafb92ab4343f89a Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Wed, 2 Jul 2025 22:07:17 +0530 Subject: [PATCH 24/32] fix: cors issue using env --- internal/routes/router.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/routes/router.go b/internal/routes/router.go index dcd8e85..d078164 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -2,8 +2,10 @@ package routes import ( "net/http" - "github.com/gorilla/mux" + "os" + "github.com/gorilla/handlers" + "github.com/gorilla/mux" "github.com/var-code-5/devboard-backend/internal/middlewares" ) @@ -11,8 +13,9 @@ func Router() *mux.Router { r := mux.NewRouter() // Add CORS middleware with more permissive settings for development + frontendUrl := os.Getenv("FRONTEND_URL") corsHandler := handlers.CORS( - handlers.AllowedOrigins([]string{"http://localhost:3000", "http://127.0.0.1:3000", "http://127.0.0.1:5500"}), + handlers.AllowedOrigins([]string{"http://localhost:3000", "http://127.0.0.1:3000", "http://127.0.0.1:5500", frontendUrl}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}), handlers.AllowedHeaders([]string{ "Content-Type", From f606d76d1b1f3243ac22de47d543b77ad44c02f3 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Thu, 10 Jul 2025 19:29:31 +0530 Subject: [PATCH 25/32] feat: add widgets --- .env.example | 15 +- .gitignore | 3 +- go.mod | 7 +- go.sum | 6 +- internal/controllers/widgets.go | 408 ++++++++++++++++++++++++++++++++ internal/models/widgets.go | 122 ++++++++++ internal/routes/router.go | 5 +- internal/routes/widget.go | 24 ++ 8 files changed, 575 insertions(+), 15 deletions(-) create mode 100644 internal/controllers/widgets.go create mode 100644 internal/models/widgets.go create mode 100644 internal/routes/widget.go diff --git a/.env.example b/.env.example index 555c898..062a554 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,11 @@ -GITHUB_CLIENT_ID=your_github_client_id -GITHUB_CLIENT_SECRET=your_github_client_secret -PROD_DOMAIN=http://localhost:3000 +GITHUB_CLIENT_ID=your_client_id_here +GITHUB_CLIENT_SECRET=your_client_secret_here +PROD_DOMAIN=http://localhost LOG_LEVEL=debug -MONGODB_URI=mongodb+srv://username:password@host/database +MONGODB_URI=mongodb+srv://your_mongodb_uri_here MONGODB_DB_NAME=devBoard -JWT_REFRESH_SECRET=your_refresh_secret_key -JWT_ACCESS_SECRET=your_access_secret_key \ No newline at end of file +JWT_REFRESH_SECRET=your_refresh_secret_here +JWT_ACCESS_SECRET=your_access_secret_here +FRONTEND_URL=http://localhost:3000 +REDIS_URL=redis://localhost:6379 +GH_PAT=your_github_pat_here \ No newline at end of file diff --git a/.gitignore b/.gitignore index 335a441..dbb833f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env -cors-test.html \ No newline at end of file +cors-test.html +localtest.go diff --git a/go.mod b/go.mod index 7801251..0b0afae 100644 --- a/go.mod +++ b/go.mod @@ -5,22 +5,21 @@ go 1.24.3 require github.com/gorilla/mux v1.8.1 require ( + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/gorilla/handlers v1.5.2 github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.10.0 github.com/sirupsen/logrus v1.9.3 go.mongodb.org/mongo-driver v1.17.3 - go.mongodb.org/mongo-driver/v2 v2.2.1 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/felixge/httpsnoop v1.0.3 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect - github.com/gorilla/handlers v1.5.2 // indirect github.com/klauspost/compress v1.16.7 // indirect github.com/montanaflynn/stats v0.7.1 // indirect - github.com/redis/go-redis/v9 v9.10.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/go.sum b/go.sum index edf8ec8..cd8e7ef 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -43,8 +47,6 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -go.mongodb.org/mongo-driver/v2 v2.2.1 h1:w5xra3yyu/sGrziMzK1D0cRRaH/b7lWCSsoN6+WV6AM= -go.mongodb.org/mongo-driver/v2 v2.2.1/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= diff --git a/internal/controllers/widgets.go b/internal/controllers/widgets.go new file mode 100644 index 0000000..3d8c94c --- /dev/null +++ b/internal/controllers/widgets.go @@ -0,0 +1,408 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" + "github.com/var-code-5/devboard-backend/internal/models" +) + +func AddWidget(w http.ResponseWriter, r *http.Request) { + var newWidget models.NewWidget + if err := json.NewDecoder(r.Body).Decode(&newWidget); err != nil { + JsonErrorResponse(w, http.StatusBadRequest, "Invalid request body") + return + } + userName := r.Context().Value("userName").(string) + newWidget.CreatedBy = userName + + keys := extractPlaceholders(newWidget.Content) + err := validatePlaceholders(keys) + + if err != nil { + JsonErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + if err := models.AddWidget(newWidget); err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + response := map[string]string{"message": "Widget added successfully"} + json.NewEncoder(w).Encode(response) +} + +func GetWidget(w http.ResponseWriter, r *http.Request) { + userName := r.Context().Value("userName").(string) + widgetName := r.URL.Query().Get("name") + + if widgetName == "" { + JsonErrorResponse(w, http.StatusBadRequest, "Widget name is required") + return + } + + widget, err := models.GetWidget(userName, widgetName) + if err != nil { + JsonErrorResponse(w, http.StatusNotFound, err.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(widget) +} + +//todo: add cache +func GetUserWidgets(w http.ResponseWriter, r *http.Request) { + userName := r.Context().Value("userName").(string) + + widgets, err := models.GetAllWidgets(userName) + if err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + if len(widgets) == 0 { + JsonErrorResponse(w, http.StatusNotFound, "No widgets found for the user") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(widgets) +} + +func EditWidget(w http.ResponseWriter, r *http.Request) { + var updatedWidget models.NewWidget + if err := json.NewDecoder(r.Body).Decode(&updatedWidget); err != nil { + JsonErrorResponse(w, http.StatusBadRequest, "Invalid request body") + return + } + userName := r.Context().Value("userName").(string) + updatedWidget.CreatedBy = userName + + if err := models.EditWidget(userName, updatedWidget.Name, updatedWidget); err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + response := map[string]string{"message": "Widget updated successfully"} + json.NewEncoder(w).Encode(response) +} + +func DeleteWidget(w http.ResponseWriter, r *http.Request) { + userName := r.Context().Value("userName").(string) + widgetName := r.URL.Query().Get("name") + + if widgetName == "" { + JsonErrorResponse(w, http.StatusBadRequest, "Widget name is required") + return + } + + if err := models.DeleteWidget(userName, widgetName); err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + response := map[string]string{"message": "Widget deleted successfully"} + json.NewEncoder(w).Encode(response) +} + +var rePlaceholder = regexp.MustCompile(`{{\s*(\w+)\s*}}`) + +var validPlaceholders = map[string]bool{ + "totalCommitContributions": true, + "totalIssueContributions": true, + "totalPullRequestContributions": true, + "totalRepositoriesWithContributedCommits": true, + "totalStars": true, + "totalRepositories": true, + "username": true, +} + +func validatePlaceholders(keys []string) error { + for _, key := range keys { + if !validPlaceholders[key] { + return fmt.Errorf("invalid placeholder: %s", key) + } + } + return nil +} + +func extractPlaceholders(s string) []string { + matches := rePlaceholder.FindAllStringSubmatch(s, -1) + var keys []string + for _, m := range matches { + keys = append(keys, m[1]) // m[1] is the inner word + } + return keys +} + +func makeGraphQLQuery(query map[string]interface{}) (map[string]interface{}, error) { + githubAccessToken := os.Getenv("GH_PAT") + if githubAccessToken == "" { + return nil, fmt.Errorf("GitHub access token not set") + } + + payload, err := json.Marshal(query) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", "https://api.github.com/graphql", strings.NewReader(string(payload))) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+githubAccessToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API request failed: %s", resp.Status) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return result, nil +} + +func buildQueryString(fields map[string]interface{}) string { + var parts []string + + for field, value := range fields { + switch field { + case "contributionsCollection": + if fieldMap, ok := value.(map[string]interface{}); ok { + from := fieldMap["from"].(string) + to := fieldMap["to"].(string) + parts = append(parts, fmt.Sprintf(`contributionsCollection(from: "%s", to: "%s") { + totalCommitContributions + totalIssueContributions + totalPullRequestContributions + totalRepositoriesWithContributedCommits + }`, from, to)) + } + case "repositories": + parts = append(parts, `repositories(first: 100, ownerAffiliations: OWNER) { + nodes { + stargazerCount + } + }`) + case "login": + parts = append(parts, "login") + } + } + + return strings.Join(parts, "\n") +} + +func buildGraphQLQuery(username string, keys []string) map[string]interface{} { + currentYear := time.Now().Year() + fromDate := fmt.Sprintf("%d-01-01T00:00:00Z", currentYear) + toDate := fmt.Sprintf("%d-12-31T23:59:59Z", currentYear) + + needsContributions := false + needsRepositories := false + + for _, key := range keys { + switch key { + case "totalCommitContributions", "totalIssueContributions", + "totalPullRequestContributions", "totalRepositoriesWithContributedCommits": + needsContributions = true + case "totalStars", "totalRepositories": + needsRepositories = true + } + } + + queryFields := make(map[string]interface{}) + + if needsContributions { + queryFields["contributionsCollection"] = map[string]interface{}{ + "from": fromDate, + "to": toDate, + "totalCommitContributions": true, + "totalIssueContributions": true, + "totalPullRequestContributions": true, + "totalRepositoriesWithContributedCommits": true, + } + } + + if needsRepositories { + queryFields["repositories"] = map[string]interface{}{ + "first": 100, + "ownerAffiliations": "OWNER", + "nodes": map[string]interface{}{ + "stargazerCount": true, + }, + } + } + + queryFields["login"] = true + + query := map[string]interface{}{ + "query": fmt.Sprintf(` + query { + user(login: "%s") { + %s + } + } + `, username, buildQueryString(queryFields)), + } + + return query +} + +func resolveVariable(username string, keys []string) (map[string]string, error) { + if err := validatePlaceholders(keys); err != nil { + return nil, err + } + + query := buildGraphQLQuery(username, keys) + result, err := makeGraphQLQuery(query) + if err != nil { + return nil, err + } + + resolvedVars := make(map[string]string) + + if errors, ok := result["errors"]; ok { + return nil, fmt.Errorf("GraphQL errors: %v", errors) + } + + data, ok := result["data"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid response format") + } + + user, ok := data["user"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("user not found") + } + + // extract contributions data + if contributions, ok := user["contributionsCollection"].(map[string]interface{}); ok { + if val, exists := contributions["totalCommitContributions"]; exists { + resolvedVars["totalCommitContributions"] = fmt.Sprintf("%.0f", val) + } + if val, exists := contributions["totalIssueContributions"]; exists { + resolvedVars["totalIssueContributions"] = fmt.Sprintf("%.0f", val) + } + if val, exists := contributions["totalPullRequestContributions"]; exists { + resolvedVars["totalPullRequestContributions"] = fmt.Sprintf("%.0f", val) + } + if val, exists := contributions["totalRepositoriesWithContributedCommits"]; exists { + resolvedVars["totalRepositoriesWithContributedCommits"] = fmt.Sprintf("%.0f", val) + } + } + + // extract repository data + if repositories, ok := user["repositories"].(map[string]interface{}); ok { + if nodes, ok := repositories["nodes"].([]interface{}); ok { + totalStars := 0 + totalRepos := len(nodes) + + for _, node := range nodes { + if repo, ok := node.(map[string]interface{}); ok { + if stars, ok := repo["stargazerCount"]; ok { + if starCount, ok := stars.(float64); ok { + totalStars += int(starCount) + } + } + } + } + + resolvedVars["totalStars"] = strconv.Itoa(totalStars) + resolvedVars["totalRepositories"] = strconv.Itoa(totalRepos) + } + } + + if login, ok := user["login"]; ok { + resolvedVars["username"] = login.(string) + } + + return resolvedVars, nil +} + +func replacePlaceholders(username, svg string) (string, error) { + keys := extractPlaceholders(svg) + if len(keys) == 0 { + return svg, nil + } + + uniqueKeys := make([]string, 0, len(keys)) + seen := make(map[string]bool) + for _, key := range keys { + if !seen[key] { + uniqueKeys = append(uniqueKeys, key) + seen[key] = true + } + } + + resolvedVars, err := resolveVariable(username, uniqueKeys) + if err != nil { + return "", err + } + + result := svg + for _, key := range uniqueKeys { + if val, exists := resolvedVars[key]; exists { + result = strings.ReplaceAll(result, "{{"+key+"}}", val) + } else { + result = strings.ReplaceAll(result, "{{"+key+"}}", "N/A") + } + } + + return result, nil +} + +//todo: add cache for multiple retrievals +func GetWidgetImage(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + username := vars["username"] + widgetOwner := vars["widgetOwner"] + widgetName := vars["widgetName"] + if username == "" || widgetName == "" || widgetOwner == "" { + JsonErrorResponse(w, http.StatusBadRequest, "Username, widget owner, and widget name are required") + log.Errorf("Username, widget owner, or widget name is empty ") + return + } + image, err := models.GetWidget(widgetOwner, widgetName) + if err != nil { + JsonErrorResponse(w, http.StatusNotFound, err.Error()) + log.Errorf("Error fetching widget: %v", err) + return + } + + finalSVG, err := replacePlaceholders(username, image.Content) + if err != nil { + JsonErrorResponse(w, http.StatusInternalServerError, err.Error()) + log.Errorf("Error replacing placeholders: %v", err) + return + } + + w.Header().Set("Content-Type", "image/svg+xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(finalSVG)) +} diff --git a/internal/models/widgets.go b/internal/models/widgets.go new file mode 100644 index 0000000..0200ac2 --- /dev/null +++ b/internal/models/widgets.go @@ -0,0 +1,122 @@ +package models + +import ( + "context" + "fmt" + + "go.mongodb.org/mongo-driver/bson" +) + +type NewWidget struct { + Name string `json:"name"` + Content string `json:"content"` + Size struct { + Width int `json:"width"` + Height int `json:"height"` + } `json:"size"` + IsPrivate bool `json:"is_private" bson:"is_private"` + Tags []string `json:"tags"` + CreatedBy string `json:"created_by" omitempty:"true" bson:"created_by"` +} + +func AddWidget(widget NewWidget) error { + collection, err := GetCollection("widgets") + if err != nil { + return err + } + + // check if the widget name already exists for the user + filter := bson.M{"name": widget.Name, "created_by": widget.CreatedBy} + count, err := collection.CountDocuments(context.Background(), filter) + if err != nil { + return fmt.Errorf("failed to check widget existence: %v", err) + } + if count > 0 { + return fmt.Errorf("widget with name '%s' already exists", widget.Name) + } + + _, err = collection.InsertOne(context.Background(), widget) + if err != nil { + return fmt.Errorf("failed to add widget: %v", err) + } + + return nil +} + +func GetWidget(username, widgetName string) (NewWidget, error) { + collection, err := GetCollection("widgets") + if err != nil { + return NewWidget{}, err + } + + filter := bson.M{"name": widgetName, "created_by": username} + var widget NewWidget + err = collection.FindOne(context.Background(), filter).Decode(&widget) + if err != nil { + if err.Error() == "mongo: no documents in result" { + return NewWidget{}, fmt.Errorf("widget '%s' not found for user '%s'", widgetName, username) + } + return NewWidget{}, fmt.Errorf("failed to get widget: %v", err) + } + + return widget, nil +} + +func GetAllWidgets(username string) ([]NewWidget, error) { + collection, err := GetCollection("widgets") + if err != nil { + return nil, err + } + + filter := bson.M{"created_by": username} + cursor, err := collection.Find(context.Background(), filter) + if err != nil { + return nil, fmt.Errorf("failed to get widgets: %v", err) + } + defer cursor.Close(context.Background()) + + var widgets []NewWidget + if err := cursor.All(context.Background(), &widgets); err != nil { + return nil, fmt.Errorf("failed to decode widgets: %v", err) + } + + return widgets, nil +} + +func EditWidget(username, widgetName string, updatedWidget NewWidget) error { + collection, err := GetCollection("widgets") + if err != nil { + return err + } + + filter := bson.M{"name": widgetName, "created_by": username} + update := bson.M{"$set": updatedWidget} + + result, err := collection.UpdateOne(context.Background(), filter, update) + if err != nil { + return fmt.Errorf("failed to update widget: %v", err) + } + if result.MatchedCount == 0 { + return fmt.Errorf("widget '%s' not found for user '%s'", widgetName, username) + } + + return nil +} + +func DeleteWidget(username, widgetName string) error { + collection, err := GetCollection("widgets") + if err != nil { + return err + } + + filter := bson.M{"name": widgetName, "created_by": username} + result, err := collection.DeleteOne(context.Background(), filter) + if err != nil { + return fmt.Errorf("failed to delete widget: %v", err) + } + if result.DeletedCount == 0 { + return fmt.Errorf("widget '%s' not found for user '%s'", widgetName, username) + } + + return nil +} \ No newline at end of file diff --git a/internal/routes/router.go b/internal/routes/router.go index d078164..dcd7b54 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -48,10 +48,11 @@ func Router() *mux.Router { RegisterAuth(apiRouter) RegisterGitHub(apiRouter) - + RegisterWidget(apiRouter) return r } func ping(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("pong")) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"message": "pong"}`)) } \ No newline at end of file diff --git a/internal/routes/widget.go b/internal/routes/widget.go new file mode 100644 index 0000000..abf0a14 --- /dev/null +++ b/internal/routes/widget.go @@ -0,0 +1,24 @@ +package routes + +import ( + "github.com/gorilla/mux" + "github.com/var-code-5/devboard-backend/internal/controllers" + "github.com/var-code-5/devboard-backend/internal/middlewares" +) + +func RegisterWidget(r *mux.Router) { + s := r.PathPrefix("/widget").Subrouter() + s.Use(middlewares.VerifyAccessToken) + + s.HandleFunc("/", controllers.AddWidget).Methods("POST", "OPTIONS") + s.HandleFunc("/", controllers.GetWidget).Methods("GET", "OPTIONS") // it should have a query parameter for widget name + s.HandleFunc("/all", controllers.GetUserWidgets).Methods("GET", "OPTIONS") + s.HandleFunc("/", controllers.DeleteWidget).Methods("DELETE", "OPTIONS")// it should have a query parameter for widget name + s.HandleFunc("/", controllers.EditWidget).Methods("PATCH", "OPTIONS") + + + // for serving widget images + w := r.PathPrefix("/widget-img").Subrouter() + + w.HandleFunc("/{username}/{widgetOwner}/{widgetName}", controllers.GetWidgetImage).Methods("GET", "OPTIONS") +} From caac12eeb11aadb9a8a594b64a348fc96b5ebe70 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sat, 12 Jul 2025 14:18:04 +0530 Subject: [PATCH 26/32] refactor: change the cors lib --- go.mod | 3 +-- go.sum | 6 ++--- internal/routes/router.go | 54 ++++++++++++++------------------------- 3 files changed, 22 insertions(+), 41 deletions(-) diff --git a/go.mod b/go.mod index 0b0afae..fa6e609 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,9 @@ require github.com/gorilla/mux v1.8.1 require ( github.com/golang-jwt/jwt/v5 v5.2.2 - github.com/gorilla/handlers v1.5.2 github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.10.0 + github.com/rs/cors v1.11.1 github.com/sirupsen/logrus v1.9.3 go.mongodb.org/mongo-driver v1.17.3 ) @@ -16,7 +16,6 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/felixge/httpsnoop v1.0.3 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/klauspost/compress v1.16.7 // indirect github.com/montanaflynn/stats v0.7.1 // indirect diff --git a/go.sum b/go.sum index cd8e7ef..33e4cb7 100644 --- a/go.sum +++ b/go.sum @@ -9,16 +9,12 @@ 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= -github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= -github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -31,6 +27,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/routes/router.go b/internal/routes/router.go index dcd7b54..b19fc8a 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -4,55 +4,39 @@ import ( "net/http" "os" - "github.com/gorilla/handlers" + // "github.com/gorilla/handlers" "github.com/gorilla/mux" + "github.com/rs/cors" "github.com/var-code-5/devboard-backend/internal/middlewares" ) -func Router() *mux.Router { +func Router() http.Handler { r := mux.NewRouter() - - // Add CORS middleware with more permissive settings for development + frontendUrl := os.Getenv("FRONTEND_URL") - corsHandler := handlers.CORS( - handlers.AllowedOrigins([]string{"http://localhost:3000", "http://127.0.0.1:3000", "http://127.0.0.1:5500", frontendUrl}), - handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}), - handlers.AllowedHeaders([]string{ - "Content-Type", - "Authorization", - "X-Requested-With", - "Accept", - "Origin", - "Cache-Control", - "X-File-Name", - }), - handlers.AllowCredentials(), - handlers.ExposedHeaders([]string{"*"}), // Add this for better compatibility - ) - - r.Use(middlewares.Logger) - // Apply CORS middleware to the entire router - r.Use(corsHandler) - - // Handle preflight requests explicitly - r.Methods("OPTIONS").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, Origin, Cache-Control, X-File-Name") - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.WriteHeader(http.StatusOK) + + c := cors.New(cors.Options{ + AllowedOrigins: []string{"http://localhost:3000","http://127.0.0.1:3000","http://localhost:5500","http://127.0.0.1:5500", frontendUrl}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, + AllowedHeaders: []string{"Authorization", "Content-Type", "X-Requested-With", "Accept", "Origin"}, + AllowCredentials: true, + //Debug: true, // Enable for debugging CORS issues }) - + + r.Use(middlewares.Logger) + apiRouter := r.PathPrefix("/api").Subrouter() apiRouter.HandleFunc("/ping", ping).Methods("GET") - + RegisterAuth(apiRouter) RegisterGitHub(apiRouter) RegisterWidget(apiRouter) - return r + + handler := c.Handler(r) + return handler } func ping(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"message": "pong"}`)) -} \ No newline at end of file +} From ca48c7de7334ac2cceac949160e23958f974078e Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 13 Jul 2025 18:48:51 +0530 Subject: [PATCH 27/32] feat: add marketplace endpoint --- internal/cache/market_place.go | 65 ++++++++++++++++++++++++++++ internal/controllers/market_place.go | 47 ++++++++++++++++++++ internal/controllers/widgets.go | 14 ++++-- internal/models/market_place.go | 41 ++++++++++++++++++ internal/routes/market_place.go | 15 +++++++ internal/routes/router.go | 1 + 6 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 internal/cache/market_place.go create mode 100644 internal/controllers/market_place.go create mode 100644 internal/models/market_place.go create mode 100644 internal/routes/market_place.go diff --git a/internal/cache/market_place.go b/internal/cache/market_place.go new file mode 100644 index 0000000..c6b5f06 --- /dev/null +++ b/internal/cache/market_place.go @@ -0,0 +1,65 @@ +package cache + +import ( + "encoding/json" + "fmt" + "time" + + log "github.com/sirupsen/logrus" + "github.com/redis/go-redis/v9" + "github.com/var-code-5/devboard-backend/internal/models" +) + +func GetMarketPlace(widgetType string, length int, offset int) ([]interface{}, error) { + client := GetClient() + data, err := client.Get(ctx, fmt.Sprintf("marketplace:%s:%d:%d", widgetType, length, offset)).Result() + + if err != nil { + if err == redis.Nil { + widgets, err := models.GetMarketPlace(widgetType, length, offset) + if err != nil { + log.Error("Failed to fetch widgets from database:", err) + return nil, fmt.Errorf("failed to fetch widgets from database: %v", err) + } + data, err := json.Marshal(widgets) + if err != nil { + log.Error("Failed to marshal widgets:", err) + return nil, fmt.Errorf("failed to marshal widgets: %v", err) + } + err = client.Set(ctx, fmt.Sprintf("marketplace:%s:%d:%d", widgetType, length, offset), data, time.Minute*5).Err() // store in cache for 5 min + if err != nil { + log.Error("Failed to set data in cache:", err) + return nil, fmt.Errorf("failed to set data in cache: %v", err) + } + var result []interface{} + if err := json.Unmarshal([]byte(data), &result); err != nil { + return nil, err + } + return result, nil + } else { + log.Error("Error fetching data from cache:", err) + return nil, fmt.Errorf("error fetching data from cache: %v", err) + } + } + var result []interface{} + if err := json.Unmarshal([]byte(data), &result); err != nil { + return nil, err + } + return result, nil +} + +func ClearMarketPlaceCache(widgetType string) error { + client := GetClient() + keys, err := client.Keys(ctx, fmt.Sprintf("marketplace:%s:*", widgetType)).Result() + if err != nil { + log.Error("Failed to fetch keys for marketplace cache:", err) + return fmt.Errorf("failed to fetch keys for marketplace cache: %v", err) + } + for _, key := range keys { + if err := client.Del(ctx, key).Err(); err != nil { + log.Error("Failed to delete key from cache:", err) + return fmt.Errorf("failed to delete key from cache: %v", err) + } + } + return nil +} \ No newline at end of file diff --git a/internal/controllers/market_place.go b/internal/controllers/market_place.go new file mode 100644 index 0000000..aceb431 --- /dev/null +++ b/internal/controllers/market_place.go @@ -0,0 +1,47 @@ +package controllers + +import ( + "encoding/json" + "net/http" + "strconv" + + log "github.com/sirupsen/logrus" + "github.com/var-code-5/devboard-backend/internal/cache" +) + +func GetMarketPlace(w http.ResponseWriter, r *http.Request) { + widgetType := r.URL.Query().Get("widgetType") + lengthStr := r.URL.Query().Get("length") + offsetStr := r.URL.Query().Get("offset") + if _, valid := validPlaceholders[widgetType]; !valid { + JsonErrorResponse(w, http.StatusBadRequest, "Invalid widget type") + return + } + if lengthStr == "" || offsetStr == "" { + lengthStr = "10" // Default length + offsetStr = "0" // Default offset + } + + length, err := strconv.Atoi(lengthStr) + if err != nil { + JsonErrorResponse(w, http.StatusBadRequest, "Invalid length") + return + } + offset, err := strconv.Atoi(offsetStr) + if err != nil { + JsonErrorResponse(w, http.StatusBadRequest, "Invalid offset") + return + } + + widgets, err := cache.GetMarketPlace(widgetType, length, offset) + if err != nil { + log.Error("Failed to fetch market place:", err) + JsonErrorResponse(w, http.StatusInternalServerError, "Failed to fetch widgets") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(widgets) + log.Info("Market place fetched successfully for widget type:", widgetType) +} diff --git a/internal/controllers/widgets.go b/internal/controllers/widgets.go index 3d8c94c..abf3f1d 100644 --- a/internal/controllers/widgets.go +++ b/internal/controllers/widgets.go @@ -12,6 +12,7 @@ import ( "github.com/gorilla/mux" log "github.com/sirupsen/logrus" + "github.com/var-code-5/devboard-backend/internal/cache" "github.com/var-code-5/devboard-backend/internal/models" ) @@ -32,11 +33,18 @@ func AddWidget(w http.ResponseWriter, r *http.Request) { return } + // for using the marketplace + newWidget.Tags = append(newWidget.Tags, keys...) + if err := models.AddWidget(newWidget); err != nil { JsonErrorResponse(w, http.StatusInternalServerError, err.Error()) return } + for _, tag := range newWidget.Tags { + _ = cache.ClearMarketPlaceCache(tag) + } + w.WriteHeader(http.StatusCreated) w.Header().Set("Content-Type", "application/json") response := map[string]string{"message": "Widget added successfully"} @@ -62,7 +70,7 @@ func GetWidget(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(widget) } -//todo: add cache +// todo: add cache func GetUserWidgets(w http.ResponseWriter, r *http.Request) { userName := r.Context().Value("userName").(string) @@ -76,7 +84,7 @@ func GetUserWidgets(w http.ResponseWriter, r *http.Request) { JsonErrorResponse(w, http.StatusNotFound, "No widgets found for the user") return } - + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(widgets) } @@ -377,7 +385,7 @@ func replacePlaceholders(username, svg string) (string, error) { return result, nil } -//todo: add cache for multiple retrievals +// todo: add cache for multiple retrievals func GetWidgetImage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) username := vars["username"] diff --git a/internal/models/market_place.go b/internal/models/market_place.go new file mode 100644 index 0000000..2dc9aae --- /dev/null +++ b/internal/models/market_place.go @@ -0,0 +1,41 @@ +package models + +import ( + "context" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func GetMarketPlace(widgetType string, length int, offset int) ([]bson.M, error) { + collection, err := GetCollection("widgets") + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + filter := bson.M{ + "tags": widgetType, // tags is an array — mongoDB matches if any element equals widgetType + } + + findOptions := options.Find() + findOptions.SetLimit(int64(length)) + findOptions.SetSkip(int64(offset)) + + cursor, err := collection.Find(ctx, filter, findOptions) + if err != nil { + return nil, fmt.Errorf("failed to find widgets: %v", err) + } + defer cursor.Close(ctx) + + var widgets []bson.M + if err := cursor.All(ctx, &widgets); err != nil { + return nil, fmt.Errorf("failed to decode widgets: %v", err) + } + + return widgets, nil +} diff --git a/internal/routes/market_place.go b/internal/routes/market_place.go new file mode 100644 index 0000000..3105259 --- /dev/null +++ b/internal/routes/market_place.go @@ -0,0 +1,15 @@ +package routes + +import ( + "github.com/gorilla/mux" + "github.com/var-code-5/devboard-backend/internal/controllers" + "github.com/var-code-5/devboard-backend/internal/middlewares" +) + +func RegisterMarketPlace(r *mux.Router) { + s := r.PathPrefix("/marketplace").Subrouter() + + // Add OPTIONS method to handle CORS preflight requests + s.HandleFunc("/", controllers.GetMarketPlace).Methods("GET", "OPTIONS") + s.Use(middlewares.VerifyAccessToken) +} diff --git a/internal/routes/router.go b/internal/routes/router.go index b19fc8a..8a41314 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -31,6 +31,7 @@ func Router() http.Handler { RegisterAuth(apiRouter) RegisterGitHub(apiRouter) RegisterWidget(apiRouter) + RegisterMarketPlace(apiRouter) handler := c.Handler(r) return handler From 84bc36f53491a5a26e91a7f75f457212baef9e8e Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Mon, 4 Aug 2025 15:46:59 +0530 Subject: [PATCH 28/32] feat: add documentation for the app --- README.md | 79 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8f25179..5739308 100644 --- a/README.md +++ b/README.md @@ -2,43 +2,86 @@ GDSC VIT -

< Insert Project Title Here >

-

< Insert Project Description Here >

+

DevBoard Backend

+

A Go-based backend API service for the DevBoard application, providing authentication, GitHub integration, and marketplace functionality

--- [![Join Us](https://img.shields.io/badge/Join%20Us-Developer%20Student%20Clubs-red)](https://dsc.community.dev/vellore-institute-of-technology/) [![Discord Chat](https://img.shields.io/discord/760928671698649098.svg)](https://discord.gg/498KVdSKWR) -[![DOCS](https://img.shields.io/badge/Documentation-see%20docs-green?style=flat-square&logo=appveyor)](INSERT_LINK_FOR_DOCS_HERE) - [![UI ](https://img.shields.io/badge/User%20Interface-Link%20to%20UI-orange?style=flat-square&logo=appveyor)](INSERT_UI_LINK_HERE) +[![DOCS](https://img.shields.io/badge/Documentation-API%20Docs-green?style=flat-square&logo=appveyor)](https://devboard.varshith.tech/api/docs) + [![UI ](https://img.shields.io/badge/User%20Interface-DevBoard%20Frontend-orange?style=flat-square&logo=appveyor)](https://devboard.varshith.tech) ## Features -- [ ] < feature > -- [ ] < feature > -- [ ] < feature > -- [ ] < feature > +- [x] GitHub OAuth Authentication +- [x] JWT-based Authorization +- [x] Redis Caching +- [x] MongoDB Integration +- [x] GitHub API Integration +- [x] Marketplace API +- [x] Widgets Management
## Dependencies - - < dependency > - - < dependency > + - Go 1.24+ + - MongoDB + - Redis + - Docker & Docker Compose (for deployment) ## Running +### Prerequisites +1. Create a `.env` file in the root directory with the following environment variables: -< directions to install > ```bash -< insert code > +# Server Configuration +LOG_LEVEL=info +PORT=3000 + +# MongoDB Configuration +MONGODB_URI=mongodb://localhost:27017 +MONGODB_DB_NAME=devboard + +# Redis Configuration +REDIS_URL=redis://localhost:6379 + +# GitHub OAuth Configuration +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret +GH_PAT=your_github_personal_access_token + +# JWT Configuration +JWT_ACCESS_SECRET=your_jwt_access_secret +JWT_REFRESH_SECRET=your_jwt_refresh_secret + +# Frontend Configuration +FRONTEND_URL=http://localhost:5173 +PROD_DOMAIN=devboard.example.com +``` + +### Development Setup + +```bash +# Clone the repository +git clone https://github.com/var-code-5/devboard-backend.git +cd devboard-backend + +# Download dependencies +go mod download + +# Run the server +go run cmd/main.go ``` -< directions to execute > +### Docker Deployment ```bash -< insert code > +# Build and run using Docker Compose +docker-compose up -d ``` ## Contributors @@ -46,15 +89,15 @@
- John Doe + Varshith

- Your Name Here (Insert Your Image Link In Src + Varshith Kumar

- + GitHub - + LinkedIn

From 2133e763f756cd622d13e49a61ef88b90f4ba734 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Tue, 5 Aug 2025 23:56:51 +0530 Subject: [PATCH 29/32] feat: add encryption to the github tokens --- .env.example | 3 +- README.md | 5 +++ internal/models/auth.go | 27 +++++++++++++-- internal/models/utils.go | 72 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 062a554..755e4ec 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,5 @@ JWT_REFRESH_SECRET=your_refresh_secret_here JWT_ACCESS_SECRET=your_access_secret_here FRONTEND_URL=http://localhost:3000 REDIS_URL=redis://localhost:6379 -GH_PAT=your_github_pat_here \ No newline at end of file +GH_PAT=your_github_pat_here +ENCRYPTION_KEY=your_32_byte_random_secret_key \ No newline at end of file diff --git a/README.md b/README.md index 5739308..a28753d 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,13 @@ JWT_REFRESH_SECRET=your_jwt_refresh_secret # Frontend Configuration FRONTEND_URL=http://localhost:5173 PROD_DOMAIN=devboard.example.com + +# Encryption for the tokens +ENCRYPTION_KEY=your_32_byte_random_secret_key + ``` + ### Development Setup ```bash diff --git a/internal/models/auth.go b/internal/models/auth.go index 92f2584..5f5a439 100644 --- a/internal/models/auth.go +++ b/internal/models/auth.go @@ -54,6 +54,22 @@ func AddUser(user User) error { return err } + if user.AccessToken != "" { + encryptedToken, err := EncryptToken(user.AccessToken) + if err != nil { + return err + } + user.AccessToken = encryptedToken + } + + if user.RefreshToken != "" { + encryptedRefresh, err := EncryptToken(user.RefreshToken) + if err != nil { + return err + } + user.RefreshToken = encryptedRefresh + } + // Check if user already exists in the database by GitHub ID filter := bson.M{"github_id": user.GithubID} existingUser := collection.FindOne(context.Background(), filter) @@ -98,7 +114,10 @@ func GetAccessToken(username string) (string, error) { return "", err } - return user.AccessToken, nil + if user.AccessToken != "" { + return DecryptToken(user.AccessToken) + } + return "", nil } func GetRefreshToken(username string) (string, error) { @@ -114,5 +133,9 @@ func GetRefreshToken(username string) (string, error) { return "", err } - return user.RefreshToken, nil + if user.RefreshToken != ""{ + return DecryptToken(user.RefreshToken) + } + + return "", nil } \ No newline at end of file diff --git a/internal/models/utils.go b/internal/models/utils.go index 0df9ca6..aa8f99d 100644 --- a/internal/models/utils.go +++ b/internal/models/utils.go @@ -2,7 +2,12 @@ package models import ( "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" "fmt" + "io" "os" "time" @@ -18,15 +23,24 @@ var ( database *mongo.Database ) +var secretKey []byte + func init() { err := godotenv.Load() if err != nil { log.Fatal("Error loading .env file") } - + connectionURL := os.Getenv("MONGODB_URI") dbName := os.Getenv("MONGODB_DB_NAME") + // Get encryption key from environment + key := os.Getenv("ENCRYPTION_KEY") + if key == "" { + panic("ENCRYPTION_KEY environment variable must be set") + } + secretKey = []byte(key) + if connectionURL == "" || dbName == "" { log.Fatal("MONGODB_URI or MONGODB_DB_NAME environment variable is not set") return @@ -55,4 +69,58 @@ func GetCollection(collectionName string) (*mongo.Collection, error) { return nil, fmt.Errorf("database connection is not initialized") } return database.Collection(collectionName), nil -} \ No newline at end of file +} + +// EncryptToken encrypts a token using AES-GCM +func EncryptToken(tokenString string) (string, error) { + plaintext := []byte(tokenString) + + block, err := aes.NewCipher(secretKey) + if err != nil { + return "", err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := aesGCM.Seal(nonce, nonce, plaintext, nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// DecryptToken decrypts an encrypted token +func DecryptToken(encryptedToken string) (string, error) { + ciphertext, err := base64.StdEncoding.DecodeString(encryptedToken) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(secretKey) + if err != nil { + return "", err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := aesGCM.NonceSize() + if len(ciphertext) < nonceSize { + return "", err + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} From 3f8b7238efb98963049396db0dfed650f976cbf7 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 10 Aug 2025 17:17:48 +0530 Subject: [PATCH 30/32] chore: update cors --- internal/routes/router.go | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/internal/routes/router.go b/internal/routes/router.go index 8a41314..2e2fe57 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -1,10 +1,10 @@ package routes import ( + "log" "net/http" "os" - // "github.com/gorilla/handlers" "github.com/gorilla/mux" "github.com/rs/cors" "github.com/var-code-5/devboard-backend/internal/middlewares" @@ -14,17 +14,40 @@ func Router() http.Handler { r := mux.NewRouter() frontendUrl := os.Getenv("FRONTEND_URL") + if frontendUrl == "" { + log.Println("Warning: FRONTEND_URL environment variable not set") + } else { + log.Printf("Using frontend URL: %s", frontendUrl) + } + + allowedOrigins := []string{ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:5500", + "http://127.0.0.1:5500", + } + + // Only add frontendUrl if it's not empty + if frontendUrl != "" { + allowedOrigins = append(allowedOrigins, frontendUrl) + } c := cors.New(cors.Options{ - AllowedOrigins: []string{"http://localhost:3000","http://127.0.0.1:3000","http://localhost:5500","http://127.0.0.1:5500", frontendUrl}, + AllowedOrigins: allowedOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowedHeaders: []string{"Authorization", "Content-Type", "X-Requested-With", "Accept", "Origin"}, + ExposedHeaders: []string{"Content-Length", "Content-Type"}, AllowCredentials: true, - //Debug: true, // Enable for debugging CORS issues + Debug: true, // Temporarily enable for debugging CORS issues }) r.Use(middlewares.Logger) + // Handle OPTIONS requests globally + r.Methods("OPTIONS").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + apiRouter := r.PathPrefix("/api").Subrouter() apiRouter.HandleFunc("/ping", ping).Methods("GET") @@ -33,6 +56,7 @@ func Router() http.Handler { RegisterWidget(apiRouter) RegisterMarketPlace(apiRouter) + // Apply CORS to the entire router handler := c.Handler(r) return handler } From 3e1d8491ad5b7a96a13905b2c3808a4f8afc44d4 Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 10 Aug 2025 17:29:45 +0530 Subject: [PATCH 31/32] fix: handle cors using rs --- internal/routes/router.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/routes/router.go b/internal/routes/router.go index 2e2fe57..5b683c0 100644 --- a/internal/routes/router.go +++ b/internal/routes/router.go @@ -44,9 +44,9 @@ func Router() http.Handler { r.Use(middlewares.Logger) // Handle OPTIONS requests globally - r.Methods("OPTIONS").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) + // r.Methods("OPTIONS").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // w.WriteHeader(http.StatusOK) + // }) apiRouter := r.PathPrefix("/api").Subrouter() apiRouter.HandleFunc("/ping", ping).Methods("GET") From 693bf01625d0a1d487bd0c215f982f065999eceb Mon Sep 17 00:00:00 2001 From: var-code-5 Date: Sun, 10 Aug 2025 17:36:51 +0530 Subject: [PATCH 32/32] fix: trailing slash in cors --- internal/controllers/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controllers/auth.go b/internal/controllers/auth.go index ccc0a36..9fc9306 100644 --- a/internal/controllers/auth.go +++ b/internal/controllers/auth.go @@ -196,7 +196,7 @@ func Callback(w http.ResponseWriter, r *http.Request) { return } - redirectURL := fmt.Sprintf("%s?access_token=%s&refresh_token=%s&login=%s&avatar_url=%s", + redirectURL := fmt.Sprintf("%s/?access_token=%s&refresh_token=%s&login=%s&avatar_url=%s", frontendURL, url.QueryEscape(jwtAccessToken), url.QueryEscape(jwtRefreshToken),