added tabs to terminal, split single .go to separate files
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// handleIndex: clean URL, always creates a fresh session.
|
||||
// The session-specific /s/<id> URL is available in the toolbar for sharing.
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id := randHex(16)
|
||||
getOrCreate(id)
|
||||
serveTerminalPage(w, id, isAuthed(r))
|
||||
}
|
||||
|
||||
// handleShell: reconnects to a specific session by ID.
|
||||
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))
|
||||
}
|
||||
|
||||
func serveTerminalPage(w http.ResponseWriter, id string, authed bool) {
|
||||
authedStr := "false"
|
||||
if authed {
|
||||
authedStr = "true"
|
||||
}
|
||||
html := strings.NewReplacer(
|
||||
"[[SESSION_ID]]", id,
|
||||
"[[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"}`))
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user