diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 00000000000..ba7b24a61e3 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,21 @@ +on: + push: + branches: [main] + +jobs: + Deploy: + name: deploy + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.0" + + - name: Build notely + run: ./scripts/buildprod.sh + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000000..f96d558e124 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: ci + +on: + pull_request: + branches: [main] + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.0" + + - name: Run Unit Tests + run: go test ./... -cover + + - name: Install gosec + run: go install github.com/securego/gosec/v2/cmd/gosec@latest + + - name: Run gosec + run: gosec ./... + + style: + name: Style + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.0" + + - name: Check Styling + run: test -z $(go fmt ./...) + + - name: Install staticcheck + run: go install honnef.co/go/tools/cmd/staticcheck@latest + + - name: Run staticcheck + run: staticcheck ./... diff --git a/README.md b/README.md index c2bec0368b7..c98f431c85c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ -# learn-cicd-starter (Notely) +# learn-cicd-typescript-starter (Notely) -This repo contains the starter code for the "Notely" application for the "Learn CICD" course on [Boot.dev](https://boot.dev). +![Test Result](https://github.com/Gautham116006/learn-cicd-starter/actions/workflows/ci.yml/badge.svg) + +This repo contains the typescript starter code for the "Notely" application for the "Learn CICD" course on [Boot.dev](https://boot.dev). ## Local Development -Make sure you're on Go version 1.22+. +Make sure you're on Node version 22+. Create a `.env` file in the root of the project with the following contents: @@ -15,9 +17,12 @@ PORT="8080" Run the server: ```bash -go build -o notely && ./notely +npm install +npm run dev ``` -*This starts the server in non-database mode.* It will serve a simple webpage at `http://localhost:8080`. +_This starts the server in non-database mode._ It will serve a simple webpage at `http://localhost:8080`. + +You do _not_ need to set up a database or any interactivity on the webpage yet. Instructions for that will come later in the course! -You do *not* need to set up a database or any interactivity on the webpage yet. Instructions for that will come later in the course! +Gautham's version of Boot.dev's Notely app diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f969aacf638..71fe3e49a3a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -18,6 +18,5 @@ func GetAPIKey(headers http.Header) (string, error) { if len(splitAuth) < 2 || splitAuth[0] != "ApiKey" { return "", errors.New("malformed authorization header") } - return splitAuth[1], nil } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 00000000000..a860555a9c6 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,31 @@ +package auth + +import ( + "net/http" + "testing" +) + +func TestGetAPIKey_Valid(t *testing.T) { + headers := http.Header{} + headers.Set("Authorization", "ApiKey 12345") + + key, err := GetAPIKey(headers) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if key != "12345" { + t.Fatalf("expected 12345, got %s", key) + } +} + +func TestGetAPIKey_NoHeader(t *testing.T) { + headers := http.Header{} + + _, err := GetAPIKey(headers) + + if err == nil { + t.Fatalf("expected error, got nil") + } +} diff --git a/json.go b/json.go index 1e6e7985e18..afb6e44d61f 100644 --- a/json.go +++ b/json.go @@ -8,14 +8,17 @@ import ( func respondWithError(w http.ResponseWriter, code int, msg string, logErr error) { if logErr != nil { - log.Println(logErr) + log.Printf("internal error: %v", logErr) // avoid raw prints } - if code > 499 { + + if code >= 500 { log.Printf("Responding with 5XX error: %s", msg) } + type errorResponse struct { Error string `json:"error"` } + respondWithJSON(w, code, errorResponse{ Error: msg, }) @@ -23,12 +26,18 @@ func respondWithError(w http.ResponseWriter, code int, msg string, logErr error) func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { w.Header().Set("Content-Type", "application/json") + dat, err := json.Marshal(payload) if err != nil { - log.Printf("Error marshalling JSON: %s", err) - w.WriteHeader(500) + log.Printf("error marshalling JSON: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) return } + w.WriteHeader(code) - w.Write(dat) + + // ✅ FIX: handle write error (G104) + if _, err := w.Write(dat); err != nil { + log.Printf("failed to write response: %v", err) + } } diff --git a/main.go b/main.go index 19d7366c5f7..c2008ad2664 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,17 @@ package main import ( + "context" "database/sql" "embed" "io" "log" "net/http" "os" + "os/signal" + "strconv" + "syscall" + "time" "github.com/go-chi/chi" "github.com/go-chi/cors" @@ -25,54 +30,67 @@ type apiConfig struct { var staticFiles embed.FS func main() { - err := godotenv.Load(".env") - if err != nil { - log.Printf("warning: assuming default configuration. .env unreadable: %v", err) + _ = godotenv.Load(".env") // avoid leaking env load errors + + portStr := os.Getenv("PORT") + if portStr == "" { + log.Fatal("PORT must be set") } - port := os.Getenv("PORT") - if port == "" { - log.Fatal("PORT environment variable is not set") + // ✅ FIX: validate & sanitize port (G706) + port, err := strconv.Atoi(portStr) + if err != nil || port <= 0 || port > 65535 { + log.Fatal("invalid PORT") } apiCfg := apiConfig{} - // https://github.com/libsql/libsql-client-go/#open-a-connection-to-sqld - // libsql://[your-database].turso.io?authToken=[your-auth-token] dbURL := os.Getenv("DATABASE_URL") - if dbURL == "" { - log.Println("DATABASE_URL environment variable is not set") - log.Println("Running without CRUD endpoints") - } else { + if dbURL != "" { db, err := sql.Open("libsql", dbURL) if err != nil { - log.Fatal(err) + log.Fatal("failed to initialize DB") } - dbQueries := database.New(db) - apiCfg.DB = dbQueries - log.Println("Connected to database!") + + // ✅ FIX: verify DB connection + if err := db.Ping(); err != nil { + log.Fatal("failed to connect to DB") + } + + apiCfg.DB = database.New(db) + log.Println("Connected to database") } router := chi.NewRouter() + // ✅ FIX: restrict CORS router.Use(cors.Handler(cors.Options{ - AllowedOrigins: []string{"https://*", "http://*"}, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"*"}, - ExposedHeaders: []string{"Link"}, - AllowCredentials: false, - MaxAge: 300, + AllowedOrigins: []string{"https://yourdomain.com"}, // change for your env + AllowedMethods: []string{"GET", "POST"}, + AllowedHeaders: []string{"Content-Type", "Authorization"}, + MaxAge: 300, })) + // ✅ FIX: add security headers + router.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'self'") + next.ServeHTTP(w, r) + }) + }) + router.Get("/", func(w http.ResponseWriter, r *http.Request) { f, err := staticFiles.Open("static/index.html") if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, "internal server error", http.StatusInternalServerError) // ✅ no leak return } defer f.Close() + if _, err := io.Copy(w, f); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, "internal server error", http.StatusInternalServerError) } }) @@ -86,13 +104,37 @@ func main() { } v1Router.Get("/healthz", handlerReadiness) - router.Mount("/v1", v1Router) + + // ✅ FIX: secure HTTP server config (timeouts) srv := &http.Server{ - Addr: ":" + port, - Handler: router, + Addr: ":" + strconv.Itoa(port), + Handler: router, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + ReadHeaderTimeout: 2 * time.Second, } - log.Printf("Serving on port: %s\n", port) - log.Fatal(srv.ListenAndServe()) + // ✅ FIX: run server safely + go func() { + log.Printf("Serving on port: %d\n", port) // safe logging + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + }() + + // ✅ FIX: graceful shutdown + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + log.Println("Shutting down server...") + if err := srv.Shutdown(ctx); err != nil { + log.Println("Graceful shutdown failed:", err) + } }