Sfoglia il codice sorgente

Merge branch 'next' of github.com:OliveTin/OliveTin into next

jamesread 4 mesi fa
parent
commit
0412b9ea1d

+ 13 - 1
frontend/js/OutputTerminal.js

@@ -1,5 +1,6 @@
 import { Terminal } from '@xterm/xterm'
 import { FitAddon } from '@xterm/addon-fit'
+import { WebLinksAddon } from '@xterm/addon-web-links'
 import { Mutex } from './Mutex.js'
 
 /**
@@ -18,13 +19,24 @@ export class OutputTerminal {
   constructor (executionTrackingId) {
     this.executionTrackingId = executionTrackingId
     this.writeMutex = new Mutex()
+    const linkHandler = {
+      activate (event, text, _range) {
+        event.preventDefault()
+        window.open(text, '_blank')
+      }
+    }
+
     this.terminal = new Terminal({
-      convertEol: true
+      convertEol: true,
+      linkHandler
     })
 
     const fitAddon = new FitAddon()
     this.terminal.loadAddon(fitAddon)
     this.terminal.fit = fitAddon
+
+    this.terminal.loadAddon(new WebLinksAddon((event, uri) => linkHandler.activate(event, uri)))
+    this.linkHandlerConfigured = true
   }
 
   async write (out, then) {

+ 7 - 0
frontend/package-lock.json

@@ -15,6 +15,7 @@
 				"@hugeicons/vue": "^1.0.4",
 				"@vitejs/plugin-vue": "^6.0.4",
 				"@xterm/addon-fit": "^0.11.0",
+				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/xterm": "^6.0.0",
 				"iconify-icon": "^3.0.2",
 				"picocrank": "^1.14.0",
@@ -1570,6 +1571,12 @@
 			"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
 			"license": "MIT"
 		},
+		"node_modules/@xterm/addon-web-links": {
+			"version": "0.12.0",
+			"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
+			"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
+			"license": "MIT"
+		},
 		"node_modules/@xterm/xterm": {
 			"version": "6.0.0",
 			"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",

+ 2 - 1
frontend/package.json

@@ -28,13 +28,14 @@
 		"@hugeicons/vue": "^1.0.4",
 		"@vitejs/plugin-vue": "^6.0.4",
 		"@xterm/addon-fit": "^0.11.0",
+		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
 		"iconify-icon": "^3.0.2",
 		"picocrank": "^1.14.0",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^31.0.0",
 		"vite": "^7.3.1",
-    "vue": "^3.5.29",
+		"vue": "^3.5.29",
 		"vue-i18n": "^11.2.8",
 		"vue-router": "^5.0.3"
 	}

+ 13 - 0
integration-tests/tests/xtermLinkHandling/config.yaml

@@ -0,0 +1,13 @@
+#
+# Integration Test Config: xterm link handling
+#
+
+listenAddressSingleHTTPFrontend: 0.0.0.0:1337
+
+logLevel: "DEBUG"
+checkForUpdates: false
+
+actions:
+  - title: Echo URL
+    shell: echo "See https://example.com for more info"
+    popupOnStart: execution-dialog-stdout-only

+ 69 - 0
integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs

@@ -0,0 +1,69 @@
+import { describe, it, before, after } from 'mocha'
+import { expect } from 'chai'
+import { By, Condition } from 'selenium-webdriver'
+import {
+  getRootAndWait,
+  takeScreenshotOnFailure,
+  getTerminalBuffer,
+} from '../../lib/elements.js'
+
+describe('config: xtermLinkHandling', function () {
+  before(async function () {
+    await runner.start('xtermLinkHandling')
+  })
+
+  after(async () => {
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('xterm output shows URL and link handling is configured', async function () {
+    await getRootAndWait()
+
+    await webdriver.wait(new Condition('wait for Echo URL button', async () => {
+      const btns = await webdriver.findElements(By.css('[title="Echo URL"]'))
+      return btns.length === 1
+    }), 10000)
+
+    const echoUrlButton = await webdriver.findElement(By.css('[title="Echo URL"]'))
+    await echoUrlButton.click()
+
+    await webdriver.wait(new Condition('wait for execution view', async () => {
+      const url = await webdriver.getCurrentUrl()
+      return url.includes('/logs/') && !url.endsWith('/logs')
+    }), 10000)
+
+    await webdriver.wait(new Condition('wait for execution status', async () => {
+      const statusElements = await webdriver.findElements(By.id('execution-dialog-status'))
+      return statusElements.length > 0
+    }), 5000)
+
+    await webdriver.wait(new Condition('wait for execution to finish', async () => {
+      try {
+        const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
+        const statusText = await statusElement.getText()
+        return !statusText.includes('Executing')
+      } catch (e) {
+        return false
+      }
+    }), 5000)
+
+    await webdriver.sleep(500)
+
+    const bufferText = await getTerminalBuffer()
+    expect(bufferText).to.not.be.null
+    expect(bufferText).to.include('https://example.com')
+
+    const linkHandlerSet = await webdriver.executeScript(`
+      try {
+        return !!(window.terminal && window.terminal.linkHandlerConfigured === true)
+      } catch (e) {
+        return false
+      }
+    `)
+    expect(linkHandlerSet).to.equal(true)
+  })
+})

+ 27 - 17
service/internal/auth/otoauth2/restapi_auth_oauth2.go

@@ -11,6 +11,7 @@ import (
 	"io"
 	"net/http"
 	"os"
+	"sync"
 	"time"
 
 	authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic"
@@ -21,6 +22,7 @@ import (
 
 type OAuth2Handler struct {
 	cfg                 *config.Config
+	mu                  sync.RWMutex
 	registeredStates    map[string]*oauth2State
 	registeredProviders map[string]*oauth2.Config
 }
@@ -144,11 +146,13 @@ func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	h.mu.Lock()
 	h.registeredStates[state] = &oauth2State{
 		providerConfig: provider,
 		providerName:   providerName,
 		Username:       "",
 	}
+	h.mu.Unlock()
 
 	h.setOAuthCallbackCookie(w, r, "olivetin-sid-oauth", state)
 
@@ -177,7 +181,9 @@ func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.
 		return nil, state, false
 	}
 
+	h.mu.RLock()
 	registeredState, ok := h.registeredStates[state]
+	h.mu.RUnlock()
 	if !ok {
 		log.Errorf("State not found in server: %v", state)
 		http.Error(w, "State not found in server", http.StatusBadRequest)
@@ -287,8 +293,10 @@ func (h *OAuth2Handler) HandleOAuthCallback(w http.ResponseWriter, r *http.Reque
 	userInfoClient := h.createUserInfoClient(ctx, registeredState.providerConfig, tok, clientSettings)
 	userinfo := getUserInfo(h.cfg, userInfoClient, providerConfig)
 
+	h.mu.Lock()
 	h.registeredStates[state].Username = userinfo.Username
 	h.registeredStates[state].Usergroup = h.computeUsergroup(userinfo, providerConfig)
+	h.mu.Unlock()
 
 	http.Redirect(w, r, "/", http.StatusFound)
 }
@@ -366,34 +374,36 @@ func getDataField(data map[string]any, field string) string {
 	return stringVal
 }
 
-func (h *OAuth2Handler) CheckUserFromOAuth2Cookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser {
-	cookie, err := context.Request.Cookie("olivetin-sid-oauth")
-
-	user := &authTypes.AuthenticatedUser{}
-
-	if err != nil {
-		return nil
+func (h *OAuth2Handler) lookupOAuth2UserByState(state string) (*authTypes.AuthenticatedUser, bool) {
+	h.mu.RLock()
+	serverState, found := h.registeredStates[state]
+	if !found {
+		h.mu.RUnlock()
+		return nil, false
+	}
+	user := &authTypes.AuthenticatedUser{
+		Username:      serverState.Username,
+		UsergroupLine: serverState.Usergroup,
+		Provider:      "oauth2",
+		SID:           state,
 	}
+	h.mu.RUnlock()
+	return user, true
+}
 
-	if cookie.Value == "" {
+func (h *OAuth2Handler) CheckUserFromOAuth2Cookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser {
+	cookie, err := context.Request.Cookie("olivetin-sid-oauth")
+	if err != nil || cookie.Value == "" {
 		return nil
 	}
 
-	serverState, found := h.registeredStates[cookie.Value]
-
+	user, found := h.lookupOAuth2UserByState(cookie.Value)
 	if !found {
 		log.WithFields(log.Fields{
 			"sid":      cookie.Value,
 			"provider": "oauth2",
 		}).Warnf("Stale session")
-
 		return nil
 	}
-
-	user.Username = serverState.Username
-	user.UsergroupLine = serverState.Usergroup
-	user.Provider = "oauth2"
-	user.SID = cookie.Value
-
 	return user
 }