Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions backend/internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ func Run() error {
return fmt.Errorf("wire session service: %w", err)
}
lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log)
previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx)
agentSvc := agentsvc.New()
go func() {
if _, err := agentSvc.Refresh(ctx); err != nil {
Expand Down Expand Up @@ -169,13 +168,13 @@ func Run() error {
})
if err != nil {
stop()
<-previewDone
lcStack.Stop()
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
log.Error("cdc pipeline shutdown", "err", cdcErr)
}
return err
}
previewDone := preview.NewPoller(store, sessionSvc, "http://"+srv.Addr().String(), preview.PollerConfig{Logger: log}).Start(ctx)

// Late-bind: the LAN listener shares the exact loopback router instance so
// the LAN surface and loopback surface never drift apart.
Expand Down
12 changes: 12 additions & 0 deletions backend/internal/httpd/addr_in_use_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build !windows

package httpd

import (
"errors"
"syscall"
)

func isAddrInUse(err error) bool {
return errors.Is(err, syscall.EADDRINUSE)
}
13 changes: 13 additions & 0 deletions backend/internal/httpd/addr_in_use_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build windows

package httpd

import (
"errors"

"golang.org/x/sys/windows"
)

func isAddrInUse(err error) bool {
return errors.Is(err, windows.WSAEADDRINUSE)
}
136 changes: 103 additions & 33 deletions backend/internal/httpd/controllers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package controllers
import (
"context"
"errors"
"io"
"net/http"
"net/url"
"os"
Expand Down Expand Up @@ -173,7 +174,11 @@ func (c *SessionsController) preview(w http.ResponseWriter, r *http.Request) {
res := SessionPreviewResponse{SessionID: sessionID(r)}
if ok {
res.Entry = entry
res.PreviewURL = previewFileURL(r, sessionID(r), entry)
res.PreviewURL, err = previewFileURL(r, sessionID(r), entry)
if err != nil {
writePreviewResolveError(w, r, err)
return
}
}
envelope.WriteJSON(w, http.StatusOK, res)
}
Expand All @@ -188,35 +193,101 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request)
envelope.WriteError(w, r, err)
return
}
file, ok := confinedPreviewPath(sess.Metadata.WorkspacePath, chi.URLParam(r, "*"))
c.serveWorkspacePreviewFile(w, r, sess.Metadata.WorkspacePath, chi.URLParam(r, "*"))
}

// PreviewOrigin serves a workspace preview from its isolated *.localhost
// origin. It returns false when the request host is not a preview origin so the
// daemon router can continue handling its normal API and control surfaces.
//
// The selected entry's directory is mounted at the origin root. For example,
// dist/index.html is reachable at both /dist/ (the persisted URL) and /, while
// /assets/app.css maps to dist/assets/app.css. This mirrors a production static
// server and fixes root-relative URLs without rewriting user-generated files.
func (c *SessionsController) PreviewOrigin(w http.ResponseWriter, r *http.Request) bool {
id, ok := previewutil.SessionIDFromHost(r.Host)
if !ok {
return false
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
Comment thread
aprv10 marked this conversation as resolved.
w.Header().Set("Allow", "GET, HEAD")
envelope.WriteAPIError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED",
r.Method+" not allowed on preview origin", nil)
return true
}
if c.Svc == nil {
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_NOT_FOUND", "Preview not found", nil)
return true
}
sess, err := c.Svc.Get(r.Context(), id)
if err != nil {
envelope.WriteError(w, r, err)
return true
}
entry, ok := previewOriginEntry(sess)
if !ok {
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "NO_PREVIEW_ENTRY", "No preview entry point found in session workspace", nil)
return true
}
asset := previewOriginAssetPath(entry, r.URL.Path)
c.serveWorkspacePreviewFile(w, r, sess.Metadata.WorkspacePath, asset)
return true
}

func previewOriginEntry(sess domain.Session) (string, bool) {
if entry, ok := previewutil.StoredWorkspaceEntry(sess.Metadata.PreviewURL, sess.ID); ok {
if stored, exists := previewutil.EntryAtPath(sess.Metadata.WorkspacePath, entry); exists {
return stored.Path, true
}
}
return discoverPreviewEntry(sess.Metadata.WorkspacePath)
}

func previewOriginAssetPath(entry, requestPath string) string {
requested := strings.TrimPrefix(path.Clean("/"+requestPath), "/")
if requested == "" || requested == "." {
return entry
}
root := path.Dir(entry)
if root == "." {
return requested
}
if requested == root {
return entry
}
requested = strings.TrimPrefix(requested, root+"/")
return path.Join(root, requested)
}

