|
- John Doe
+ Varshith
-
+
-
+
-
+
diff --git a/cmd/main.go b/cmd/main.go
new file mode 100644
index 0000000..66ec07b
--- /dev/null
+++ b/cmd/main.go
@@ -0,0 +1,35 @@
+package main
+
+import (
+ "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
+
+ logLevel, err := log.ParseLevel(os.Getenv("LOG_LEVEL"))
+ if err != nil {
+ logLevel = log.InfoLevel
+ }
+
+ log.SetLevel(logLevel)
+}
+
+func main() {
+ router := routes.Router()
+ log.Info("Router initialized successfully")
+ log.Info("Starting server on :3000")
+ log.Fatal(http.ListenAndServe(":3000", router))
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..74ffef7
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,57 @@
+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$$3msTBU5l$$sH0xSDeZajSQ0QeV1.u1Y/"
+
+ app:
+ image: varcode05/devboard-backend:${IMAGE_TAG:-latest}
+ 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:
+ - "6379:6379"
+ volumes:
+ - redis-data:/data
+ command: redis-server --appendonly yes
+ restart: always
+
+volumes:
+ redis-data:
+ letsencrypt:
\ 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/go.mod b/go.mod
new file mode 100644
index 0000000..fa6e609
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,30 @@
+module github.com/var-code-5/devboard-backend
+
+go 1.24.3
+
+require github.com/gorilla/mux v1.8.1
+
+require (
+ github.com/golang-jwt/jwt/v5 v5.2.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
+)
+
+require (
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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
+ 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
+ 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..33e4cb7
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,82 @@
+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=
+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/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/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=
+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=
+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 v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
+go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
+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 h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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/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/cache/utils.go b/internal/cache/utils.go
new file mode 100644
index 0000000..ccde9ce
--- /dev/null
+++ b/internal/cache/utils.go
@@ -0,0 +1,42 @@
+package cache
+
+import (
+ "context"
+ "os"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/joho/godotenv"
+ "github.com/redis/go-redis/v9"
+)
+
+var client *redis.Client
+
+func init() {
+ err := godotenv.Load()
+ if err != nil {
+ log.Error("Error loading .env file")
+ }
+
+ redisUrl := os.Getenv("REDIS_URL")
+ opt, err := redis.ParseURL(redisUrl)
+ if err != nil {
+ 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 {
+ log.Error("Redis client is not initialized")
+ }
+ return client
+}
+
diff --git a/internal/controllers/auth.go b/internal/controllers/auth.go
new file mode 100644
index 0000000..9fc9306
--- /dev/null
+++ b/internal/controllers/auth.go
@@ -0,0 +1,276 @@
+package controllers
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "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) {
+ 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.SameSiteLaxMode,
+ }
+ 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"`
+ Error string `json:"error,omitempty"`
+ ErrorDescription string `json:"error_description,omitempty"`
+}
+
+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/vnd.github+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()
+ 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
+ }
+
+
+
+ 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
+ }
+
+ // 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 == "" {
+ 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, 302)
+ 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
+ }
+
+ 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")
+ 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("userName").(string)
+ log.Debug("Logging out user:", username)
+ err := cache.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)
+}
diff --git a/internal/controllers/github.go b/internal/controllers/github.go
new file mode 100644
index 0000000..63fe9bb
--- /dev/null
+++ b/internal/controllers/github.go
@@ -0,0 +1,340 @@
+package controllers
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ log "github.com/sirupsen/logrus"
+)
+
+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 {
+ 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 {
+ JsonErrorResponse(w, http.StatusInternalServerError, "Failed to encode response")
+ log.Errorf("Failed to encode response: %v", err)
+ }
+}
+
+type UpdateFileRequest struct {
+ 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
+ }
+ }else{
+ log.Info("Repository already exists for user:", userName)
+ _, 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)
+ 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/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/utils.go b/internal/controllers/utils.go
new file mode 100644
index 0000000..9dcca33
--- /dev/null
+++ b/internal/controllers/utils.go
@@ -0,0 +1,131 @@
+package controllers
+
+import (
+ "encoding/json"
+ "net/http"
+ "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 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 == "" {
+ 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/controllers/widgets.go b/internal/controllers/widgets.go
new file mode 100644
index 0000000..abf3f1d
--- /dev/null
+++ b/internal/controllers/widgets.go
@@ -0,0 +1,416 @@
+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/cache"
+ "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
+ }
+
+ // 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"}
+ 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/middlewares/auth.go b/internal/middlewares/auth.go
new file mode 100644
index 0000000..f0d0e59
--- /dev/null
+++ b/internal/middlewares/auth.go
@@ -0,0 +1,49 @@
+package middlewares
+
+import (
+ "context"
+ "net/http"
+ "strings"
+
+ "github.com/var-code-5/devboard-backend/internal/cache"
+ "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) {
+
+ // 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")
+ return
+ }
+ token, err := controllers.VerifyAccessToken(strings.Split(tokenString, " ")[1])
+ if err != nil {
+ controllers.JsonErrorResponse(w, http.StatusUnauthorized, "Invalid access token")
+ return
+ }
+
+ tokens, err := cache.GetTokens(token.Username)
+
+ if err != nil {
+ controllers.JsonErrorResponse(w, http.StatusUnauthorized, "Failed to retrieve GitHub access token")
+ return
+ }
+ 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", tokens.GithubAccessToken)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
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/models/auth.go b/internal/models/auth.go
new file mode 100644
index 0000000..5f5a439
--- /dev/null
+++ b/internal/models/auth.go
@@ -0,0 +1,141 @@
+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"`
+ RefreshToken string `json:"refresh_token,omitempty" bson:"refresh_token,omitempty"`
+}
+
+func AddUser(user User) error {
+ collection, err := GetCollection("users")
+ if err != nil {
+ 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)
+ if existingUser.Err() == nil {
+ // 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
+ }
+ 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": "", "refresh_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
+ }
+
+ if user.AccessToken != "" {
+ return DecryptToken(user.AccessToken)
+ }
+ return "", 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
+ }
+
+ if user.RefreshToken != ""{
+ return DecryptToken(user.RefreshToken)
+ }
+
+ return "", nil
+}
\ No newline at end of file
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/models/utils.go b/internal/models/utils.go
new file mode 100644
index 0000000..aa8f99d
--- /dev/null
+++ b/internal/models/utils.go
@@ -0,0 +1,126 @@
+package models
+
+import (
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "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
+)
+
+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
+ }
+
+ 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
+}
+
+// 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
+}
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/auth.go b/internal/routes/auth.go
new file mode 100644
index 0000000..84cd7cf
--- /dev/null
+++ b/internal/routes/auth.go
@@ -0,0 +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")
+}
diff --git a/internal/routes/github.go b/internal/routes/github.go
new file mode 100644
index 0000000..fabc8bf
--- /dev/null
+++ b/internal/routes/github.go
@@ -0,0 +1,17 @@
+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 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", "OPTIONS")
+ s.HandleFunc("/readme", controllers.CreateFile).Methods("POST", "OPTIONS")
+}
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
new file mode 100644
index 0000000..5b683c0
--- /dev/null
+++ b/internal/routes/router.go
@@ -0,0 +1,67 @@
+package routes
+
+import (
+ "log"
+ "net/http"
+ "os"
+
+ "github.com/gorilla/mux"
+ "github.com/rs/cors"
+ "github.com/var-code-5/devboard-backend/internal/middlewares"
+)
+
+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: 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, // 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")
+
+ RegisterAuth(apiRouter)
+ RegisterGitHub(apiRouter)
+ RegisterWidget(apiRouter)
+ RegisterMarketPlace(apiRouter)
+
+ // Apply CORS to the entire router
+ 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"}`))
+}
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")
+}
|