diff --git a/go.mod b/go.mod index e483728..cb3b495 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,10 @@ module claude-web-chat -go 1.23.6 +go 1.24.0 require ( + github.com/creack/pty v1.1.24 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/crypto v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index 79d4d52..3c62b7c 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= diff --git a/main.go b/main.go index 120ced2..addeacf 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,7 @@ var ( cfg *Config templates *TemplateRenderer sessionMgr *SessionManager - chatMgr *ChatSessionManager + ptyMgr *PTYSessionManager ) func main() { @@ -29,10 +29,10 @@ func main() { } sessionMgr = NewSessionManager(cfg.SessionSecret) - chatMgr = NewChatSessionManager() - defer chatMgr.Stop() + ptyMgr = NewPTYSessionManager() + defer ptyMgr.Stop() - wsHandler := NewWSHandler(chatMgr) + wsHandler := NewTerminalHandler(ptyMgr) mux := http.NewServeMux() @@ -49,6 +49,8 @@ func main() { mux.Handle("GET /chat/{project}", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChat))) mux.Handle("GET /ws", AuthMiddleware(sessionMgr, wsHandler)) mux.Handle("GET /api/file", AuthMiddleware(sessionMgr, http.HandlerFunc(handleFileAPI))) + mux.Handle("GET /change-password", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChangePasswordPage))) + mux.Handle("POST /change-password", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChangePassword))) // Root redirect (exact match only) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { @@ -75,7 +77,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { username := r.FormValue("username") password := r.FormValue("password") - if username != cfg.Username || password != cfg.Password { + if username != cfg.Username || !cfg.CheckPassword(password) { w.WriteHeader(http.StatusUnauthorized) templates.Render(w, "login.html", map[string]string{"Error": "Pogrešno korisničko ime ili lozinka"}) return @@ -118,20 +120,57 @@ func handleChat(w http.ResponseWriter, r *http.Request) { projectDir := filepath.Join(cfg.ProjectsPath, project) - files, err := ListMarkdownFiles(projectDir) - if err != nil { - log.Printf("ListMarkdownFiles error: %v", err) - files = nil - } - data := map[string]any{ "Project": project, "ProjectDir": projectDir, - "Files": files, } templates.Render(w, "chat.html", data) } +func handleChangePasswordPage(w http.ResponseWriter, r *http.Request) { + templates.Render(w, "change-password.html", map[string]string{"Error": "", "Success": ""}) +} + +func handleChangePassword(w http.ResponseWriter, r *http.Request) { + currentPassword := r.FormValue("current_password") + newPassword := r.FormValue("new_password") + confirmPassword := r.FormValue("confirm_password") + + data := map[string]string{"Error": "", "Success": ""} + + if !cfg.CheckPassword(currentPassword) { + data["Error"] = "Pogrešna trenutna lozinka" + w.WriteHeader(http.StatusBadRequest) + templates.Render(w, "change-password.html", data) + return + } + + if len(newPassword) < 6 { + data["Error"] = "Nova lozinka mora imati najmanje 6 karaktera" + w.WriteHeader(http.StatusBadRequest) + templates.Render(w, "change-password.html", data) + return + } + + if newPassword != confirmPassword { + data["Error"] = "Nova lozinka i potvrda se ne poklapaju" + w.WriteHeader(http.StatusBadRequest) + templates.Render(w, "change-password.html", data) + return + } + + if err := cfg.SetPassword(newPassword); err != nil { + log.Printf("SetPassword error: %v", err) + data["Error"] = "Greška pri čuvanju lozinke" + w.WriteHeader(http.StatusInternalServerError) + templates.Render(w, "change-password.html", data) + return + } + + data["Success"] = "Lozinka je uspešno promenjena" + templates.Render(w, "change-password.html", data) +} + func handleFileAPI(w http.ResponseWriter, r *http.Request) { project := r.URL.Query().Get("project") relPath := r.URL.Query().Get("path") diff --git a/markdown_test.go b/markdown_test.go new file mode 100644 index 0000000..f244aa3 --- /dev/null +++ b/markdown_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "strings" + "testing" +) + +func TestRenderMD(t *testing.T) { + tests := []struct { + name string + input string + contains string + }{ + {"plain text", "Hello world", "

Hello world

"}, + {"bold", "**bold**", "bold"}, + {"code block", "```\ncode\n```", "code"}, + {"inline code", "`inline`", "inline"}, + {"heading", "# Title", "

Title

