2026-05-23 15:29:05 +00:00
|
|
|
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/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 06:37:59 +00:00
|
|
|
// handleIndex: always creates a fresh workspace and redirects to its stable URL.
|
|
|
|
|
// PTY sessions are started lazily by the frontend via WebSocket connections.
|
2026-05-23 15:29:05 +00:00
|
|
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path != "/" {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-24 06:37:59 +00:00
|
|
|
http.Redirect(w, r, "/s/"+randHex(16), http.StatusFound)
|
2026-05-23 15:29:05 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
// handleShell: serves the terminal page for an existing (or new) workspace ID.
|
2026-05-23 15:29:05 +00:00
|
|
|
func handleShell(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
id := strings.TrimPrefix(r.URL.Path, "/s/")
|
|
|
|
|
if !validID(id) {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
serveTerminalPage(w, id, isAuthed(r))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 06:37:59 +00:00
|
|
|
func serveTerminalPage(w http.ResponseWriter, workspaceID string, authed bool) {
|
2026-05-23 15:29:05 +00:00
|
|
|
authedStr := "false"
|
|
|
|
|
if authed {
|
|
|
|
|
authedStr = "true"
|
|
|
|
|
}
|
|
|
|
|
html := strings.NewReplacer(
|
2026-05-24 06:37:59 +00:00
|
|
|
"[[WORKSPACE_ID]]", workspaceID,
|
|
|
|
|
"[[AUTHED]]", authedStr,
|
2026-05-23 15:29:05 +00:00
|
|
|
).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"}`))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
w.Write([]byte(`{"error":"bad form"}`))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if checkCreds(strings.TrimSpace(r.FormValue("username")), r.FormValue("password")) {
|
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
|
|
|
Name: authCookieName,
|
|
|
|
|
Value: makeAuthToken(),
|
|
|
|
|
Path: "/",
|
|
|
|
|
HttpOnly: true,
|
|
|
|
|
Secure: true,
|
|
|
|
|
SameSite: http.SameSiteLaxMode,
|
|
|
|
|
MaxAge: int(authTokenTTL.Seconds()),
|
|
|
|
|
})
|
|
|
|
|
w.Write([]byte(`{"ok":true}`))
|
|
|
|
|
} else {
|
|
|
|
|
time.Sleep(500 * time.Millisecond) // blunt brute-force deterrent
|
|
|
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
|
w.Write([]byte(`{"error":"Invalid username or password"}`))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|