Files
gotermix/internals/handlers.go
T

351 lines
9.2 KiB
Go
Raw Normal View History

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
2026-05-24 07:18:54 +00:00
//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)
}
2026-05-24 07:18:54 +00:00
// 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/<hex>" 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
}
2026-05-24 07:18:54 +00:00
if !isAuthed(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
2026-05-24 06:37:59 +00:00
http.Redirect(w, r, "/s/"+randHex(16), http.StatusFound)
}
2026-05-24 07:18:54 +00:00
// 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
}
2026-05-24 07:18:54 +00:00
if !isAuthed(r) {
http.Redirect(w, r, "/login?next=/s/"+id, http.StatusFound)
return
}
serveTerminalPage(w, id, true)
}
2026-05-24 06:37:59 +00:00
func serveTerminalPage(w http.ResponseWriter, workspaceID string, authed bool) {
authedStr := "false"
if authed {
authedStr = "true"
}
html := strings.NewReplacer(
2026-05-24 06:37:59 +00:00
"[[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)
2026-05-24 07:18:54 +00:00
w.Write([]byte(`{"error":"POST only"}`)) //nolint:errcheck
return
}
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
2026-05-24 07:18:54 +00:00
w.Write([]byte(`{"error":"bad form"}`)) //nolint:errcheck
return
}
2026-05-24 07:18:54 +00:00
// 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()),
})
2026-05-24 07:18:54 +00:00
logAuthAttempt(r, username, true, "login_success")
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
} else {
time.Sleep(500 * time.Millisecond) // blunt brute-force deterrent
2026-05-24 07:18:54 +00:00
logAuthAttempt(r, username, false, "invalid_credentials")
w.WriteHeader(http.StatusUnauthorized)
2026-05-24 07:18:54 +00:00
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)
}