Merge branch 'next' of github.com:OliveTin/OliveTin into next
This commit is contained in:
commit
0412b9ea1d
|
|
@ -1,5 +1,6 @@
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
import { Mutex } from './Mutex.js'
|
import { Mutex } from './Mutex.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -18,13 +19,24 @@ export class OutputTerminal {
|
||||||
constructor (executionTrackingId) {
|
constructor (executionTrackingId) {
|
||||||
this.executionTrackingId = executionTrackingId
|
this.executionTrackingId = executionTrackingId
|
||||||
this.writeMutex = new Mutex()
|
this.writeMutex = new Mutex()
|
||||||
|
const linkHandler = {
|
||||||
|
activate (event, text, _range) {
|
||||||
|
event.preventDefault()
|
||||||
|
window.open(text, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.terminal = new Terminal({
|
this.terminal = new Terminal({
|
||||||
convertEol: true
|
convertEol: true,
|
||||||
|
linkHandler
|
||||||
})
|
})
|
||||||
|
|
||||||
const fitAddon = new FitAddon()
|
const fitAddon = new FitAddon()
|
||||||
this.terminal.loadAddon(fitAddon)
|
this.terminal.loadAddon(fitAddon)
|
||||||
this.terminal.fit = fitAddon
|
this.terminal.fit = fitAddon
|
||||||
|
|
||||||
|
this.terminal.loadAddon(new WebLinksAddon((event, uri) => linkHandler.activate(event, uri)))
|
||||||
|
this.linkHandlerConfigured = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async write (out, then) {
|
async write (out, then) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"@hugeicons/vue": "^1.0.4",
|
"@hugeicons/vue": "^1.0.4",
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"iconify-icon": "^3.0.2",
|
"iconify-icon": "^3.0.2",
|
||||||
"picocrank": "^1.14.0",
|
"picocrank": "^1.14.0",
|
||||||
|
|
@ -1570,6 +1571,12 @@
|
||||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@xterm/xterm": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"@hugeicons/vue": "^1.0.4",
|
"@hugeicons/vue": "^1.0.4",
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"iconify-icon": "^3.0.2",
|
"iconify-icon": "^3.0.2",
|
||||||
"picocrank": "^1.14.0",
|
"picocrank": "^1.14.0",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||||
|
|
@ -21,6 +22,7 @@ import (
|
||||||
|
|
||||||
type OAuth2Handler struct {
|
type OAuth2Handler struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
mu sync.RWMutex
|
||||||
registeredStates map[string]*oauth2State
|
registeredStates map[string]*oauth2State
|
||||||
registeredProviders map[string]*oauth2.Config
|
registeredProviders map[string]*oauth2.Config
|
||||||
}
|
}
|
||||||
|
|
@ -144,11 +146,13 @@ func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
h.registeredStates[state] = &oauth2State{
|
h.registeredStates[state] = &oauth2State{
|
||||||
providerConfig: provider,
|
providerConfig: provider,
|
||||||
providerName: providerName,
|
providerName: providerName,
|
||||||
Username: "",
|
Username: "",
|
||||||
}
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
h.setOAuthCallbackCookie(w, r, "olivetin-sid-oauth", state)
|
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
|
return nil, state, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
registeredState, ok := h.registeredStates[state]
|
registeredState, ok := h.registeredStates[state]
|
||||||
|
h.mu.RUnlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Errorf("State not found in server: %v", state)
|
log.Errorf("State not found in server: %v", state)
|
||||||
http.Error(w, "State not found in server", http.StatusBadRequest)
|
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)
|
userInfoClient := h.createUserInfoClient(ctx, registeredState.providerConfig, tok, clientSettings)
|
||||||
userinfo := getUserInfo(h.cfg, userInfoClient, providerConfig)
|
userinfo := getUserInfo(h.cfg, userInfoClient, providerConfig)
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
h.registeredStates[state].Username = userinfo.Username
|
h.registeredStates[state].Username = userinfo.Username
|
||||||
h.registeredStates[state].Usergroup = h.computeUsergroup(userinfo, providerConfig)
|
h.registeredStates[state].Usergroup = h.computeUsergroup(userinfo, providerConfig)
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
@ -366,34 +374,36 @@ func getDataField(data map[string]any, field string) string {
|
||||||
return stringVal
|
return stringVal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func (h *OAuth2Handler) CheckUserFromOAuth2Cookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser {
|
func (h *OAuth2Handler) CheckUserFromOAuth2Cookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser {
|
||||||
cookie, err := context.Request.Cookie("olivetin-sid-oauth")
|
cookie, err := context.Request.Cookie("olivetin-sid-oauth")
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
user := &authTypes.AuthenticatedUser{}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if cookie.Value == "" {
|
user, found := h.lookupOAuth2UserByState(cookie.Value)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
serverState, found := h.registeredStates[cookie.Value]
|
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"sid": cookie.Value,
|
"sid": cookie.Value,
|
||||||
"provider": "oauth2",
|
"provider": "oauth2",
|
||||||
}).Warnf("Stale session")
|
}).Warnf("Stale session")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
user.Username = serverState.Username
|
|
||||||
user.UsergroupLine = serverState.Usergroup
|
|
||||||
user.Provider = "oauth2"
|
|
||||||
user.SID = cookie.Value
|
|
||||||
|
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue