Files
CodeNavigator/main.go
T
Andy Dixon 26800b9b66 Scope jobs and snapshots to a browser session
An HttpOnly, SameSite=Lax session cookie is issued when a job is
created. Job and snapshot routes return 404 to any other session, so
indexed source is only readable by the browser that loaded it.
2026-02-15 10:53:01 +00:00

685 lines
19 KiB
Go

package main
import (
"bufio"
"bytes"
"crypto/rand"
"embed"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
)
//go:embed web
var webFiles embed.FS
type Job struct {
ID string `json:"id"`
SourceKind string `json:"sourceKind"`
Name string `json:"name"`
Phase string `json:"phase"`
Completed int `json:"completed"`
Total int `json:"total"`
Message string `json:"message"`
SnapshotID *string `json:"snapshotId"`
Error *string `json:"error"`
Warnings []string `json:"warnings"`
root string
owner string // session that created the job
}
type Server struct {
jobsMu sync.Mutex
jobs map[string]*Job
snapshotsMu sync.Mutex
snapshots map[string]*Snapshot
workspace string // parent of every job directory; only paths under it are ever removed
cors string
static http.Handler
}
func NewServer(workspace string) *Server {
cors := os.Getenv("CORS_ORIGIN")
if cors == "" || strings.ContainsAny(cors, "\r\n") {
cors = "*"
}
web, _ := fs.Sub(webFiles, "web")
return &Server{
jobs: map[string]*Job{},
snapshots: map[string]*Snapshot{},
workspace: workspace,
cors: cors,
static: http.FileServerFS(web),
}
}
func main() {
port := 4177
if p, err := strconv.Atoi(os.Getenv("PORT")); err == nil {
port = p
}
flag.IntVar(&port, "port", port, "listening port (default $PORT or 4177)")
open := flag.Bool("open", false, "open the viewer in a browser")
flag.Parse()
server := &http.Server{
Addr: fmt.Sprintf("0.0.0.0:%d", port),
Handler: NewServer(filepath.Join(os.TempDir(), "codenavigator")),
ReadHeaderTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
}
log.Printf("CodeNavigator listening on http://127.0.0.1:%d", port)
if *open {
_ = exec.Command("xdg-open", fmt.Sprintf("http://127.0.0.1:%d", port)).Start()
}
log.Fatal(server.ListenAndServe())
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Cache-Control", "no-store")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Access-Control-Allow-Origin", s.cors)
h.Set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, ngrok-skip-browser-warning")
h.Set("Access-Control-Max-Age", "86400")
path := r.URL.Path
switch {
case r.Method == http.MethodOptions:
w.WriteHeader(http.StatusNoContent)
case path == "/api/health" && r.Method == http.MethodGet:
writeJSON(w, 200, map[string]any{"ok": true, "version": "0.1.0"})
case path == "/api/jobs" && r.Method == http.MethodPost:
s.createJob(w, r)
case strings.HasPrefix(path, "/api/jobs/"):
s.jobRoute(w, r, strings.Split(strings.TrimPrefix(path, "/api/jobs/"), "/"))
case strings.HasPrefix(path, "/api/snapshots/"):
s.snapshotRoute(w, r, strings.Split(strings.TrimPrefix(path, "/api/snapshots/"), "/"))
case !strings.HasPrefix(path, "/api/") && (r.Method == http.MethodGet || r.Method == http.MethodHead):
s.static.ServeHTTP(w, r)
default:
writeError(w, 404, "Not found")
}
}
func (s *Server) createJob(w http.ResponseWriter, r *http.Request) {
var request struct {
Kind string `json:"kind"`
Name *string `json:"name"`
URL *string `json:"url"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil || request.Kind == "" {
writeError(w, 400, "Invalid job request")
return
}
owner := sessionID(w, r)
url := ""
if request.Kind == "github" {
if request.URL == nil {
writeError(w, 400, "Missing GitHub URL")
return
}
if url = cleanGithubURL(*request.URL); url == "" {
writeError(w, 400, "Use a public github.com owner/repository URL")
return
}
}
id := uniqueID("job")
root := filepath.Join(s.workspace, id)
if err := os.MkdirAll(root, 0o755); err != nil {
writeError(w, 500, err.Error())
return
}
job := &Job{ID: id, SourceKind: request.Kind, Name: "Local codebase", Phase: "receiving", Message: "Waiting for files…", Warnings: []string{}, root: root, owner: owner}
if request.Name != nil {
job.Name = *request.Name
}
if request.Kind == "github" {
job.Phase, job.Message = "cloning", "Cloning repository…"
}
s.jobsMu.Lock()
s.jobs[id] = job
s.jobsMu.Unlock()
if request.Kind == "github" {
go func() {
checkout := filepath.Join(root, "repo")
output, err := exec.Command("git", "clone", "--depth=1", "--single-branch", "--quiet", "--", url, checkout).CombinedOutput()
if err != nil {
s.failJob(id, fmt.Sprintf("git clone failed: %v %s", err, strings.TrimSpace(string(output))))
s.removeWorkspace(root)
return
}
s.indexJob(id, checkout)
}()
}
writeJSON(w, 201, map[string]string{"jobId": id})
}
func (s *Server) jobRoute(w http.ResponseWriter, r *http.Request, parts []string) {
id, action := parts[0], ""
if len(parts) > 1 {
action = parts[1]
}
if job, ok := s.jobSnapshot(id); !ok || job.owner != sessionID(nil, r) {
writeError(w, 404, "Unknown job")
return
}
switch {
case r.Method == http.MethodGet && action == "":
if job, ok := s.jobSnapshot(id); ok {
writeJSON(w, 200, job)
} else {
writeError(w, 404, "Unknown job")
}
case r.Method == http.MethodGet && action == "events":
s.streamJobEvents(w, r, id)
case r.Method == http.MethodPost && action == "files":
relative, ok := safeRelativePath(r.URL.Query().Get("path"))
if !ok {
writeError(w, 400, "Unsafe file path")
return
}
root, ok := s.localJobRoot(id)
if !ok {
writeError(w, 404, "Unknown local job")
return
}
output := filepath.Join(root, "files", relative)
if err := writeUpload(output, http.MaxBytesReader(w, r.Body, 64<<20)); err != nil {
writeError(w, 400, "Could not store file")
return
}
s.jobsMu.Lock()
if job := s.jobs[id]; job != nil {
job.Completed++
job.Total = job.Completed
job.Message = fmt.Sprintf("Received %d files", job.Completed)
}
s.jobsMu.Unlock()
writeJSON(w, 200, map[string]bool{"ok": true})
case r.Method == http.MethodPost && action == "commit":
s.jobsMu.Lock()
job := s.jobs[id]
if job == nil || job.SourceKind != "local" {
s.jobsMu.Unlock()
writeError(w, 404, "Unknown local job")
return
}
job.Phase, job.Message = "scanning", "Scanning files…"
root := filepath.Join(job.root, "files")
s.jobsMu.Unlock()
go s.indexJob(id, root)
writeJSON(w, 202, map[string]bool{"ok": true})
default:
writeError(w, 404, "Unknown job route")
}
}
func writeUpload(output string, body io.Reader) error {
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
return err
}
file, err := os.Create(output)
if err != nil {
return err
}
_, err = io.Copy(file, body)
return errors.Join(err, file.Close())
}
func (s *Server) snapshotRoute(w http.ResponseWriter, r *http.Request, parts []string) {
part := func(i int) string {
if i < len(parts) {
return parts[i]
}
return ""
}
id, action := part(0), part(1)
s.snapshotsMu.Lock()
snapshot := s.snapshots[id]
if snapshot == nil || snapshot.Owner != sessionID(nil, r) {
s.snapshotsMu.Unlock()
writeError(w, 404, "Unknown snapshot")
return
}
switch action {
case "":
body := map[string]any{
"id": snapshot.ID, "name": snapshot.Name, "source": snapshot.Source, "fileCount": len(snapshot.Files),
"totalLines": snapshot.TotalLines, "definitions": snapshot.Definitions, "references": snapshot.References,
"edges": len(snapshot.Edges), "warnings": snapshot.Warnings,
}
s.snapshotsMu.Unlock()
writeJSON(w, 200, body)
case "scene":
body := sceneJSON(snapshot)
s.snapshotsMu.Unlock()
if sendJSON(w, 200, body) == nil {
// The response owned the bytes; release the duplicate previews.
s.snapshotsMu.Lock()
for i := range snapshot.Files {
snapshot.Files[i].Preview = ""
}
s.snapshotsMu.Unlock()
}
case "search":
hits := search(snapshot, r.URL.Query().Get("q"))
s.snapshotsMu.Unlock()
writeJSON(w, 200, hits)
case "entities":
entityID, err := strconv.ParseUint(part(2), 10, 32)
if err != nil || entityID == 0 || int(entityID) > len(snapshot.Files) {
s.snapshotsMu.Unlock()
writeError(w, 404, "Unknown entity")
return
}
file := &snapshot.Files[entityID-1]
switch part(3) {
case "":
body := marshal(file)
s.snapshotsMu.Unlock()
sendJSON(w, 200, body)
case "source":
s.serveSource(w, r, snapshot, file) // unlocks
default:
s.snapshotsMu.Unlock()
writeError(w, 404, "Unknown entity route")
}
default:
s.snapshotsMu.Unlock()
writeError(w, 404, "Unknown snapshot route")
}
}
// serveSource is called with snapshotsMu held and releases it before writing.
func (s *Server) serveSource(w http.ResponseWriter, r *http.Request, snapshot *Snapshot, file *FileRecord) {
query := r.URL.Query()
start, err := strconv.Atoi(query.Get("start"))
if err != nil || start < 0 {
start = 0
}
start = min(start, file.Lines)
limit, err := strconv.Atoi(query.Get("limit"))
if err != nil || limit < 0 {
limit = 600
}
limit = min(max(limit, 1), 4096)
fileID, totalLines := file.ID, file.Lines
relative, ok := safeRelativePath(file.Path)
if !ok {
s.snapshotsMu.Unlock()
writeError(w, 400, "Unsafe file path")
return
}
sourcePath := filepath.Join(snapshot.Root, relative)
lines, err := readLines(sourcePath, start, limit)
s.snapshotsMu.Unlock()
if err != nil {
writeError(w, 404, "Source unavailable")
return
}
body := marshal(map[string]any{"start": start, "lines": lines, "totalLines": totalLines})
if sendJSON(w, 200, body) != nil {
return
}
s.snapshotsMu.Lock()
defer s.snapshotsMu.Unlock()
if snapshot.released[fileID] {
return
}
ranges, complete := recordDelivery(snapshot.delivered[fileID], start, start+len(lines), totalLines)
snapshot.delivered[fileID] = ranges
if complete {
snapshot.released[fileID] = true
os.Remove(sourcePath)
if len(snapshot.released) == len(snapshot.Files) {
s.removeWorkspace(snapshot.Root)
}
}
}
// readLines returns up to limit lines after skipping start, stopping at the first
// invalid UTF-8 line like Rust's BufRead::lines.
func readLines(path string, start, limit int) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 64*1024), 9*1024*1024)
lines := []string{}
for index := 0; len(lines) < limit && scanner.Scan(); index++ {
if index < start {
continue
}
if !utf8.Valid(scanner.Bytes()) {
break
}
lines = append(lines, scanner.Text())
}
return lines, nil
}
type sceneFile struct {
ID uint32 `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
Directory string `json:"directory"`
Extension string `json:"extension"`
Language string `json:"language"`
Layer string `json:"layer"`
Lines int `json:"lines"`
Bytes int `json:"bytes"`
Complexity int `json:"complexity"`
Preview string `json:"preview"`
SymbolCount int `json:"symbolCount"`
}
func sceneJSON(snapshot *Snapshot) []byte {
files := make([]sceneFile, len(snapshot.Files))
for i, f := range snapshot.Files {
files[i] = sceneFile{f.ID, f.Path, f.Name, f.Directory, f.Extension, f.Language, f.Layer, f.Lines, f.Bytes, f.Complexity, f.Preview, len(f.Symbols)}
}
return marshal(struct {
ID string `json:"id"`
Name string `json:"name"`
Source string `json:"source"`
Files []sceneFile `json:"files"`
Edges []Edge `json:"edges"`
TotalLines int `json:"totalLines"`
Definitions int `json:"definitions"`
References int `json:"references"`
}{snapshot.ID, snapshot.Name, snapshot.Source, files, snapshot.Edges, snapshot.TotalLines, snapshot.Definitions, snapshot.References})
}
type searchHit struct {
EntityID uint32 `json:"entityId"`
Path string `json:"path"`
Name string `json:"name"`
Line int `json:"line"`
Kind string `json:"kind"`
Preview string `json:"preview"`
}
func search(snapshot *Snapshot, query string) []searchHit {
const maxHits = 180
needle := strings.ToLower(query)
hits := []searchHit{}
if len(needle) < 2 {
return hits
}
for _, file := range snapshot.Files {
if len(hits) >= maxHits {
break
}
if strings.Contains(strings.ToLower(file.Path), needle) {
firstLine, _, _ := strings.Cut(file.Preview, "\n")
hits = append(hits, searchHit{file.ID, file.Path, file.Name, 1, "file", strings.TrimSuffix(firstLine, "\r")})
}
for _, symbol := range file.Symbols {
if len(hits) >= maxHits {
break
}
if strings.Contains(strings.ToLower(symbol.Name), needle) {
hits = append(hits, searchHit{file.ID, file.Path, symbol.Name, symbol.Line, "definition", symbol.Signature})
}
}
}
return hits
}
func (s *Server) streamJobEvents(w http.ResponseWriter, r *http.Request, id string) {
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
if flusher != nil {
flusher.Flush()
}
last := ""
ticker := time.NewTicker(150 * time.Millisecond)
defer ticker.Stop()
for range 1200 {
job, ok := s.jobSnapshot(id)
if !ok {
return
}
encoded := marshal(job)
if string(encoded) != last {
if _, err := fmt.Fprintf(w, "event: progress\ndata: %s\n\n", encoded); err != nil {
return
}
if flusher != nil {
flusher.Flush()
}
last = string(encoded)
}
if job.SnapshotID != nil || job.Error != nil {
return
}
select {
case <-r.Context().Done():
return
case <-ticker.C:
}
}
}
func (s *Server) jobSnapshot(id string) (Job, bool) {
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
job := s.jobs[id]
if job == nil {
return Job{}, false
}
return *job, true
}
func (s *Server) localJobRoot(id string) (string, bool) {
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
job := s.jobs[id]
if job == nil || job.SourceKind != "local" {
return "", false
}
return job.root, true
}
func (s *Server) indexJob(id, root string) {
s.updateJob(id, "scanning", 0, 0, "Discovering source files…")
snapshot, err := indexDirectory(root, func(phase string, completed, total int, message string) {
s.updateJob(id, phase, completed, total, message)
})
if err != nil {
s.removeWorkspace(root)
s.failJob(id, err.Error())
return
}
s.jobsMu.Lock()
if job := s.jobs[id]; job != nil {
snapshot.Name, snapshot.Source, snapshot.Owner = job.Name, job.SourceKind, job.owner
}
s.jobsMu.Unlock()
snapshot.ID = uniqueID("snapshot")
// Only the latest snapshot is kept; older workspaces are deleted.
s.snapshotsMu.Lock()
var oldRoots []string
for _, old := range s.snapshots {
oldRoots = append(oldRoots, old.Root)
}
clear(s.snapshots)
s.snapshots[snapshot.ID] = snapshot
s.snapshotsMu.Unlock()
for _, old := range oldRoots {
if old != root {
s.removeWorkspace(old)
}
}
s.jobsMu.Lock()
if job := s.jobs[id]; job != nil {
job.Phase, job.Message = "ready", "Landscape ready"
job.SnapshotID = &snapshot.ID
job.Completed = max(job.Total, job.Completed)
}
s.jobsMu.Unlock()
}
func (s *Server) updateJob(id, phase string, completed, total int, message string) {
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
if job := s.jobs[id]; job != nil {
job.Phase, job.Completed, job.Total, job.Message = phase, completed, total, message
}
}
func (s *Server) failJob(id, message string) {
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
if job := s.jobs[id]; job != nil {
job.Phase, job.Message, job.Error = "error", "Indexing failed", &message
}
}
// removeWorkspace deletes a checkout and its now-empty job directory, never leaving the workspace.
func (s *Server) removeWorkspace(root string) {
if !strings.HasPrefix(root, s.workspace+string(filepath.Separator)) {
return
}
os.RemoveAll(root)
if parent := filepath.Dir(root); parent != s.workspace && strings.HasPrefix(parent, s.workspace) {
os.Remove(parent)
}
}
// recordDelivery merges [start,end) into ranges and reports whether [0,total) is fully covered.
func recordDelivery(ranges [][2]int, start, end, total int) ([][2]int, bool) {
if end > start {
ranges = append(ranges, [2]int{start, min(end, total)})
slices.SortFunc(ranges, func(a, b [2]int) int { return a[0] - b[0] })
merged := ranges[:1]
for _, r := range ranges[1:] {
if last := &merged[len(merged)-1]; r[0] <= last[1] {
last[1] = max(last[1], r[1])
} else {
merged = append(merged, r)
}
}
ranges = merged
}
return ranges, total == 0 || (len(ranges) > 0 && ranges[0][0] == 0 && ranges[0][1] >= total)
}
// cleanGithubURL returns the normalised https://github.com/owner/repo URL, or "" if invalid.
func cleanGithubURL(value string) string {
clean := strings.TrimSuffix(strings.TrimRight(strings.TrimSpace(value), "/"), ".git")
rest, ok := strings.CutPrefix(clean, "https://github.com/")
if !ok {
return ""
}
owner, repo, ok := strings.Cut(rest, "/")
if !ok || !slug(owner) || !slug(repo) {
return ""
}
return clean
}
func slug(s string) bool {
return s != "" && strings.Trim(s, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.") == ""
}
func safeRelativePath(value string) (string, bool) {
if value == "" || strings.HasPrefix(value, "/") || strings.HasPrefix(value, "./") || value == "." {
return "", false
}
var clean []string
for part := range strings.SplitSeq(value, "/") {
switch part {
case "", ".":
case "..":
return "", false
default:
clean = append(clean, part)
}
}
if len(clean) == 0 {
return "", false
}
return filepath.Join(clean...), true
}
const sessionCookie = "codenav_session"
// sessionID returns the caller's session, issuing a cookie when w is non-nil and none exists.
// Jobs and snapshots are only visible to the session that created them. Without w a
// missing session returns "", which never matches an owner.
func sessionID(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(sessionCookie); err == nil && len(cookie.Value) >= 26 && len(cookie.Value) <= 64 {
return cookie.Value
}
if w == nil {
return ""
}
id := rand.Text()
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: id,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
})
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: id}) // later lookups in this request see it
return id
}
// uniqueID is unguessable: IDs are the capability that grants access to a job or snapshot.
func uniqueID(prefix string) string {
return prefix + "-" + rand.Text()
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
sendJSON(w, status, marshal(value))
}
// marshal skips HTML escaping: source previews are full of < > & and the payloads are large.
func marshal(value any) []byte {
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if encoder.Encode(value) != nil {
return []byte(`{"error":"serialization failed"}`)
}
return bytes.TrimSuffix(buf.Bytes(), []byte("\n"))
}
func sendJSON(w http.ResponseWriter, status int, body []byte) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(status)
_, err := w.Write(body)
return err
}