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...
-
-
-
-
-
-
-
-
-
-
-
-
-
-