diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..755e4ec --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +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://your_mongodb_uri_here +MONGODB_DB_NAME=devBoard +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 +ENCRYPTION_KEY=your_32_byte_random_secret_key \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0fc5d3e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,72 @@ +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: + 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 + 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 "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 + if: always() + run: | + rm -f ~/.ssh/id_rsa + + - name: Notify Deployment Success + run: | + cat << 'EOF' + Deployment to Oracle Cloud completed successfully. + _____ _ _ + | __ \ | | | | + | | | | ___ _ __| | ___ _ _ ___ __| | + | | | |/ _ \ '_ \ |/ _ \| | | |/ _ \/ _` | + | |__| | __/ |_) | | (_) | |_| | __/ (_| | + |_____/ \___| .__/|_|\___/ \__, |\___|\__,_| + | | __/ | + |_| |___/ + EOF \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbb833f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +cors-test.html +localtest.go diff --git a/README.md b/README.md index 8f25179..a28753d 100644 --- a/README.md +++ b/README.md @@ -2,43 +2,91 @@ 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 + +# Encryption for the tokens +ENCRYPTION_KEY=your_32_byte_random_secret_key + +``` + + +### 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 +94,15 @@
- John Doe + Varshith

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

- + GitHub - + LinkedIn

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") +}