fix splitting layout bug - was not splitting visually original shell
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -140,6 +140,18 @@ journalctl -u gotermix -f
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Session & logout
|
||||||
|
|
||||||
|
- Auth cookie TTL is **30 minutes** from last activity.
|
||||||
|
- While the browser tab is open and you are active, the session extends automatically (heartbeat every 5 minutes).
|
||||||
|
- After **30 minutes of inactivity** the page signs you out automatically.
|
||||||
|
- The **Logout** button in the toolbar signs you out immediately — the workspace URL stays valid and all terminals resume after you sign in again.
|
||||||
|
- The **End** button destroys the saved layout (fresh session on next visit).
|
||||||
|
|
||||||
|
> Cookie expiry and inactivity timeout are independent: the cookie expires 30 min after the last heartbeat; the JS inactivity timer fires 30 min after the last mouse/keyboard event. Closing the browser tab stops heartbeats; reopening after >30 min will redirect to login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -92,3 +92,24 @@ func isAuthed(r *http.Request) bool {
|
|||||||
c, err := r.Cookie(authCookieName)
|
c, err := r.Cookie(authCookieName)
|
||||||
return err == nil && validAuthToken(c.Value)
|
return err == nil && validAuthToken(c.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshAuthCookie issues a fresh auth cookie, extending the session TTL
|
||||||
|
// (sliding window). No-op in -nopw mode or if current token is already invalid.
|
||||||
|
func refreshAuthCookie(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if nopwMode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(authCookieName)
|
||||||
|
if err != nil || !validAuthToken(c.Value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: authCookieName,
|
||||||
|
Value: makeAuthToken(),
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: int(authTokenTTL.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ const (
|
|||||||
sessionTTL = 24 * time.Hour
|
sessionTTL = 24 * time.Hour
|
||||||
authCookieName = "gws_auth"
|
authCookieName = "gws_auth"
|
||||||
csrfCookieName = "gws_csrf"
|
csrfCookieName = "gws_csrf"
|
||||||
authTokenTTL = 12 * time.Hour
|
authTokenTTL = 30 * time.Minute
|
||||||
credsFilename = "gws-creds.json"
|
credsFilename = "gws-creds.json"
|
||||||
defaultUser = "ivor"
|
defaultUser = "ivor"
|
||||||
defaultPass = "Silv3rSw0rd!"
|
defaultPass = "Silv3rSw0rd!"
|
||||||
|
|||||||
@@ -210,6 +210,47 @@ func handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleLogout clears the auth cookie without destroying the workspace.
|
||||||
|
// The terminal URL remains valid — user re-authenticates to resume.
|
||||||
|
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: authCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
next := r.FormValue("next")
|
||||||
|
if !isValidNext(next) {
|
||||||
|
next = "/"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login?next="+next, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRefresh extends the auth cookie TTL for an active authenticated session.
|
||||||
|
// Called by the frontend heartbeat every 5 minutes.
|
||||||
|
func handleRefresh(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 !isAuthed(r) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`{"error":"unauthorized"}`)) //nolint:errcheck
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshAuthCookie(w, r)
|
||||||
|
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
func handleWS(w http.ResponseWriter, r *http.Request) {
|
func handleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
if !isAuthed(r) {
|
if !isAuthed(r) {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ func Run() {
|
|||||||
mux.HandleFunc("/static/app.css", handleStaticCSS)
|
mux.HandleFunc("/static/app.css", handleStaticCSS)
|
||||||
mux.HandleFunc("/static/app.js", handleStaticJS)
|
mux.HandleFunc("/static/app.js", handleStaticJS)
|
||||||
mux.HandleFunc("/api/workspace/", handleWorkspaceAPI)
|
mux.HandleFunc("/api/workspace/", handleWorkspaceAPI)
|
||||||
|
mux.HandleFunc("/logout", handleLogout)
|
||||||
|
mux.HandleFunc("/api/refresh", handleRefresh)
|
||||||
|
|
||||||
ln, _ := net.Listen("tcp", *addr)
|
ln, _ := net.Listen("tcp", *addr)
|
||||||
fmt.Printf("Go Web Shell https://%s\n", *addr)
|
fmt.Printf("Go Web Shell https://%s\n", *addr)
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0;
|
|||||||
.tb-btn.tb-info { background: rgba(59,130,246,0.1); color: #93c5fd;
|
.tb-btn.tb-info { background: rgba(59,130,246,0.1); color: #93c5fd;
|
||||||
border: 1px solid rgba(59,130,246,0.2); }
|
border: 1px solid rgba(59,130,246,0.2); }
|
||||||
.tb-btn.tb-info:hover { background: rgba(59,130,246,0.18); color: #bfdbfe; }
|
.tb-btn.tb-info:hover { background: rgba(59,130,246,0.18); color: #bfdbfe; }
|
||||||
|
.tb-btn.tb-logout { background: rgba(245,158,11,0.08); color: #fbbf24;
|
||||||
|
border: 1px solid rgba(245,158,11,0.15); }
|
||||||
|
.tb-btn.tb-logout:hover { background: rgba(245,158,11,0.15); color: #fcd34d; }
|
||||||
.tb-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.07); flex-shrink: 0; margin: 0 2px; }
|
.tb-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.07); flex-shrink: 0; margin: 0 2px; }
|
||||||
|
|
||||||
/* ── Toast ── */
|
/* ── Toast ── */
|
||||||
|
|||||||
@@ -379,6 +379,9 @@ function splitPane(dir) {
|
|||||||
|
|
||||||
// replaceNode uses leaf.parent (old parent) — set new parents AFTER
|
// replaceNode uses leaf.parent (old parent) — set new parents AFTER
|
||||||
replaceNode(tab, leaf, splitNode);
|
replaceNode(tab, leaf, splitNode);
|
||||||
|
// Re-apply grandparent's flex so the new splitNode.el inherits correct sizing.
|
||||||
|
// Without this, the new container has no flex rule and collapses visually.
|
||||||
|
if (splitNode.parent) applyFlex(splitNode.parent);
|
||||||
leaf.parent = splitNode;
|
leaf.parent = splitNode;
|
||||||
newLeaf.parent = splitNode;
|
newLeaf.parent = splitNode;
|
||||||
|
|
||||||
@@ -439,6 +442,8 @@ function closePane() {
|
|||||||
|
|
||||||
replaceNode(tab, splitNode, sibling);
|
replaceNode(tab, splitNode, sibling);
|
||||||
sibling.el.removeAttribute('style');
|
sibling.el.removeAttribute('style');
|
||||||
|
// Re-apply grandparent's flex so sibling gets correct sizing in its new position.
|
||||||
|
if (sibling.parent) applyFlex(sibling.parent);
|
||||||
|
|
||||||
const next = findFirstLeaf(sibling);
|
const next = findFirstLeaf(sibling);
|
||||||
setActiveLeaf(tab, next);
|
setActiveLeaf(tab, next);
|
||||||
@@ -708,6 +713,57 @@ function modalFb(id, msg, type) {
|
|||||||
el.className = 'm-fb' + (type ? ' ' + type : '');
|
el.className = 'm-fb' + (type ? ' ' + type : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Logout & inactivity ────────────────────────────────────────────────
|
||||||
|
const INACTIVITY_MS = 30 * 60 * 1000; // 30 min inactivity → auto-logout
|
||||||
|
const HEARTBEAT_MS = 5 * 60 * 1000; // 5 min heartbeat → extend server cookie
|
||||||
|
|
||||||
|
let _inactTimer = null;
|
||||||
|
let _hbTimer = null;
|
||||||
|
|
||||||
|
function _resetInactivity() {
|
||||||
|
clearTimeout(_inactTimer);
|
||||||
|
_inactTimer = setTimeout(doLogout, INACTIVITY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startHeartbeat() {
|
||||||
|
clearInterval(_hbTimer);
|
||||||
|
_hbTimer = setInterval(_sendHeartbeat, HEARTBEAT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _sendHeartbeat() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/refresh', { method: 'POST' });
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Cookie already expired server-side — stop timers, go to login
|
||||||
|
clearInterval(_hbTimer);
|
||||||
|
clearTimeout(_inactTimer);
|
||||||
|
window.location.href = '/login?next=/s/' + WORKSPACE_ID;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
clearTimeout(_inactTimer);
|
||||||
|
clearInterval(_hbTimer);
|
||||||
|
try {
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.append('next', '/s/' + WORKSPACE_ID);
|
||||||
|
await fetch('/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
window.location.href = '/login?next=/s/' + WORKSPACE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset inactivity timer on any user interaction.
|
||||||
|
['mousemove', 'keydown', 'touchstart', 'click'].forEach(ev =>
|
||||||
|
document.addEventListener(ev, _resetInactivity, { passive: true })
|
||||||
|
);
|
||||||
|
_resetInactivity();
|
||||||
|
_startHeartbeat();
|
||||||
|
|
||||||
// ── Bootstrap ──────────────────────────────────────────────────────────
|
// ── Bootstrap ──────────────────────────────────────────────────────────
|
||||||
// Terminal page is only served to authenticated users (server redirects
|
// Terminal page is only served to authenticated users (server redirects
|
||||||
// unauthenticated requests to /login). Load saved workspace or start fresh.
|
// unauthenticated requests to /login). Load saved workspace or start fresh.
|
||||||
|
|||||||
@@ -148,6 +148,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
End
|
End
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Logout (sign out, workspace preserved) -->
|
||||||
|
<button class="tb-btn tb-logout" onclick="doLogout()" title="Sign out — workspace is preserved, sign in again to resume">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
<!-- Upload -->
|
<!-- Upload -->
|
||||||
<button class="tb-btn" onclick="openModal('upOverlay')" title="Upload file">
|
<button class="tb-btn" onclick="openModal('upOverlay')" title="Upload file">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -216,10 +224,17 @@
|
|||||||
<table class="info-table">
|
<table class="info-table">
|
||||||
<tr><td>Link button</td><td>Copy shareable workspace URL — open on any device to resume all tabs & splits</td></tr>
|
<tr><td>Link button</td><td>Copy shareable workspace URL — open on any device to resume all tabs & splits</td></tr>
|
||||||
<tr><td>End button</td><td>Clear saved layout; next visit starts fresh</td></tr>
|
<tr><td>End button</td><td>Clear saved layout; next visit starts fresh</td></tr>
|
||||||
|
<tr><td>Logout button</td><td>Sign out — workspace and all terminals are preserved; sign in again at the same URL to resume</td></tr>
|
||||||
<tr><td>Upload button</td><td>Upload file to server (lands in active shell's cwd by default)</td></tr>
|
<tr><td>Upload button</td><td>Upload file to server (lands in active shell's cwd by default)</td></tr>
|
||||||
<tr><td>Down button</td><td>Download file from server by path</td></tr>
|
<tr><td>Down button</td><td>Download file from server by path</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<div class="info-section-label">Session timeout</div>
|
||||||
|
<table class="info-table">
|
||||||
|
<tr><td>Auto-logout</td><td>30 minutes of inactivity signs you out automatically (workspace preserved)</td></tr>
|
||||||
|
<tr><td>Active sessions</td><td>Session extends automatically while you are active</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user