package internals import ( "embed" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/creack/pty" "github.com/gorilla/websocket" ) //go:embed web/shell.html var shellPageHTML string //go:embed web/login.html var loginPageHTML string //go:embed web/favicon.svg var faviconSVG string //go:embed web/*.css web/*.js var staticAssets embed.FS var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} // handleFavicon serves a terminal-themed SVG favicon. func handleFavicon(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write([]byte(faviconSVG)) } // handleStaticCSS serves the embedded app.css stylesheet. func handleStaticCSS(w http.ResponseWriter, r *http.Request) { data, err := staticAssets.ReadFile("web/app.css") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write(data) } // handleStaticJS serves the embedded app.js script. func handleStaticJS(w http.ResponseWriter, r *http.Request) { data, err := staticAssets.ReadFile("web/app.js") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/javascript; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write(data) } // handleLogin serves the standalone login page (GET) or redirects authed users. func handleLogin(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/login" { http.NotFound(w, r) return } if isAuthed(r) { http.Redirect(w, r, "/", http.StatusFound) return } next := r.URL.Query().Get("next") if !isValidNext(next) { next = "/" } tok := setCSRFCookie(w) html := strings.NewReplacer( "[[CSRF_TOKEN]]", tok, "[[NEXT]]", next, ).Replace(loginPageHTML) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Write([]byte(html)) //nolint:errcheck } // isValidNext rejects open-redirect targets; only "/" and "/s/" allowed. func isValidNext(next string) bool { if next == "" || next == "/" { return true } if strings.HasPrefix(next, "/s/") { return validID(strings.TrimPrefix(next, "/s/")) } return false } // handleIndex: creates a fresh workspace and redirects to its stable URL. // Unauthenticated requests are sent to the login page first. func handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } if !isAuthed(r) { http.Redirect(w, r, "/login", http.StatusFound) return } http.Redirect(w, r, "/s/"+randHex(16), http.StatusFound) } // handleShell: serves the terminal page for a workspace ID. // Unauthenticated requests are redirected to /login?next=... func handleShell(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/s/") if !validID(id) { http.NotFound(w, r) return } if !isAuthed(r) { http.Redirect(w, r, "/login?next=/s/"+id, http.StatusFound) return } serveTerminalPage(w, id, true) } func serveTerminalPage(w http.ResponseWriter, workspaceID string, authed bool) { authedStr := "false" if authed { authedStr = "true" } html := strings.NewReplacer( "[[WORKSPACE_ID]]", workspaceID, "[[AUTHED]]", authedStr, ).Replace(shellPageHTML) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) } func handleAuth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) w.Write([]byte(`{"error":"POST only"}`)) //nolint:errcheck return } if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error":"bad form"}`)) //nolint:errcheck return } // CSRF validation (skipped in -nopw mode which never shows the login page) if !nopwMode && !checkCSRF(r) { logAuthAttempt(r, "", false, "csrf_invalid") w.WriteHeader(http.StatusForbidden) w.Write([]byte(`{"error":"invalid request"}`)) //nolint:errcheck return } username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") // Input bounds — reject obviously bad values before touching the hasher if len(username) == 0 || len(username) > 64 || len(password) == 0 || len(password) > 1024 { time.Sleep(500 * time.Millisecond) logAuthAttempt(r, username, false, "invalid_input") w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error":"invalid input"}`)) //nolint:errcheck return } if checkCreds(username, password) { http.SetCookie(w, &http.Cookie{ Name: authCookieName, Value: makeAuthToken(), Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: int(authTokenTTL.Seconds()), }) logAuthAttempt(r, username, true, "login_success") w.Write([]byte(`{"ok":true}`)) //nolint:errcheck } else { time.Sleep(500 * time.Millisecond) // blunt brute-force deterrent logAuthAttempt(r, username, false, "invalid_credentials") w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"Invalid username or password"}`)) //nolint:errcheck } } func handleWS(w http.ResponseWriter, r *http.Request) { if !isAuthed(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } id := strings.TrimPrefix(r.URL.Path, "/ws/") if !validID(id) { http.Error(w, "invalid session id", http.StatusBadRequest) return } s := getOrCreate(id) if s == nil { http.Error(w, "failed to start shell", http.StatusInternalServerError) return } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return } cl := &client{conn: conn} s.mu.Lock() s.clients[cl] = struct{}{} s.lastSeen = time.Now() isNew := len(s.buf) == 0 replay := make([]byte, len(s.buf)) copy(replay, s.buf) s.mu.Unlock() if len(replay) > 0 { cl.write(websocket.BinaryMessage, replay) } cl.write(websocket.TextMessage, []byte( fmt.Sprintf(`{"type":"session","id":"%s","new":%v}`, id[:8], isNew), )) defer func() { s.mu.Lock() delete(s.clients, cl) s.mu.Unlock() conn.Close() }() for { mt, data, err := conn.ReadMessage() if err != nil { break } s.mu.Lock() s.lastSeen = time.Now() s.mu.Unlock() if mt == websocket.TextMessage { var msg struct { Type string `json:"type"` Cols int `json:"cols"` Rows int `json:"rows"` } if json.Unmarshal(data, &msg) == nil && msg.Type == "resize" { pty.Setsize(s.ptty, &pty.Winsize{Cols: uint16(msg.Cols), Rows: uint16(msg.Rows)}) continue } } s.ptty.Write(data) //nolint:errcheck } } func handleUpload(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if !isAuthed(r) { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"unauthorized"}`)) return } if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) w.Write([]byte(`{"error":"POST only"}`)) return } r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) if err := r.ParseMultipartForm(32 << 20); err != nil { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, `{"error":"parse error: %s"}`, err.Error()) return } id := r.FormValue("sid") if !validID(id) { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error":"missing or invalid session id"}`)) return } s := sessionByID(id) if s == nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error":"session not found — reopen the terminal"}`)) return } file, header, err := r.FormFile("file") if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error":"missing file field"}`)) return } defer file.Close() tmp, err := os.CreateTemp("", "gws-upload-*") if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":"temp file error"}`)) return } tmpName := tmp.Name() if _, err := io.Copy(tmp, file); err != nil { tmp.Close() os.Remove(tmpName) w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":"write error"}`)) return } tmp.Close() destDir := strings.TrimSpace(r.FormValue("dest")) if destDir == "" { destDir = initialCwd } destPath := filepath.Join(filepath.Clean(destDir), filepath.Base(header.Filename)) // Inject mv into the shell — inherits the shell's effective user (e.g. root after sudo su). mvCmd := fmt.Sprintf("\nmv %s %s && echo 'Uploaded: %s'\n", shellQuote(tmpName), shellQuote(destPath), filepath.Base(header.Filename)) s.mu.Lock() s.ptty.Write([]byte(mvCmd)) //nolint:errcheck s.mu.Unlock() fmt.Fprintf(w, `{"ok":true,"dest":%q}`, destPath) } func handleDownload(w http.ResponseWriter, r *http.Request) { if !isAuthed(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } path := r.URL.Query().Get("path") if path == "" { http.Error(w, "missing ?path=", http.StatusBadRequest) return } full := filepath.Clean(path) if !filepath.IsAbs(full) { full = filepath.Join(initialCwd, full) } if _, err := os.Stat(full); err != nil { http.Error(w, "file not found", http.StatusNotFound) return } w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(full)+`"`) http.ServeFile(w, r, full) }