Files
kandev/apps/backend/internal/agentctl/server/process/workspace_preview.go
T
2026-09-07 13:45:28 +01:00

341 lines
9.8 KiB
Go

package process
import (
"context"
"errors"
"fmt"
"mime"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
const (
// MaxWorkspacePreviewContentBytes bounds one current-buffer overlay. The
// shared agentctl type is the source of truth for every layer.
MaxWorkspacePreviewContentBytes = agentctltypes.MaxWorkspacePreviewContentBytes
maxWorkspacePreviewOverlays = 32
)
var (
ErrWorkspacePreviewContentTooLarge = errors.New("workspace preview content exceeds 5 MiB")
ErrWorkspacePreviewPathInvalid = errors.New("workspace preview path is invalid")
ErrWorkspacePreviewTypeUnsupported = errors.New("workspace preview only supports HTML files")
ErrWorkspacePreviewUnavailable = errors.New("workspace preview server is unavailable")
)
// WorkspacePreviewRequest is the current editor buffer to publish for a
// workspace preview. Repo is an optional repository-relative scope.
type WorkspacePreviewRequest struct {
Repo string
Path string
Content string
}
// WorkspacePreviewResponse identifies the ephemeral static server and the
// published entry document.
type WorkspacePreviewResponse struct {
Port int
Path string
Version uint64
}
type workspacePreviewOverlay struct {
content string
version uint64
published uint64
}
type workspacePreviewRoot struct {
path string
port int
server *http.Server
overlays map[string]workspacePreviewOverlay
}
// WorkspacePreviewManager owns loopback static servers and in-memory entry
// overlays for one agentctl instance.
type WorkspacePreviewManager struct {
mu sync.Mutex
logger *logger.Logger
roots map[string]*workspacePreviewRoot
sequence uint64
closed bool
}
// NewWorkspacePreviewManager creates a manager for one agentctl instance.
func NewWorkspacePreviewManager(log *logger.Logger) *WorkspacePreviewManager {
return &WorkspacePreviewManager{
logger: log.WithFields(zap.String("component", "workspace-preview")),
roots: make(map[string]*workspacePreviewRoot),
}
}
// Publish stores an entry-document overlay and starts or reuses the static
// server for its selected workspace root.
func (m *WorkspacePreviewManager) Publish(root string, req WorkspacePreviewRequest) (WorkspacePreviewResponse, error) {
if len([]byte(req.Content)) > MaxWorkspacePreviewContentBytes {
return WorkspacePreviewResponse{}, ErrWorkspacePreviewContentTooLarge
}
relPath, err := validateWorkspacePreviewEntryPath(req.Path)
if err != nil {
return WorkspacePreviewResponse{}, err
}
canonicalRoot, err := canonicalPreviewDirectory(root)
if err != nil {
return WorkspacePreviewResponse{}, fmt.Errorf("resolve workspace preview root: %w", err)
}
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return WorkspacePreviewResponse{}, ErrWorkspacePreviewUnavailable
}
previewRoot := m.roots[canonicalRoot]
if previewRoot == nil {
previewRoot, err = m.startRootLocked(canonicalRoot)
if err != nil {
return WorkspacePreviewResponse{}, err
}
m.roots[canonicalRoot] = previewRoot
}
previous := previewRoot.overlays[relPath]
version := previous.version + 1
m.sequence++
previewRoot.overlays[relPath] = workspacePreviewOverlay{
content: req.Content,
version: version,
published: m.sequence,
}
m.evictOldestOverlayLocked()
m.logger.Info("workspace preview published",
zap.String("root", canonicalRoot),
zap.String("path", relPath),
zap.Int("bytes", len([]byte(req.Content))),
zap.Uint64("version", version),
)
return WorkspacePreviewResponse{Port: previewRoot.port, Path: "/" + relPath, Version: version}, nil
}
func (m *WorkspacePreviewManager) startRootLocked(root string) (*workspacePreviewRoot, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("start workspace preview listener: %w", err)
}
previewRoot := &workspacePreviewRoot{
path: root,
port: listener.Addr().(*net.TCPAddr).Port,
overlays: make(map[string]workspacePreviewOverlay),
}
previewRoot.server = &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.serveRootRequest(previewRoot, w, r)
}),
}
go func() {
if serveErr := previewRoot.server.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
m.logger.Warn("workspace preview server stopped unexpectedly",
zap.String("root", root), zap.Int("port", previewRoot.port), zap.Error(serveErr))
}
}()
m.logger.Info("workspace preview server started",
zap.String("root", root), zap.Int("port", previewRoot.port))
return previewRoot, nil
}
func (m *WorkspacePreviewManager) evictOldestOverlayLocked() {
count := 0
var oldestRoot *workspacePreviewRoot
var oldestPath string
var oldestSequence uint64
for _, root := range m.roots {
for path, overlay := range root.overlays {
count++
if oldestRoot == nil || overlay.published < oldestSequence {
oldestRoot = root
oldestPath = path
oldestSequence = overlay.published
}
}
}
if count <= maxWorkspacePreviewOverlays || oldestRoot == nil {
return
}
delete(oldestRoot.overlays, oldestPath)
m.logger.Debug("workspace preview overlay evicted",
zap.String("root", oldestRoot.path), zap.String("path", oldestPath))
}
func (m *WorkspacePreviewManager) serveRootRequest(root *workspacePreviewRoot, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
relPath, err := workspacePreviewRequestPath(r.URL)
if err != nil {
http.NotFound(w, r)
return
}
m.mu.Lock()
overlay, hasOverlay := root.overlays[relPath]
m.mu.Unlock()
if hasOverlay {
serveWorkspacePreviewBytes(w, r, relPath, []byte(overlay.content))
return
}
filePath, err := resolveWorkspacePreviewFile(root.path, relPath)
if err != nil {
http.NotFound(w, r)
return
}
file, err := os.Open(filePath)
if err != nil {
http.NotFound(w, r)
return
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
http.NotFound(w, r)
return
}
setWorkspacePreviewContentType(w, relPath)
http.ServeContent(w, r, filepath.Base(filePath), info.ModTime(), file)
}
func serveWorkspacePreviewBytes(w http.ResponseWriter, r *http.Request, path string, content []byte) {
setWorkspacePreviewContentType(w, path)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(content)
}
func setWorkspacePreviewContentType(w http.ResponseWriter, path string) {
if contentType := mime.TypeByExtension(filepath.Ext(path)); contentType != "" {
w.Header().Set("Content-Type", contentType)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
}
func resolveWorkspacePreviewFile(canonicalRoot, relPath string) (string, error) {
candidate := filepath.Join(canonicalRoot, filepath.FromSlash(relPath))
resolved, err := filepath.EvalSymlinks(candidate)
if err != nil {
return "", err
}
if !previewPathWithinRoot(canonicalRoot, resolved) {
return "", ErrWorkspacePreviewPathInvalid
}
info, err := os.Stat(resolved)
if err != nil || !info.Mode().IsRegular() {
return "", os.ErrNotExist
}
return resolved, nil
}
func workspacePreviewRequestPath(parsed *url.URL) (string, error) {
escaped := parsed.EscapedPath()
decoded, err := url.PathUnescape(escaped)
if err != nil || !strings.HasPrefix(decoded, "/") {
return "", ErrWorkspacePreviewPathInvalid
}
relPath := strings.TrimPrefix(decoded, "/")
if !validWorkspacePreviewRelativePath(relPath) {
return "", ErrWorkspacePreviewPathInvalid
}
return relPath, nil
}
func validateWorkspacePreviewEntryPath(path string) (string, error) {
decodedPath, err := url.PathUnescape(path)
if err != nil {
return "", ErrWorkspacePreviewPathInvalid
}
path = decodedPath
if !validWorkspacePreviewRelativePath(path) {
return "", ErrWorkspacePreviewPathInvalid
}
ext := strings.ToLower(filepath.Ext(filepath.FromSlash(path)))
if ext != ".html" && ext != ".htm" {
return "", ErrWorkspacePreviewTypeUnsupported
}
return filepath.ToSlash(path), nil
}
func validWorkspacePreviewRelativePath(path string) bool {
if path == "" || strings.ContainsRune(path, '\x00') || filepath.IsAbs(filepath.FromSlash(path)) {
return false
}
for _, segment := range strings.Split(filepath.ToSlash(path), "/") {
if segment == "" || segment == "." || segment == ".." {
return false
}
}
return true
}
func canonicalPreviewDirectory(path string) (string, error) {
clean := filepath.Clean(path)
resolved, err := filepath.EvalSymlinks(clean)
if err != nil {
return "", err
}
info, err := os.Stat(resolved)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("preview root is not a directory")
}
return filepath.Clean(resolved), nil
}
func previewPathWithinRoot(root, candidate string) bool {
rel, err := filepath.Rel(root, candidate)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
}
// Close stops every preview server and releases its loopback listener.
func (m *WorkspacePreviewManager) Close(ctx context.Context) error {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return nil
}
m.closed = true
servers := make([]*http.Server, 0, len(m.roots))
for _, root := range m.roots {
servers = append(servers, root.server)
}
m.roots = make(map[string]*workspacePreviewRoot)
m.mu.Unlock()
var errs []error
for _, server := range servers {
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
errs = append(errs, err)
_ = server.Close()
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}