diff --git a/Makefile b/Makefile index cceadd4141c..7da2d71d80c 100644 --- a/Makefile +++ b/Makefile @@ -90,21 +90,38 @@ build-api-protos: build-protos: build-osv-protos build-api-protos -run-website: - cd gcp/website/frontend3 && pnpm install && pnpm run build - cd gcp/website/blog && hugo --buildFuture -d ../dist/static/blog +build-website-frontend: + cd website/frontend3 && pnpm install && pnpm run build + cd website/blog && hugo --buildFuture -d ../dist/static/blog + +run-website: build-website-frontend cd gcp/website && $(install-cmd) && GOOGLE_CLOUD_PROJECT=oss-vdb OSV_VULNERABILITIES_BUCKET=osv-vulnerabilities $(run-cmd) python main.py -run-website-staging: - cd gcp/website/frontend3 && pnpm install && pnpm run build - cd gcp/website/blog && hugo --buildFuture -d ../dist/static/blog +run-website-staging: build-website-frontend cd gcp/website && $(install-cmd) && GOOGLE_CLOUD_PROJECT=oss-vdb-test OSV_VULNERABILITIES_BUCKET=osv-test-vulnerabilities $(run-cmd) python main.py -run-website-emulator: - cd gcp/website/frontend3 && pnpm install && pnpm run build - cd gcp/website/blog && hugo --buildFuture -d ../dist/static/blog +run-website-emulator: build-website-frontend cd gcp/website && $(install-cmd) && DATASTORE_EMULATOR_PORT=5002 $(run-cmd) python frontend_emulator.py +run-go-website: build-website-frontend + cd go && GOOGLE_CLOUD_PROJECT=oss-vdb OSV_VULNERABILITIES_BUCKET=osv-vulnerabilities go run ./cmd/website -static-dir ../website/dist -docs-dir ../docs + +run-go-website-staging: build-website-frontend + cd go && GOOGLE_CLOUD_PROJECT=oss-vdb-test OSV_VULNERABILITIES_BUCKET=osv-test-vulnerabilities go run ./cmd/website -static-dir ../website/dist -docs-dir ../docs + +run-go-website-emulator: build-website-frontend + cd go && DATASTORE_EMULATOR_HOST=localhost:5002 go run ./cmd/website -static-dir ../website/dist -docs-dir ../docs + +stage-website-assets: build-website-frontend + mkdir -p go/cmd/website/dist go/cmd/website/docs + cp -r website/dist/* go/cmd/website/dist/ + cp docs/osv_service_v1.swagger.json go/cmd/website/docs/ + +run-go-website-prod: stage-website-assets + cd go && GOOGLE_CLOUD_PROJECT=oss-vdb OSV_VULNERABILITIES_BUCKET=osv-vulnerabilities go run -tags embedstatic ./cmd/website + + + # Run with `make run-api-server ARGS=--no-backend` to launch esp without backend. # Run the Go developer server orchestrator (launches both ESPv2 and the Go API server). # Run with `make run-api-server ARGS=--no-backend` to launch esp without backend. diff --git a/go/cmd/website/.gitignore b/go/cmd/website/.gitignore new file mode 100644 index 00000000000..5c53e32e007 --- /dev/null +++ b/go/cmd/website/.gitignore @@ -0,0 +1,2 @@ +dist/ +docs/ diff --git a/go/cmd/website/main.go b/go/cmd/website/main.go new file mode 100644 index 00000000000..9f671f60645 --- /dev/null +++ b/go/cmd/website/main.go @@ -0,0 +1,106 @@ +// Package main implements the entry point for the OSV website server in Go. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/google/osv.dev/go/internal/website" + "github.com/google/osv.dev/go/logger" +) + +func main() { + if err := run(); err != nil { + os.Exit(1) + } +} + +func run() error { + logger.InitGlobalLogger() + defer logger.Close() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + defaultPort := 8000 + if portStr := os.Getenv("PORT"); portStr != "" { + if p, err := strconv.Atoi(portStr); err == nil { + defaultPort = p + } else { + logger.ErrorContext(ctx, "Invalid PORT environment variable, using default", slog.Any("error", err)) + } + } + + port := flag.Int("port", defaultPort, "port for the website server") + staticDir := flag.String("static-dir", "dist", "directory containing static assets") + docsDir := flag.String("docs-dir", "docs", "directory containing API docs") + flag.Parse() + + staticFiles, err := getStaticFS(*staticDir) + if err != nil { + logger.ErrorContext(ctx, "Failed to load static filesystem", slog.String("dir", *staticDir), slog.Any("error", err)) + + return fmt.Errorf("failed to load static filesystem %q: %w", *staticDir, err) + } + + docsFiles, err := getDocsFS(*docsDir) + if err != nil { + logger.ErrorContext(ctx, "Failed to load docs filesystem", slog.String("dir", *docsDir), slog.Any("error", err)) + + return fmt.Errorf("failed to load docs filesystem %q: %w", *docsDir, err) + } + + srv, err := website.NewServer(website.Config{ + StaticFS: staticFiles, + DocsFS: docsFiles, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to create website server", slog.Any("error", err)) + + return fmt.Errorf("failed to create website server: %w", err) + } + + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", *port), + Handler: srv, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + serverErrors := make(chan error, 1) + go func() { + url := fmt.Sprintf("http://localhost:%d", *port) + logger.InfoContext(ctx, "Starting OSV website server at "+url, slog.Int("port", *port), slog.String("url", url)) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + serverErrors <- err + } + }() + + select { + case err := <-serverErrors: + logger.ErrorContext(ctx, "Server error", slog.Any("error", err)) + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + + logger.InfoContext(shutdownCtx, "Shutting down website server...") + if err := httpServer.Shutdown(shutdownCtx); err != nil { + logger.ErrorContext(shutdownCtx, "Error during server shutdown", slog.Any("error", err)) + + return err + } + } + + return nil +} diff --git a/go/cmd/website/static_dev.go b/go/cmd/website/static_dev.go new file mode 100644 index 00000000000..1dcd4a01b8c --- /dev/null +++ b/go/cmd/website/static_dev.go @@ -0,0 +1,16 @@ +//go:build !embedstatic + +package main + +import ( + "io/fs" + "os" +) + +func getStaticFS(dir string) (fs.FS, error) { + return os.DirFS(dir), nil +} + +func getDocsFS(dir string) (fs.FS, error) { + return os.DirFS(dir), nil +} diff --git a/go/cmd/website/static_embed.go b/go/cmd/website/static_embed.go new file mode 100644 index 00000000000..a146d4a61b8 --- /dev/null +++ b/go/cmd/website/static_embed.go @@ -0,0 +1,22 @@ +//go:build embedstatic + +package main + +import ( + "embed" + "io/fs" +) + +//go:embed dist/* +var embeddedDist embed.FS + +//go:embed docs/* +var embeddedDocs embed.FS + +func getStaticFS(_ string) (fs.FS, error) { + return fs.Sub(embeddedDist, "dist") +} + +func getDocsFS(_ string) (fs.FS, error) { + return fs.Sub(embeddedDocs, "docs") +} diff --git a/go/internal/website/auth.go b/go/internal/website/auth.go new file mode 100644 index 00000000000..9c6286831fb --- /dev/null +++ b/go/internal/website/auth.go @@ -0,0 +1,34 @@ +package website + +import ( + "fmt" + "net/http" +) + +// handleLogin handles initiating Google OAuth authentication login flow. +// Query parameters: +// - redirect: optional post-login redirect destination +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + redirectURI := r.URL.Query().Get("redirect") + + // TODO: Redirect user to Google OAuth authorization endpoint + http.Error(w, fmt.Sprintf("OAuth login handler stub (redirect=%q)", redirectURI), http.StatusNotImplemented) +} + +// handleAuthCallback handles processing OAuth callback tokens. +// Query parameters: +// - code: authorization code from OAuth provider +// - state: CSRF protection state token +func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + + // TODO: Exchange code for session token and store in cookie + http.Error(w, fmt.Sprintf("OAuth callback handler stub (code=%q, state=%q)", code, state), http.StatusNotImplemented) +} + +// handleLogout handles logging out user and clearing authentication session cookies. +func (s *Server) handleLogout(w http.ResponseWriter, _ *http.Request) { + // TODO: Clear authentication cookie and redirect user + http.Error(w, "OAuth logout handler stub", http.StatusNotImplemented) +} diff --git a/go/internal/website/blog.go b/go/internal/website/blog.go new file mode 100644 index 00000000000..7ca74004760 --- /dev/null +++ b/go/internal/website/blog.go @@ -0,0 +1,45 @@ +package website + +import ( + "fmt" + "net/http" +) + +// handleBlogIndex handles serving the blog landing page /blog/. +func (s *Server) handleBlogIndex(w http.ResponseWriter, _ *http.Request) { + // TODO: Load blog index content from StaticFS (static/blog/index.html) and render blog template + http.Error(w, "Blog index handler stub", http.StatusNotImplemented) +} + +// handleBlogRSS handles serving the blog RSS feed /blog/index.xml. +func (s *Server) handleBlogRSS(w http.ResponseWriter, _ *http.Request) { + // TODO: Serve static/blog/index.xml from StaticFS + http.Error(w, "Blog RSS handler stub", http.StatusNotImplemented) +} + +// handleBlogPost handles serving individual blog posts /blog/posts/{blog_name}/. +func (s *Server) handleBlogPost(w http.ResponseWriter, r *http.Request) { + blogName := r.PathValue("blog_name") + if blogName == "" { + http.NotFound(w, r) + + return + } + + // TODO: Validate blog_name, load static/blog/posts/{blog_name}/index.html and render post template + http.Error(w, fmt.Sprintf("Blog post handler stub (blogName=%q)", blogName), http.StatusNotImplemented) +} + +// handleBlogPostFile handles serving static assets inside blog post directories /blog/posts/{blog_name}/{file_name}. +func (s *Server) handleBlogPostFile(w http.ResponseWriter, r *http.Request) { + blogName := r.PathValue("blog_name") + fileName := r.PathValue("file_name") + if blogName == "" || fileName == "" { + http.NotFound(w, r) + + return + } + + // TODO: Serve static/blog/posts/{blog_name}/{file_name} from StaticFS + http.Error(w, fmt.Sprintf("Blog post asset handler stub (blogName=%q, fileName=%q)", blogName, fileName), http.StatusNotImplemented) +} diff --git a/go/internal/website/linter.go b/go/internal/website/linter.go new file mode 100644 index 00000000000..e31433bf2ed --- /dev/null +++ b/go/internal/website/linter.go @@ -0,0 +1,33 @@ +package website + +import ( + "fmt" + "net/http" +) + +// handleLinterPage handles serving the linter findings UI page. +func (s *Server) handleLinterPage(w http.ResponseWriter, _ *http.Request) { + // TODO: Serve linter UI page / template + http.Error(w, "Linter UI page stub", http.StatusNotImplemented) +} + +// handleLinterSources handles listing sources that have linter findings from GCS. +func (s *Server) handleLinterSources(w http.ResponseWriter, _ *http.Request) { + // TODO: List prefixes in GCS bucket osv-test-public-import-logs under linter-result/ + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error": "Linter sources handler stub"}`, http.StatusNotImplemented) +} + +// handleLinterFindings handles fetching linter findings JSON for a specific source from GCS. +func (s *Server) handleLinterFindings(w http.ResponseWriter, r *http.Request) { + source := r.PathValue("source") + if source == "" { + http.NotFound(w, r) + + return + } + + // TODO: Download linter-result//result.json from GCS bucket + w.Header().Set("Content-Type", "application/json") + http.Error(w, fmt.Sprintf(`{"error": "Linter findings handler stub", "source": %q}`, source), http.StatusNotImplemented) +} diff --git a/go/internal/website/list.go b/go/internal/website/list.go new file mode 100644 index 00000000000..22439e25f4a --- /dev/null +++ b/go/internal/website/list.go @@ -0,0 +1,20 @@ +package website + +import ( + "fmt" + "net/http" +) + +// handleList handles rendering the vulnerability listing page and backing paginated list queries. +// Query parameters: +// - q: search query string +// - ecosystem: ecosystem filter +// - page: page number +func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + ecosystem := r.URL.Query().Get("ecosystem") + page := r.URL.Query().Get("page") + + // TODO: Query ListedVulnerability entities from Datastore and render page / JSON + http.Error(w, fmt.Sprintf("Vulnerability list handler stub (q=%q, ecosystem=%q, page=%q)", q, ecosystem, page), http.StatusNotImplemented) +} diff --git a/go/internal/website/search.go b/go/internal/website/search.go new file mode 100644 index 00000000000..a17e29b0d1c --- /dev/null +++ b/go/internal/website/search.go @@ -0,0 +1,19 @@ +package website + +import ( + "fmt" + "net/http" +) + +// handleSearchSuggestions handles auto-complete search suggestions querying Datastore models. +// Query parameters: +// - q: search query string / prefix +// - ecosystem: optional ecosystem filter +func (s *Server) handleSearchSuggestions(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + ecosystem := r.URL.Query().Get("ecosystem") + + // TODO: Perform prefix / search matching on Datastore vulnerability indices + w.Header().Set("Content-Type", "application/json") + http.Error(w, fmt.Sprintf(`{"error": "Search suggestions handler stub", "q": %q, "ecosystem": %q}`, q, ecosystem), http.StatusNotImplemented) +} diff --git a/go/internal/website/server.go b/go/internal/website/server.go new file mode 100644 index 00000000000..b3b70fdcd41 --- /dev/null +++ b/go/internal/website/server.go @@ -0,0 +1,168 @@ +// Package website provides HTTP handler logic for the OSV website. +package website + +import ( + "errors" + "io/fs" + "log/slog" + "net/http" + "os" + "time" + + "github.com/google/osv.dev/go/logger" +) + +const goVanityMetadata = `` + +// Config holds configuration options for the website server. +type Config struct { + StaticFS fs.FS + DocsFS fs.FS +} + +// Server handles website routing and HTTP requests. +type Server struct { + config Config + mux *http.ServeMux + handler http.Handler +} + +type responseLogger struct { + http.ResponseWriter + + statusCode int + bytesWritten int64 +} + +func (r *responseLogger) WriteHeader(code int) { + r.statusCode = code + r.ResponseWriter.WriteHeader(code) +} + +func (r *responseLogger) Write(b []byte) (int, error) { + if r.statusCode == 0 { + r.statusCode = http.StatusOK + } + n, err := r.ResponseWriter.Write(b) + r.bytesWritten += int64(n) + + return n, err +} + +// NewServer creates and initializes a new website Server. +// It returns an error if cfg.StaticFS or cfg.DocsFS is nil. +func NewServer(cfg Config) (*Server, error) { + if cfg.StaticFS == nil { + return nil, errors.New("StaticFS is required") + } + if cfg.DocsFS == nil { + return nil, errors.New("DocsFS is required") + } + + s := &Server{ + config: cfg, + mux: http.NewServeMux(), + } + s.registerRoutes() + + // Middlewares: Logging (if local/dev) -> ServeMux + var h http.Handler = s.mux + + // Skip HTTP access logging in Cloud Run production to avoid duplicating Cloud Run infrastructure logs. + if os.Getenv("K_SERVICE") == "" { + h = loggingMiddleware(h) + } + + s.handler = h + + return s, nil +} + +// ServeHTTP implements the http.Handler interface by delegating to the middleware chain. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.handler.ServeHTTP(w, r) +} + +// loggingMiddleware returns an http.Handler that logs HTTP requests. +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &responseLogger{ResponseWriter: w, statusCode: http.StatusOK} + + next.ServeHTTP(rw, r) + + logger.InfoContext(r.Context(), "HTTP Request", + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.Int("status", rw.statusCode), + slog.Duration("duration", time.Since(start)), + slog.Int64("bytes", rw.bytesWritten), + ) + }) +} + +func (s *Server) registerRoutes() { + // Health check + s.mux.HandleFunc("GET /healthz", s.handleHealthz) + + // Go vanity imports & root check + s.mux.HandleFunc("GET /{$}", s.handleRoot) + s.mux.HandleFunc("GET /bindings/go", s.handleGoBindingsVanity) + + // Simple redirects & static pages + s.mux.HandleFunc("GET /about", s.handleRedirect("https://google.github.io/osv.dev/faq")) + s.mux.HandleFunc("GET /faq", s.handleRedirect("https://google.github.io/osv.dev/faq")) + s.mux.HandleFunc("GET /docs", s.handleRedirect("https://google.github.io/osv.dev")) + s.mux.HandleFunc("GET /docs/", s.handleRedirect("https://google.github.io/osv.dev")) + s.mux.HandleFunc("GET /ecosystems", s.handleRedirect("https://storage.googleapis.com/osv-vulnerabilities/ecosystems.txt")) + + // Static documentation & assets + s.mux.HandleFunc("GET /docs/osv_service_v1.swagger.json", s.handleSwagger) + s.mux.HandleFunc("GET /public_keys/{filename...}", s.handlePublicKeys) + + // Serve static directory if StaticFS is configured + if s.config.StaticFS != nil { + s.mux.Handle("GET /static/", http.FileServer(http.FS(s.config.StaticFS))) + } + + // Static root assets + s.mux.HandleFunc("GET /favicon.ico", s.handleFavicon) + s.mux.HandleFunc("GET /robots.txt", s.handleRobots) + + // Vulnerability details & raw JSON + s.mux.HandleFunc("GET /vulnerability/{vuln_id}", s.handleVulnerabilityDetails) + s.mux.HandleFunc("GET /{potential_vuln_id}", s.handlePotentialVulnerability) + + // Blog routes (matching strict_slashes=False) + s.mux.HandleFunc("GET /blog", s.handleBlogIndex) + s.mux.HandleFunc("GET /blog/", s.handleBlogIndex) + s.mux.HandleFunc("GET /blog/index.xml", s.handleBlogRSS) + s.mux.HandleFunc("GET /blog/posts/{blog_name}", s.handleBlogPost) + s.mux.HandleFunc("GET /blog/posts/{blog_name}/", s.handleBlogPost) + s.mux.HandleFunc("GET /blog/posts/{blog_name}/{file_name}", s.handleBlogPostFile) + + // Vulnerability listing & search suggestions + s.mux.HandleFunc("GET /list", s.handleList) + s.mux.HandleFunc("GET /api/search_suggestions", s.handleSearchSuggestions) + + // Linter findings (matching strict_slashes=False) + s.mux.HandleFunc("GET /linter", s.handleLinterPage) + s.mux.HandleFunc("GET /linter/", s.handleLinterPage) + s.mux.HandleFunc("GET /linter-findings", s.handleLinterSources) + s.mux.HandleFunc("GET /linter-findings/", s.handleLinterSources) + s.mux.HandleFunc("GET /linter-findings/{source}", s.handleLinterFindings) + + // Triage workflow + s.mux.HandleFunc("GET /triage", s.handleTriagePage) + s.mux.HandleFunc("POST /triage/proxy", s.handleTriageProxy) + + // Google OAuth authentication + s.mux.HandleFunc("GET /login", s.handleLogin) + s.mux.HandleFunc("GET /auth/callback", s.handleAuthCallback) + s.mux.HandleFunc("GET /logout", s.handleLogout) +} + +func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} diff --git a/go/internal/website/server_test.go b/go/internal/website/server_test.go new file mode 100644 index 00000000000..1a9ed7b9951 --- /dev/null +++ b/go/internal/website/server_test.go @@ -0,0 +1,304 @@ +package website_test + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "github.com/google/osv.dev/go/internal/website" +) + +func newTestServer(t *testing.T, cfg website.Config) *website.Server { + t.Helper() + if cfg.StaticFS == nil { + cfg.StaticFS = fstest.MapFS{} + } + if cfg.DocsFS == nil { + cfg.DocsFS = fstest.MapFS{} + } + srv, err := website.NewServer(cfg) + if err != nil { + t.Fatalf("failed creating test server: %v", err) + } + + return srv +} + +func TestNewServer_NilFS(t *testing.T) { + t.Parallel() + + if _, err := website.NewServer(website.Config{}); err == nil { + t.Errorf("expected error when StaticFS and DocsFS are nil, got nil") + } + if _, err := website.NewServer(website.Config{StaticFS: fstest.MapFS{}}); err == nil { + t.Errorf("expected error when DocsFS is nil, got nil") + } + if _, err := website.NewServer(website.Config{DocsFS: fstest.MapFS{}}); err == nil { + t.Errorf("expected error when StaticFS is nil, got nil") + } +} + +func TestHealthz(t *testing.T) { + t.Parallel() + + srv := newTestServer(t, website.Config{}) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + if body := rec.Body.String(); body != "OK" { + t.Errorf("expected body 'OK', got %q", body) + } +} + +func TestGoVanityImports(t *testing.T) { + t.Parallel() + + srv := newTestServer(t, website.Config{}) + + t.Run("Root go-get=1", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/?go-get=1", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + expectedMeta := `` + if rec.Body.String() != expectedMeta { + t.Errorf("expected body %q, got %q", expectedMeta, rec.Body.String()) + } + }) + + t.Run("Bindings go-get=1", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/bindings/go?go-get=1", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + expectedMeta := `` + if rec.Body.String() != expectedMeta { + t.Errorf("expected body %q, got %q", expectedMeta, rec.Body.String()) + } + }) + + t.Run("Bindings redirect without go-get", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/bindings/go", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Errorf("expected status 302 Found, got %d", rec.Code) + } + expectedLoc := "https://pkg.go.dev/osv.dev/bindings/go" + if loc := rec.Header().Get("Location"); loc != expectedLoc { + t.Errorf("expected Location header %q, got %q", expectedLoc, loc) + } + }) +} + +func TestRedirects(t *testing.T) { + t.Parallel() + + srv := newTestServer(t, website.Config{}) + + tests := []struct { + path string + expectedLoc string + }{ + {"/about", "https://google.github.io/osv.dev/faq"}, + {"/faq", "https://google.github.io/osv.dev/faq"}, + {"/docs", "https://google.github.io/osv.dev"}, + {"/ecosystems", "https://storage.googleapis.com/osv-vulnerabilities/ecosystems.txt"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Errorf("expected status 302 Found for %s, got %d", tt.path, rec.Code) + } + if loc := rec.Header().Get("Location"); loc != tt.expectedLoc { + t.Errorf("expected Location %q for %s, got %q", tt.expectedLoc, tt.path, loc) + } + }) + } +} + +func TestPublicKeys(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + keysDir := filepath.Join(tmpDir, "public_keys") + if err := os.MkdirAll(keysDir, 0755); err != nil { + t.Fatalf("failed to create temp keys dir: %v", err) + } + + keyFile := filepath.Join(keysDir, "test.pub") + if err := os.WriteFile(keyFile, []byte("PUBLIC KEY DATA"), 0600); err != nil { + t.Fatalf("failed to write test key file: %v", err) + } + + srv := newTestServer(t, website.Config{StaticFS: os.DirFS(tmpDir)}) + + t.Run("Existing public key", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/public_keys/test.pub", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + if body := rec.Body.String(); body != "PUBLIC KEY DATA" { + t.Errorf("expected body 'PUBLIC KEY DATA', got %q", body) + } + }) + + t.Run("Non-existent key", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/public_keys/missing.pub", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("expected status 404 Not Found, got %d", rec.Code) + } + }) + + t.Run("Path traversal prevention", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/public_keys/../test.pub", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound && rec.Code != http.StatusTemporaryRedirect && rec.Code != http.StatusMovedPermanently { + t.Errorf("expected status 400, 404, 307, or 301, got %d", rec.Code) + } + }) +} + +func TestStaticFiles(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + imgDir := filepath.Join(tmpDir, "static", "img") + if err := os.MkdirAll(imgDir, 0755); err != nil { + t.Fatalf("failed to create static img dir: %v", err) + } + + if err := os.WriteFile(filepath.Join(tmpDir, "home.html"), []byte("Home"), 0600); err != nil { + t.Fatalf("failed to write home.html: %v", err) + } + if err := os.WriteFile(filepath.Join(imgDir, "favicon-32x32.png"), []byte("FAVICON"), 0600); err != nil { + t.Fatalf("failed to write favicon: %v", err) + } + + srv := newTestServer(t, website.Config{StaticFS: os.DirFS(tmpDir)}) + + t.Run("Root serves home.html", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + if rec.Body.String() != "Home" { + t.Errorf("expected body Home, got %q", rec.Body.String()) + } + }) + + t.Run("Favicon", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + if rec.Body.String() != "FAVICON" { + t.Errorf("expected body 'FAVICON', got %q", rec.Body.String()) + } + }) + + t.Run("Robots.txt", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/robots.txt", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + if !strings.HasPrefix(rec.Body.String(), "Sitemap: ") { + t.Errorf("expected Sitemap header in body, got %q", rec.Body.String()) + } + }) +} + +func TestEndpointRegistration(t *testing.T) { + t.Parallel() + + srv := newTestServer(t, website.Config{}) + + endpoints := []struct { + method string + path string + }{ + {http.MethodGet, "/blog"}, + {http.MethodGet, "/blog/"}, + {http.MethodGet, "/blog/index.xml"}, + {http.MethodGet, "/blog/posts/test-post"}, + {http.MethodGet, "/blog/posts/test-post/"}, + {http.MethodGet, "/blog/posts/test-post/image.png"}, + {http.MethodGet, "/vulnerability/GHSA-1234"}, + {http.MethodGet, "/GHSA-1234"}, + {http.MethodGet, "/vulnerability/GHSA-1234.json"}, + {http.MethodGet, "/GHSA-1234.json"}, + {http.MethodGet, "/list"}, + {http.MethodGet, "/api/search_suggestions"}, + {http.MethodGet, "/linter"}, + {http.MethodGet, "/linter/"}, + {http.MethodGet, "/linter-findings"}, + {http.MethodGet, "/linter-findings/"}, + {http.MethodGet, "/linter-findings/test-source"}, + {http.MethodGet, "/triage"}, + {http.MethodPost, "/triage/proxy"}, + {http.MethodGet, "/login"}, + {http.MethodGet, "/auth/callback"}, + {http.MethodGet, "/logout"}, + } + + for _, ep := range endpoints { + t.Run(ep.method+" "+ep.path, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(ep.method, ep.path, nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code == http.StatusNotFound { + t.Errorf("expected route %s %s to be registered, got 404 Not Found", ep.method, ep.path) + } + }) + } +} diff --git a/go/internal/website/static.go b/go/internal/website/static.go new file mode 100644 index 00000000000..e4c75e9d7f8 --- /dev/null +++ b/go/internal/website/static.go @@ -0,0 +1,67 @@ +package website + +import ( + "fmt" + "io/fs" + "net/http" +) + +func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("go-get") == "1" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(goVanityMetadata)) + + return + } + http.ServeFileFS(w, r, s.config.StaticFS, "home.html") +} + +func (s *Server) handleGoBindingsVanity(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("go-get") == "1" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(goVanityMetadata)) + + return + } + http.Redirect(w, r, "https://pkg.go.dev/osv.dev/bindings/go", http.StatusFound) +} + +func (s *Server) handleSwagger(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, s.config.DocsFS, "osv_service_v1.swagger.json") +} + +func (s *Server) handlePublicKeys(w http.ResponseWriter, r *http.Request) { + keyPath := "public_keys/" + r.PathValue("filename") + if !fs.ValidPath(keyPath) { + http.Error(w, "Invalid file path", http.StatusBadRequest) + + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + http.ServeFileFS(w, r, s.config.StaticFS, keyPath) +} + +func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, s.config.StaticFS, "static/img/favicon-32x32.png") +} + +func (s *Server) handleRobots(w http.ResponseWriter, r *http.Request) { + if _, err := fs.Stat(s.config.StaticFS, "robots.txt"); err == nil { + http.ServeFileFS(w, r, s.config.StaticFS, "robots.txt") + + return + } + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + sitemapURL := fmt.Sprintf("%s://%s/sitemap_index.xml", scheme, r.Host) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = fmt.Fprintf(w, "Sitemap: %s\n", sitemapURL) +} + +func (s *Server) handleRedirect(targetURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, targetURL, http.StatusFound) + } +} diff --git a/go/internal/website/triage.go b/go/internal/website/triage.go new file mode 100644 index 00000000000..ce8a0621e50 --- /dev/null +++ b/go/internal/website/triage.go @@ -0,0 +1,17 @@ +package website + +import ( + "net/http" +) + +// handleTriagePage handles serving the vulnerability triage UI page. +func (s *Server) handleTriagePage(w http.ResponseWriter, _ *http.Request) { + // TODO: Serve triage page / template + http.Error(w, "Triage UI page stub", http.StatusNotImplemented) +} + +// handleTriageProxy handles proxying triage workflow actions. +func (s *Server) handleTriageProxy(w http.ResponseWriter, _ *http.Request) { + // TODO: Proxy triage submission to backend service + http.Error(w, "Triage proxy handler stub", http.StatusNotImplemented) +} diff --git a/go/internal/website/vulnerability.go b/go/internal/website/vulnerability.go new file mode 100644 index 00000000000..51942abe865 --- /dev/null +++ b/go/internal/website/vulnerability.go @@ -0,0 +1,60 @@ +package website + +import ( + "fmt" + "net/http" + "strings" +) + +// handleVulnerabilityDetails handles rendering the vulnerability details page or raw JSON for /vulnerability/{vuln_id}. +func (s *Server) handleVulnerabilityDetails(w http.ResponseWriter, r *http.Request) { + vulnID := r.PathValue("vuln_id") + if vulnID == "" { + http.NotFound(w, r) + + return + } + if strings.HasSuffix(vulnID, ".json") { + s.handleVulnerabilityJSON(w, r) + + return + } + + // TODO: Hydrate vulnerability metadata from Datastore/GCS and render template + http.Error(w, fmt.Sprintf("Vulnerability details handler stub (vulnID=%q)", vulnID), http.StatusNotImplemented) +} + +// handlePotentialVulnerability handles requests for /{potential_vuln_id} (redirects or vulnerability pages). +func (s *Server) handlePotentialVulnerability(w http.ResponseWriter, r *http.Request) { + potentialID := r.PathValue("potential_vuln_id") + if potentialID == "" { + http.NotFound(w, r) + + return + } + if strings.HasSuffix(potentialID, ".json") { + s.handleVulnerabilityJSON(w, r) + + return + } + + // TODO: Validate ID, resolve canonical ID from Datastore, and render page or redirect + http.Error(w, fmt.Sprintf("Potential vulnerability handler stub (potentialID=%q)", potentialID), http.StatusNotImplemented) +} + +// handleVulnerabilityJSON handles redirecting /{potential_vuln_id}.json and /vulnerability/{potential_vuln_id}.json +// to https://api.osv.dev/v1/vulns/{canonical_id}. +func (s *Server) handleVulnerabilityJSON(w http.ResponseWriter, r *http.Request) { + potentialID := strings.TrimSuffix(r.PathValue("potential_vuln_id"), ".json") + if potentialID == "" { + potentialID = strings.TrimSuffix(r.PathValue("vuln_id"), ".json") + } + if potentialID == "" { + http.NotFound(w, r) + + return + } + + // TODO: Validate ID, resolve canonical ID from Datastore, and redirect to https://api.osv.dev/v1/vulns/{canonical_id} + http.Error(w, fmt.Sprintf("Vulnerability JSON redirector stub (id=%q)", potentialID), http.StatusNotImplemented) +} diff --git a/website b/website new file mode 120000 index 00000000000..6b866c7c0bb --- /dev/null +++ b/website @@ -0,0 +1 @@ +gcp/website \ No newline at end of file