"}, + {"table", "| A | B |\n|---|---|\n| 1 | 2 |", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RenderMD(tt.input) + if !strings.Contains(result, tt.contains) { + t.Errorf("RenderMD(%q) = %q, want to contain %q", tt.input, result, tt.contains) + } + }) + } +} diff --git a/pty_session.go b/pty_session.go new file mode 100644 index 0000000..78022a0 --- /dev/null +++ b/pty_session.go @@ -0,0 +1,312 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "sync" + "time" + + "github.com/creack/pty" +) + +const ( + outputBufferSize = 128 * 1024 // 128KB ring buffer for replay + ptyIdleTimeout = 60 * time.Minute + ptyCleanupTick = 5 * time.Minute +) + +// RingBuffer is a fixed-size circular buffer for terminal output. +type RingBuffer struct { + data []byte + size int + pos int + full bool + mu sync.Mutex +} + +func NewRingBuffer(size int) *RingBuffer { + return &RingBuffer{data: make([]byte, size), size: size} +} + +func (rb *RingBuffer) Write(p []byte) { + rb.mu.Lock() + defer rb.mu.Unlock() + for _, b := range p { + rb.data[rb.pos] = b + rb.pos++ + if rb.pos >= rb.size { + rb.pos = 0 + rb.full = true + } + } +} + +func (rb *RingBuffer) Bytes() []byte { + rb.mu.Lock() + defer rb.mu.Unlock() + if !rb.full { + result := make([]byte, rb.pos) + copy(result, rb.data[:rb.pos]) + return result + } + result := make([]byte, rb.size) + n := copy(result, rb.data[rb.pos:]) + copy(result[n:], rb.data[:rb.pos]) + return result +} + +// PTYSession manages a single Claude CLI running in a pseudo-terminal. +type PTYSession struct { + ID string + ProjectDir string + Ptmx *os.File + Cmd *exec.Cmd + + buffer *RingBuffer + subscribers map[string]chan []byte + mu sync.Mutex + done chan struct{} + lastActive time.Time +} + +// SpawnPTY starts a new Claude CLI in a pseudo-terminal. +func SpawnPTY(projectDir string) (*PTYSession, error) { + cmd := exec.Command("claude") + cmd.Dir = projectDir + + // Filter env vars to prevent nested session detection + cmd.Env = filterEnvMulti(os.Environ(), []string{ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + }) + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 80}) + if err != nil { + return nil, fmt.Errorf("start pty: %w", err) + } + + sess := &PTYSession{ + ID: fmt.Sprintf("%d", time.Now().UnixNano()), + ProjectDir: projectDir, + Ptmx: ptmx, + Cmd: cmd, + buffer: NewRingBuffer(outputBufferSize), + subscribers: make(map[string]chan []byte), + done: make(chan struct{}), + lastActive: time.Now(), + } + + go sess.readLoop() + go sess.waitExit() + + return sess, nil +} + +// readLoop reads PTY output, writes to ring buffer, and forwards to subscribers. +func (s *PTYSession) readLoop() { + buf := make([]byte, 4096) + for { + n, err := s.Ptmx.Read(buf) + if err != nil { + return + } + if n == 0 { + continue + } + data := make([]byte, n) + copy(data, buf[:n]) + + s.buffer.Write(data) + + s.mu.Lock() + s.lastActive = time.Now() + for id, ch := range s.subscribers { + select { + case ch <- data: + default: + log.Printf("PTY subscriber %s slow, dropping data", id) + } + } + s.mu.Unlock() + } +} + +// waitExit waits for the CLI process to exit and signals done. +func (s *PTYSession) waitExit() { + if s.Cmd.Process != nil { + s.Cmd.Wait() + } + close(s.done) +} + +// Subscribe adds a subscriber for PTY output. +func (s *PTYSession) Subscribe(id string) chan []byte { + s.mu.Lock() + defer s.mu.Unlock() + ch := make(chan []byte, 256) + s.subscribers[id] = ch + return ch +} + +// Unsubscribe removes a subscriber. +func (s *PTYSession) Unsubscribe(id string) { + s.mu.Lock() + defer s.mu.Unlock() + if ch, ok := s.subscribers[id]; ok { + close(ch) + delete(s.subscribers, id) + } +} + +// Resize changes the PTY terminal size. +func (s *PTYSession) Resize(rows, cols uint16) error { + return pty.Setsize(s.Ptmx, &pty.Winsize{Rows: rows, Cols: cols}) +} + +// WriteInput sends keyboard input to the PTY. +func (s *PTYSession) WriteInput(data []byte) (int, error) { + s.mu.Lock() + s.lastActive = time.Now() + s.mu.Unlock() + return s.Ptmx.Write(data) +} + +// GetBuffer returns the ring buffer contents for replay. +func (s *PTYSession) GetBuffer() []byte { + return s.buffer.Bytes() +} + +// Done returns a channel that closes when the process exits. +func (s *PTYSession) Done() <-chan struct{} { + return s.done +} + +// Close terminates the PTY session. +func (s *PTYSession) Close() { + s.mu.Lock() + for id, ch := range s.subscribers { + close(ch) + delete(s.subscribers, id) + } + s.mu.Unlock() + + s.Ptmx.Close() + if s.Cmd.Process != nil { + s.Cmd.Process.Kill() + } +} + +// PTYSessionManager manages PTY sessions keyed by project name. +type PTYSessionManager struct { + sessions map[string]*PTYSession + mu sync.RWMutex + stopCh chan struct{} +} + +func NewPTYSessionManager() *PTYSessionManager { + m := &PTYSessionManager{ + sessions: make(map[string]*PTYSession), + stopCh: make(chan struct{}), + } + go m.cleanup() + return m +} + +// GetOrCreate returns an existing session or creates a new one. +func (m *PTYSessionManager) GetOrCreate(project, projectDir string) (*PTYSession, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if sess, ok := m.sessions[project]; ok { + // Check if process is still alive + select { + case <-sess.Done(): + // Process exited, remove and create new + sess.Close() + delete(m.sessions, project) + default: + sess.mu.Lock() + sess.lastActive = time.Now() + sess.mu.Unlock() + return sess, false, nil + } + } + + sess, err := SpawnPTY(projectDir) + if err != nil { + return nil, false, err + } + + m.sessions[project] = sess + return sess, true, nil +} + +// Remove terminates and removes a session. +func (m *PTYSessionManager) Remove(project string) { + m.mu.Lock() + sess, ok := m.sessions[project] + if ok { + delete(m.sessions, project) + } + m.mu.Unlock() + + if ok { + sess.Close() + } +} + +// Stop shuts down the manager and all sessions. +func (m *PTYSessionManager) Stop() { + close(m.stopCh) + + m.mu.Lock() + for id, sess := range m.sessions { + sess.Close() + delete(m.sessions, id) + } + m.mu.Unlock() +} + +func (m *PTYSessionManager) cleanup() { + ticker := time.NewTicker(ptyCleanupTick) + defer ticker.Stop() + + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + m.cleanupIdle() + } + } +} + +func (m *PTYSessionManager) cleanupIdle() { + m.mu.Lock() + var toRemove []string + for id, sess := range m.sessions { + sess.mu.Lock() + idle := time.Since(sess.lastActive) > ptyIdleTimeout + sess.mu.Unlock() + + // Also check if process has exited + exited := false + select { + case <-sess.Done(): + exited = true + default: + } + + if idle || exited { + toRemove = append(toRemove, id) + } + } + m.mu.Unlock() + + for _, id := range toRemove { + log.Printf("Cleaning up PTY session: %s", id) + m.Remove(id) + } +} diff --git a/pty_session_test.go b/pty_session_test.go new file mode 100644 index 0000000..b53f441 --- /dev/null +++ b/pty_session_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "testing" +) + +func TestRingBuffer(t *testing.T) { + t.Run("write and read", func(t *testing.T) { + rb := NewRingBuffer(10) + rb.Write([]byte("hello")) + got := rb.Bytes() + if string(got) != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } + }) + + t.Run("wrap around", func(t *testing.T) { + rb := NewRingBuffer(5) + rb.Write([]byte("abcdefgh")) // wraps around + got := rb.Bytes() + if string(got) != "defgh" { + t.Errorf("got %q, want %q", got, "defgh") + } + }) + + t.Run("exact size", func(t *testing.T) { + rb := NewRingBuffer(5) + rb.Write([]byte("abcde")) + got := rb.Bytes() + if string(got) != "abcde" { + t.Errorf("got %q, want %q", got, "abcde") + } + }) + + t.Run("empty", func(t *testing.T) { + rb := NewRingBuffer(10) + got := rb.Bytes() + if len(got) != 0 { + t.Errorf("expected empty, got %q", got) + } + }) + + t.Run("multiple writes", func(t *testing.T) { + rb := NewRingBuffer(8) + rb.Write([]byte("abc")) + rb.Write([]byte("def")) + got := rb.Bytes() + if string(got) != "abcdef" { + t.Errorf("got %q, want %q", got, "abcdef") + } + }) + + t.Run("overflow multiple writes", func(t *testing.T) { + rb := NewRingBuffer(5) + rb.Write([]byte("abc")) + rb.Write([]byte("defgh")) // total 8, buffer 5 + got := rb.Bytes() + if string(got) != "defgh" { + t.Errorf("got %q, want %q", got, "defgh") + } + }) +} + +func TestPTYSessionManager(t *testing.T) { + t.Run("new manager has no sessions", func(t *testing.T) { + m := &PTYSessionManager{ + sessions: make(map[string]*PTYSession), + stopCh: make(chan struct{}), + } + if len(m.sessions) != 0 { + t.Error("expected empty sessions") + } + }) +} diff --git a/templates/chat.html b/templates/chat.html index 156be10..1e28417 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -3,146 +3,170 @@ - Claude Web Chat — {{.Project}} - - - + Claude — {{.Project}} + + -
- - - - -
-
-

{{.Project}}

- Povezivanje... -
- -
- -
- -
- -
-
- - Code - Plan - - Shift+Tab za promenu moda -
-
- - - - -
-
- - -