update readme and build
This commit is contained in:
+79
-9
@@ -18,6 +18,9 @@ import (
|
||||
//go:embed web/shell.html
|
||||
var shellPageHTML string
|
||||
|
||||
//go:embed web/login.html
|
||||
var loginPageHTML string
|
||||
|
||||
//go:embed web/favicon.svg
|
||||
var faviconSVG string
|
||||
|
||||
@@ -57,24 +60,68 @@ func handleStaticJS(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// handleIndex: always creates a fresh workspace and redirects to its stable URL.
|
||||
// PTY sessions are started lazily by the frontend via WebSocket connections.
|
||||
// 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
|
||||
}
|
||||
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 an existing (or new) workspace ID.
|
||||
// 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
|
||||
}
|
||||
serveTerminalPage(w, id, isAuthed(r))
|
||||
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) {
|
||||
@@ -94,15 +141,36 @@ 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"}`))
|
||||
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"}`))
|
||||
w.Write([]byte(`{"error":"bad form"}`)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
if checkCreds(strings.TrimSpace(r.FormValue("username")), r.FormValue("password")) {
|
||||
|
||||
// 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(),
|
||||
@@ -112,11 +180,13 @@ func handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(authTokenTTL.Seconds()),
|
||||
})
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
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"}`))
|
||||
w.Write([]byte(`{"error":"Invalid username or password"}`)) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user