// serveWorkspacePreviewFile is the single serving path for both the legacy API
// route and isolated preview origins. OpenRoot keeps symlink traversal and the
// subsequent read on the same workspace-confined file handle.
func (c *SessionsController) serveWorkspacePreviewFile(w http.ResponseWriter, r *http.Request, workspacePath, assetPath string) {
file, info, clean, err := previewutil.OpenWorkspaceFile(workspacePath, assetPath)
if err != nil {
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
return
}
if previewutil.IsMarkdownPath(file) {
c.servePreviewMarkdown(w, r, file)
defer func() { _ = file.Close() }()
if !previewutil.IsMarkdownPath(clean) {
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
return
}
http.ServeFile(w, r, file)
}

// servePreviewMarkdown renders a workspace Markdown file to a self-contained
// HTML document so the browser panel displays formatted content instead of raw
// source.
func (c *SessionsController) servePreviewMarkdown(w http.ResponseWriter, r *http.Request, file string) {
source, err := os.ReadFile(file)
source, err := io.ReadAll(file)
if err != nil {
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
return
}
rendered, err := previewutil.RenderMarkdown(source, filepath.Base(file))
rendered, err := previewutil.RenderMarkdown(source, filepath.Base(clean))
if err != nil {
envelope.WriteError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")

_, _ = w.Write(rendered) //nolint:gosec // G705: preview content is workspace-local and agent-trusted
if r.Method != http.MethodHead {
_, _ = w.Write(rendered) //nolint:gosec // G705: preview content is workspace-local and agent-trusted
}
}

func (c *SessionsController) listWorkspaceFiles(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -284,7 +355,11 @@ func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request)
previewURL := strings.TrimSpace(in.URL)
if previewURL == "" {
if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok {
previewURL = previewFileURL(r, sessionID(r), entry)
previewURL, err = previewFileURL(r, sessionID(r), entry)
if err != nil {
writePreviewResolveError(w, r, err)
return
}
} else if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" {
var resolveErr error
previewURL, resolveErr = resolvePreviewTarget(r, sessionID(r), sess.Metadata.WorkspacePath, existing)
Expand Down Expand Up @@ -665,30 +740,25 @@ func discoverPreviewEntry(workspacePath string) (string, bool) {
// that already looks like a URL (an http(s)/file scheme, or a host:port dev
// server) and for paths that escape the workspace or do not point at a file, so
// the caller keeps those targets verbatim.
func resolveLocalPreview(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, bool) {
raw = strings.TrimSpace(raw)
func resolveLocalPreview(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, bool, error) {
if raw == "" || hasURLScheme(raw) {
return "", false
return "", false, nil
}
file, ok := confinedPreviewPath(workspacePath, raw)
entry, ok := previewutil.EntryAtPath(workspacePath, raw)
if !ok {
return "", false
}
info, err := os.Stat(file)
if err != nil || info.IsDir() {
return "", false
return "", false, nil
}
entry := strings.TrimPrefix(path.Clean("/"+raw), "/")
return previewFileURL(r, id, entry), true
resolved, err := previewFileURL(r, id, entry.Path)
return resolved, true, err
}

func resolvePreviewTarget(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, error) {
raw = strings.TrimSpace(raw)
if isAbsolutePreviewPath(raw) {
return absolutePreviewFileURL(raw)
}
if resolved, ok := resolveLocalPreview(r, id, workspacePath, raw); ok {
return resolved, nil
if resolved, ok, err := resolveLocalPreview(r, id, workspacePath, raw); ok || err != nil {
return resolved, err
}
return raw, nil
}
Expand Down Expand Up @@ -722,6 +792,10 @@ func writePreviewResolveError(w http.ResponseWriter, r *http.Request, err error)
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
return
}
if errors.Is(err, previewutil.ErrPreviewHostUnsupported) {
envelope.WriteAPIError(w, r, http.StatusUnprocessableEntity, "unprocessable", "PREVIEW_SESSION_ID_UNSUPPORTED", "Session ID is too long for an isolated preview hostname", nil)
return
}
envelope.WriteError(w, r, err)
}

Expand All @@ -743,11 +817,7 @@ func hasURLScheme(raw string) bool {
return false
}

func confinedPreviewPath(workspacePath, assetPath string) (string, bool) {
return previewutil.ConfinedPath(workspacePath, assetPath)
}

func previewFileURL(r *http.Request, id domain.SessionID, entry string) string {
func previewFileURL(r *http.Request, id domain.SessionID, entry string) (string, error) {
return previewutil.FileURL("http://"+r.Host, id, entry)
}

Expand Down
Loading
Loading