Skip to content

preparing for monorepo #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open

preparing for monorepo #10

wants to merge 2 commits into from

Conversation

audibleblink
Copy link
Owner

This pull request introduces significant updates to the PassDB project, including frontend integration, API restructuring, improved caching, and a multi-stage Docker build. The changes enhance the application's architecture by enabling it to serve both API endpoints and static frontend assets, improving maintainability and scalability.

Frontend Integration and Static File Serving:

  • Added Go embed functionality to serve static assets from the docs/ directory, with MIME type handling and SPA routing fallback to index.html. Static files are cached for one year. (main.go, main.goR316-R389)
  • Updated Dockerfile to include a multi-stage build process for both frontend (Node.js) and backend (Go), ensuring the final image is lightweight and production-ready. (Dockerfile, DockerfileL1-R31)
  • Created a Makefile to automate build processes, including frontend asset generation, Go binary compilation, and Docker image creation. (Makefile, MakefileR1-R62)

API Restructuring:

  • Moved all API routes under the /api/v1/ prefix to support versioning and maintain backward compatibility. (main.go, main.goR61-R93)
  • Added a new health check endpoint at /api/v1/health to monitor service status. (main.go, main.goR316-R389)

Caching Enhancements:

  • Updated cache configuration to reflect the new API route structure, ensuring proper TTLs for versioned endpoints. (cache.go, cache.goL70-R89)
  • Introduced a shouldCache function to determine if a route should be cached, improving cache middleware logic. (cache.go, [1] [2]

Dependency and Documentation Updates:

  • Added go.etcd.io/bbolt to go.mod for potential future use, then removed it as an indirect dependency to clean up unused imports. (go.mod, [1] [2]
  • Included a detailed plan.md file outlining the changes made for frontend integration and build system updates. (plan.md, plan.mdR1-R43)

@audibleblink audibleblink requested a review from Copilot June 20, 2025 02:39
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR prepares the project for a monorepo structure by integrating the frontend with the backend, restructuring API routes, enhancing caching, and streamlining the build process. Key changes include:

  • Integration of Go embed functionality and SPA routing for serving static frontend assets.
  • Multi-stage Docker build and added Makefile for automated builds.
  • API route restructuring under /api/v1/ and updated caching configuration.

Reviewed Changes

Copilot reviewed 6 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
plan.md Added a project integration and build plan.
main.go Updated static file serving, API routes, and logging.
go.mod Updated dependencies by re-adding and removing indirect ones.
cache.go Revised cache TTL configurations and added route-based caching logic.
Makefile Created automation targets for frontend and backend builds.
Dockerfile Implemented a multi-stage build for frontend and backend.

- Add Makefile for Go backend and frontend asset build automation, including clean,
  test, and Docker targets.
- Refactor Dockerfile to use multi-stage builds for frontend (Node.js) and Go
  backend, embedding static docs and optimizing final image.
- Serve static frontend assets from embedded docs directory using Go 1.16+ embed
  and chi router, with SPA fallback and cache headers.
- Introduce API versioning under /api/v1, moving all endpoints and adding a
  /api/v1/health endpoint.
- Update cache middleware to match new API route patterns and add shouldCache
  helper.
- Update .gitignore to exclude node_modules, docs/*, and build/ directories.
- Update Go module dependencies for bbolt and static asset support.

```

diff --git a/.gitignore b/.gitignore
index bfc680a..53680fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
 .env
 credentials.json
 cache.db
+node_modules
+docs/*
+build/
diff --git a/Dockerfile b/Dockerfile
index f21da98..f926774 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,31 @@
-FROM golang:1.24
+#================
+# Stage 1: Frontend build (Node.js)
+#================
+FROM node:18-alpine AS frontend-builder
+WORKDIR /app
+RUN apk add --no-cache make
+COPY Makefile package*.json ./
+RUN make docs/index.html

-RUN go install github.com/audibleblink/passdb@latest
-CMD ["passdb"]
+#================
+# Stage 2: Go build
+#================
+FROM golang:1.24-alpine AS go-builder
+WORKDIR /app
+RUN apk add --no-cache git make
+COPY . .
+COPY --from=frontend-builder /app/docs /app
+RUN make passdb

-# $ docker build -t passdb-server .
-# $ docker run --env-file .env -p 3000:3000  passdb-server
+#================
+# Stage 3: Final runtime image
+#================
+FROM alpine:latest
+RUN apk --no-cache add ca-certificates tzdata curl
+WORKDIR /app
+COPY --from=go-builder /app/build/passdb /usr/local/bin/passdb
+EXPOSE 3000
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+  CMD curl -f http://localhost:3000/api/v1/health || exit 1
+
+ENTRYPOINT ["passdb"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1f7ca1f
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,62 @@
+# PassDB Makefile
+# Build automation for Go backend with embedded frontend assets
+
+# Variables
+BINARY_NAME=passdb
+BUILD_DIR=build
+DOCS_DIR=docs
+MAIN_FILE=.
+CRT=container
+
+# Build Go binary with embedded assets
+$(BINARY_NAME): docs/index.html
+	@echo "Building Go binary..."
+	@mkdir -p $(BUILD_DIR)
+	go mod tidy
+	go build -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_FILE)
+	@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
+
+# Build frontend assets (runs npm build if package.json exists)
+docs/index.html:
+	@mkdir -p docs
+	@if [ -f package.json ]; then \
+		echo "Building frontend assets..."; \
+		npm install; \
+		npm run build; \
+	else \
+		echo "No package.json found, skipping frontend build"; \
+	fi
+
+
+# Development server (backend only, no asset building)
+.PHONY: dev
+dev:
+	@echo "Starting development server..."
+	go run $(MAIN_FILE)
+
+# Clean build artifacts
+.PHONY: clean
+clean:
+	@echo "Cleaning build artifacts..."
+	@rm -rf $(BUILD_DIR)
+	@if [ -d node_modules ]; then rm -rf node_modules; fi
+	@if [ -f package-lock.json ]; then rm -f package-lock.json; fi
+	@echo "Clean complete"
+
+# Test the application
+.PHONY: test
+test:
+	@echo "Running tests..."
+	go test -v ./...
+
+# Docker build
+.PHONY: docker-build
+docker-build:
+	@echo "Building Docker image..."
+	$(CRT) build -t passdb .
+
+# Docker run
+.PHONY: docker-run
+docker-run: docker-build
+	@echo "Running Docker container..."
+	$(CRT) run --env-file .env -p 3000:3000 passdb-server
diff --git a/cache.go b/cache.go
index 2632351..7a41f00 100644
--- a/cache.go
+++ b/cache.go
@@ -40,8 +40,8 @@ func newResponseCapture(w http.ResponseWriter) *responseCapture {
 	return &responseCapture{
 		ResponseWriter: w,
 		statusCode:     http.StatusOK,
-		body:          new(bytes.Buffer),
-		headers:       make(http.Header),
+		body:           new(bytes.Buffer),
+		headers:        make(http.Header),
 	}
 }

@@ -67,11 +67,26 @@ func LoadCacheConfig() CacheConfig {
 		RouteTTLs:  make(map[string]time.Duration),
 	}

-	config.RouteTTLs["/breaches/"] = getEnvDuration("CACHE_TTL_BREACHES", 168*time.Hour)   // 7 days
-	config.RouteTTLs["/usernames/"] = getEnvDuration("CACHE_TTL_USERNAMES", 720*time.Hour) // 30 days
-	config.RouteTTLs["/passwords/"] = getEnvDuration("CACHE_TTL_PASSWORDS", 720*time.Hour) // 30 days
-	config.RouteTTLs["/domains/"] = getEnvDuration("CACHE_TTL_DOMAINS", 720*time.Hour)     // 30 days
-	config.RouteTTLs["/emails/"] = getEnvDuration("CACHE_TTL_EMAILS", 720*time.Hour)       // 30 days
+	config.RouteTTLs["/api/v1/breaches/"] = getEnvDuration(
+		"CACHE_TTL_BREACHES",
+		168*time.Hour,
+	) // 7 days
+	config.RouteTTLs["/api/v1/usernames/"] = getEnvDuration(
+		"CACHE_TTL_USERNAMES",
+		720*time.Hour,
+	) // 30 days
+	config.RouteTTLs["/api/v1/passwords/"] = getEnvDuration(
+		"CACHE_TTL_PASSWORDS",
+		720*time.Hour,
+	) // 30 days
+	config.RouteTTLs["/api/v1/domains/"] = getEnvDuration(
+		"CACHE_TTL_DOMAINS",
+		720*time.Hour,
+	) // 30 days
+	config.RouteTTLs["/api/v1/emails/"] = getEnvDuration(
+		"CACHE_TTL_EMAILS",
+		720*time.Hour,
+	) // 30 days

 	return config
 }
@@ -130,8 +145,14 @@ func CacheMiddleware(config CacheConfig) func(http.Handler) http.Handler {

 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			// Check if this route should be cached
+			if !shouldCache(r.URL.Path, config) {
+				next.ServeHTTP(w, r)
+				return
+			}
+
 			cacheKey := fmt.Sprintf("%s:%s", r.Method, r.URL.Path)
-
+
 			var cached *CacheEntry
 			err := db.View(func(tx *bbolt.Tx) error {
 				bucket := tx.Bucket([]byte("cache"))
@@ -139,7 +160,7 @@ func CacheMiddleware(config CacheConfig) func(http.Handler) http.Handler {
 				if data == nil {
 					return nil
 				}
-
+
 				cached = &CacheEntry{}
 				return json.Unmarshal(data, cached)
 			})
@@ -162,7 +183,7 @@ func CacheMiddleware(config CacheConfig) func(http.Handler) http.Handler {

 			if rc.statusCode >= 200 && rc.statusCode < 300 {
 				ttl := getTTLForPath(r.URL.Path, config)
-
+
 				entry := CacheEntry{
 					StatusCode: rc.statusCode,
 					Headers:    make(map[string]string),
@@ -201,4 +222,15 @@ func getTTLForPath(path string, config CacheConfig) time.Duration {
 		}
 	}
 	return config.DefaultTTL
-}
\ No newline at end of file
+}
+
+func shouldCache(path string, config CacheConfig) bool {
+	// Check if the path matches any configured cache routes
+	for routePattern := range config.RouteTTLs {
+		if strings.HasPrefix(path, routePattern) {
+			return true
+		}
+	}
+	return false
+}
+
diff --git a/go.mod b/go.mod
index d47b36b..372fcf3 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
 	cloud.google.com/go/bigquery v1.10.0
 	github.com/go-chi/chi v4.1.2+incompatible
 	github.com/go-chi/cors v1.1.1
+	go.etcd.io/bbolt v1.4.1
 	google.golang.org/api v0.29.0
 )

@@ -17,7 +18,6 @@ require (
 	github.com/golang/protobuf v1.4.2 // indirect
 	github.com/googleapis/gax-go/v2 v2.0.5 // indirect
 	github.com/jstemmer/go-junit-report v0.9.1 // indirect
-	go.etcd.io/bbolt v1.4.1 // indirect
 	go.opencensus.io v0.22.3 // indirect
 	golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect
 	golang.org/x/mod v0.3.0 // indirect
diff --git a/go.sum b/go.sum
index 92ba315..d8ba422 100644
--- a/go.sum
+++ b/go.sum
@@ -43,6 +43,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -111,11 +113,14 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -202,6 +207,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -225,7 +232,6 @@ golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200523222454-059865788121 h1:rITEj+UZHYC927n8GT97eC3zrpzXdb/voyeOuVKS46o=
 golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
 golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -360,6 +366,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/main.go b/main.go
index 1a8af17..158700d 100644
--- a/main.go
+++ b/main.go
@@ -2,12 +2,17 @@ package main

 import (
 	"context"
+	"embed"
 	"encoding/json"
 	"fmt"
+	"io"
+	"io/fs"
 	"log"
 	"net/http"
 	"os"
+	"path/filepath"
 	"strings"
+	"time"

 	"cloud.google.com/go/bigquery"
 	"google.golang.org/api/iterator"
@@ -18,6 +23,9 @@ import (
 	"github.com/go-chi/cors"
 )

+//go:embed docs
+var staticFiles embed.FS
+
 var (
 	projectID     = os.Getenv("GOOGLE_CLOUD_PROJECT")
 	bigQueryTable = os.Getenv("GOOGLE_BIGQUERY_TABLE")
@@ -50,26 +58,39 @@ func main() {
 	cacheConfig := LoadCacheConfig()

 	r := chi.NewRouter()
+	r.Use(cors.Handler(cors.Options{
+		AllowedOrigins: []string{"*"},
+	}))
 	r.Use(middleware.RequestID)
 	r.Use(middleware.RealIP)
 	r.Use(middleware.Logger)
 	r.Use(CacheMiddleware(cacheConfig))
 	r.Use(middleware.Recoverer)
-	r.Use(cors.Handler(cors.Options{
-		AllowedOrigins: []string{"*"},
-	}))

-	r.Get("/usernames/{username}", handleUsername)
-	r.Get("/passwords/{password}", handlePassword)
-	r.Get("/domains/{domain}", handleDomain)
-	r.Get("/emails/{email}", handleEmail)
-	r.Get("/breaches/{email}", handleBreaches)
+	// API routes with versioning
+	r.Route("/api/v1", func(r chi.Router) {
+		// Health check endpoint
+		r.Get("/health", handleHealth)

-	r.Get("/cache/stats", handleCacheStats)
-	r.Delete("/cache", handleCacheClear)
-	r.Delete("/cache/{pattern}", handleCacheClearPattern)
+		// Password database endpoints
+		r.Get("/usernames/{username}", handleUsername)
+		r.Get("/passwords/{password}", handlePassword)
+		r.Get("/domains/{domain}", handleDomain)
+		r.Get("/emails/{email}", handleEmail)
+		r.Get("/breaches/{email}", handleBreaches)
+
+		// Cache management endpoints
+		r.Get("/cache/stats", handleCacheStats)
+		r.Delete("/cache", handleCacheClear)
+		r.Delete("/cache/{pattern}", handleCacheClearPattern)
+	})
+
+	// Static file serving
+	setupStaticRoutes(r)

 	log.Printf("Starting server on %s\n", listenAddr)
+	log.Printf("API endpoints available at /api/v1/")
+	log.Printf("Static files served from /")
 	err := http.ListenAndServe(listenAddr, r)
 	if err != nil {
 		log.Fatal(err)
@@ -292,3 +313,77 @@ func handleCacheClearPattern(w http.ResponseWriter, r *http.Request) {

 	json.NewEncoder(w).Encode(response)
 }
+
+func handleHealth(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+
+	response := map[string]any{
+		"status":  "healthy",
+		"service": "passdb-api",
+		"version": "v1",
+	}
+
+	json.NewEncoder(w).Encode(response)
+}
+
+func setupStaticRoutes(r chi.Router) {
+	// Create a sub filesystem for the docs directory
+	docsFS, err := fs.Sub(staticFiles, "docs")
+	if err != nil {
+		log.Printf("Warning: could not create docs filesystem: %v", err)
+		return
+	}
+
+	// Serve static files from the docs directory
+	fileServer := http.FileServer(http.FS(docsFS))
+
+	// Handle all remaining routes as static files
+	r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
+		rctx := chi.RouteContext(r.Context())
+		pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
+		fs := http.StripPrefix(pathPrefix, fileServer)
+
+		// Set appropriate content type based on file extension
+		ext := filepath.Ext(r.URL.Path)
+		switch ext {
+		case ".html":
+			w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		case ".css":
+			w.Header().Set("Content-Type", "text/css; charset=utf-8")
+		case ".js":
+			w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
+		case ".json":
+			w.Header().Set("Content-Type", "application/json; charset=utf-8")
+		case ".png":
+			w.Header().Set("Content-Type", "image/png")
+		case ".jpg", ".jpeg":
+			w.Header().Set("Content-Type", "image/jpeg")
+		case ".svg":
+			w.Header().Set("Content-Type", "image/svg+xml")
+		}
+
+		// Add cache headers for static assets
+		w.Header().Set("Cache-Control", "public, max-age=31536000") // 1 year
+
+		// Try to serve the requested file
+		fs.ServeHTTP(w, r)
+	})
+
+	// Fallback route for SPA routing - serve index.html for unmatched routes
+	r.NotFound(func(w http.ResponseWriter, r *http.Request) {
+		// Only serve index.html for non-API routes
+		if !strings.HasPrefix(r.URL.Path, "/api/") {
+			w.Header().Set("Content-Type", "text/html; charset=utf-8")
+			indexFile, err := docsFS.Open("index.html")
+			if err != nil {
+				http.NotFound(w, r)
+				return
+			}
+			defer indexFile.Close()
+
+			http.ServeContent(w, r, "index.html", time.Time{}, indexFile.(io.ReadSeeker))
+		} else {
+			http.NotFound(w, r)
+		}
+	})
+}
…points

- Add global references for cache configuration and BoltDB instance to enable
  management operations outside middleware.
- Implement GetCacheStats to provide cache status, entry counts, DB size, and
  route-specific statistics.
- Add ClearCache to remove all cache entries and ClearCachePattern to delete
  entries matching a pattern.
- Update /cache/stats, /cache/clear, and /cache/clear/{pattern} endpoints to use
  new management functions and return detailed JSON responses.
- Improve error handling for cache management endpoints, returning proper error
  messages and HTTP status codes.
- Ensure cacheDB is set to nil if initialization fails to prevent misuse.

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# On branch api-route
# Your branch is up to date with 'origin/api-route'.
#
# Changes to be committed:
#	modified:   cache.go
#	modified:   main.go
#
# Untracked files:
#	passdb
#
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
diff --git a/cache.go b/cache.go
index 7a41f00..a8d7ab6 100644
--- a/cache.go
+++ b/cache.go
@@ -117,6 +117,9 @@ func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
 }

 func CacheMiddleware(config CacheConfig) func(http.Handler) http.Handler {
+	// Store config in global variable for management functions
+	cacheConfig = config
+
 	if !config.Enabled {
 		return func(next http.Handler) http.Handler {
 			return next
@@ -131,6 +134,9 @@ func CacheMiddleware(config CacheConfig) func(http.Handler) http.Handler {
 		}
 	}

+	// Store db reference in global variable for management functions
+	cacheDB = db
+
 	err = db.Update(func(tx *bbolt.Tx) error {
 		_, err := tx.CreateBucketIfNotExists([]byte("cache"))
 		return err
@@ -138,6 +144,7 @@ func CacheMiddleware(config CacheConfig) func(http.Handler) http.Handler {
 	if err != nil {
 		log.Printf("Failed to create cache bucket: %v", err)
 		db.Close()
+		cacheDB = nil
 		return func(next http.Handler) http.Handler {
 			return next
 		}
@@ -234,3 +241,140 @@ func shouldCache(path string, config CacheConfig) bool {
 	return false
 }

+// Global cache database reference for management functions
+var cacheDB *bbolt.DB
+var cacheConfig CacheConfig
+
+// CacheStats represents cache statistics
+type CacheStats struct {
+	Enabled       bool                     `json:"enabled"`
+	TotalEntries  int                      `json:"total_entries"`
+	DatabaseSize  int64                    `json:"database_size_bytes"`
+	Configuration map[string]interface{}   `json:"configuration"`
+	EntriesByPath map[string]int           `json:"entries_by_path"`
+}
+
+// GetCacheStats returns current cache statistics
+func GetCacheStats() (CacheStats, error) {
+	stats := CacheStats{
+		Enabled:       cacheConfig.Enabled,
+		Configuration: make(map[string]interface{}),
+		EntriesByPath: make(map[string]int),
+	}
+
+	// Add configuration details
+	stats.Configuration["default_ttl"] = cacheConfig.DefaultTTL.String()
+	stats.Configuration["db_path"] = cacheConfig.DBPath
+
+	// Add route-specific TTLs
+	routeTTLs := make(map[string]string)
+	for route, ttl := range cacheConfig.RouteTTLs {
+		routeTTLs[route] = ttl.String()
+	}
+	stats.Configuration["route_ttls"] = routeTTLs
+
+	if !cacheConfig.Enabled || cacheDB == nil {
+		return stats, nil
+	}
+
+	// Get database file size
+	if fileInfo, err := os.Stat(cacheConfig.DBPath); err == nil {
+		stats.DatabaseSize = fileInfo.Size()
+	}
+
+	// Count entries and group by path prefix
+	err := cacheDB.View(func(tx *bbolt.Tx) error {
+		bucket := tx.Bucket([]byte("cache"))
+		if bucket == nil {
+			return nil
+		}
+
+		return bucket.ForEach(func(k, v []byte) error {
+			stats.TotalEntries++
+
+			// Extract path from cache key (format: "METHOD:path")
+			key := string(k)
+			parts := strings.SplitN(key, ":", 2)
+			if len(parts) == 2 {
+				path := parts[1]
+				// Group by route prefix
+				for routePrefix := range cacheConfig.RouteTTLs {
+					if strings.HasPrefix(path, routePrefix) {
+						stats.EntriesByPath[routePrefix]++
+						break
+					}
+				}
+			}
+
+			return nil
+		})
+	})
+
+	return stats, err
+}
+
+// ClearCache removes all entries from the cache
+func ClearCache() error {
+	if !cacheConfig.Enabled || cacheDB == nil {
+		return fmt.Errorf("cache is not enabled or not initialized")
+	}
+
+	return cacheDB.Update(func(tx *bbolt.Tx) error {
+		bucket := tx.Bucket([]byte("cache"))
+		if bucket == nil {
+			return nil
+		}
+
+		// Delete and recreate the bucket to clear all entries
+		if err := tx.DeleteBucket([]byte("cache")); err != nil {
+			return err
+		}
+		_, err := tx.CreateBucket([]byte("cache"))
+		return err
+	})
+}
+
+// ClearCachePattern removes cache entries matching the given pattern
+func ClearCachePattern(pattern string) (int, error) {
+	if !cacheConfig.Enabled || cacheDB == nil {
+		return 0, fmt.Errorf("cache is not enabled or not initialized")
+	}
+
+	var deletedCount int
+
+	err := cacheDB.Update(func(tx *bbolt.Tx) error {
+		bucket := tx.Bucket([]byte("cache"))
+		if bucket == nil {
+			return nil
+		}
+
+		// Collect keys to delete
+		var keysToDelete [][]byte
+
+		err := bucket.ForEach(func(k, v []byte) error {
+			key := string(k)
+			// Check if key contains the pattern
+			if strings.Contains(key, pattern) {
+				keysToDelete = append(keysToDelete, k)
+			}
+			return nil
+		})
+
+		if err != nil {
+			return err
+		}
+
+		// Delete the collected keys
+		for _, key := range keysToDelete {
+			if err := bucket.Delete(key); err != nil {
+				return err
+			}
+			deletedCount++
+		}
+
+		return nil
+	})
+
+	return deletedCount, err
+}
+
diff --git a/main.go b/main.go
index 158700d..c46175b 100644
--- a/main.go
+++ b/main.go
@@ -285,19 +285,26 @@ func JSONError(w http.ResponseWriter, err error, code int) {
 func handleCacheStats(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "application/json")

-	response := map[string]any{
-		"message": "Cache stats endpoint - implementation pending",
-		"enabled": getEnvBool("CACHE_ENABLED", true),
+	stats, err := GetCacheStats()
+	if err != nil {
+		JSONError(w, err, http.StatusInternalServerError)
+		return
 	}

-	json.NewEncoder(w).Encode(response)
+	json.NewEncoder(w).Encode(stats)
 }

 func handleCacheClear(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "application/json")

+	if err := ClearCache(); err != nil {
+		JSONError(w, err, http.StatusInternalServerError)
+		return
+	}
+
 	response := map[string]any{
-		"message": "Cache cleared - implementation pending",
+		"message": "Cache cleared successfully",
+		"success": true,
 	}

 	json.NewEncoder(w).Encode(response)
@@ -307,8 +314,16 @@ func handleCacheClearPattern(w http.ResponseWriter, r *http.Request) {
 	pattern := chi.URLParam(r, "pattern")
 	w.Header().Set("Content-Type", "application/json")

+	deletedCount, err := ClearCachePattern(pattern)
+	if err != nil {
+		JSONError(w, err, http.StatusInternalServerError)
+		return
+	}
+
 	response := map[string]any{
-		"message": fmt.Sprintf("Cache cleared for pattern: %s - implementation pending", pattern),
+		"message": fmt.Sprintf("Cache entries matching pattern '%s' cleared", pattern),
+		"deleted_count": deletedCount,
+		"success": true,
 	}

 	json.NewEncoder(w).Encode(response)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant