Просмотр исходного кода

Merge remote-tracking branch 'origin/next' into fix/1147-update-picocrank

# Conflicts:
#	frontend/resources/vue/App.vue
jamesread 6 часов назад
Родитель
Сommit
ac83202e6d

+ 2 - 0
Dockerfile.multiarches

@@ -54,3 +54,5 @@ COPY var/helper-actions/* /usr/bin/
 USER olivetin
 USER olivetin
 
 
 ENTRYPOINT [ "/usr/bin/OliveTin" ]
 ENTRYPOINT [ "/usr/bin/OliveTin" ]
+
+HEALTHCHECK CMD curl -fsS "http://127.0.0.1:${PORT:-1337}/readyz"

+ 2 - 0
Dockerfile.singlearch

@@ -46,3 +46,5 @@ COPY var/helper-actions/* /usr/bin/
 USER olivetin
 USER olivetin
 
 
 ENTRYPOINT [ "/usr/bin/OliveTin" ]
 ENTRYPOINT [ "/usr/bin/OliveTin" ]
+
+HEALTHCHECK CMD curl -fsS "http://127.0.0.1:${PORT:-1337}/readyz"

+ 82 - 9
docs/modules/ROOT/pages/security/oauth2_pocketid.adoc

@@ -1,14 +1,77 @@
 [#oauth2-pocketid]
 [#oauth2-pocketid]
 = OAuth2 - Pocket ID
 = OAuth2 - Pocket ID
 
 
-OliveTin has been tested with Pocket ID. This documentation page describes how to configure Pocket ID for use with OliveTin and assumes you already have Pocket ID installed and running.
+OliveTin has been tested with Pocket ID. This documentation page describes how to configure Pocket ID for use with OliveTin and assumes you already have Pocket ID installed and running. It allows guests access to some actions, and admins access to all actions.
 
 
-== Configuration
+This example uses Pocket ID v2.19.2. Please check the Pocket ID docs if you are running a newer version.
+
+== Pocket ID Configuration:
+
+==== Create a new user group:
+Assign users to this group that will have access to all olivetin actions. We will add a custom claim that is sent to Olivetin at login. +
+
+-> Administration -> User Groups -> Add Group +
+
+[cols="1,2"]
+|===
+
+| Name | `olivetin_admins`
+
+| Friendly Name | `Olivetin Admins`
+|===
+
+-> Custom Claims
+
+[cols="1,2", options="header"]
+|===
+
+| Key | Value
+
+| olivetin_group
+| olivetin_admin
+
+|===
+
+==== Create a new client:
+
+-> Administration -> OIDC Clients -> Add OIDC Client +
+
+[cols="1,2"]
+|===
+
+| Name | `Olivetin`
+
+| Client Type | Confidential Client
+|===
+
+-> Callback URLs -> + Add callback URL +
+
+[cols="1,1"]
+|===
+
+| Callback URLs | `\https://olivetin.example.com/oauth/callback`
+|===
+
+-> Create
+
+-> Access Tab: +
+
+Allowed User Groups -> Selected user groups +
+-> `olivetin_admins` -> Save +
+
+Copy the generated Client ID and Client Secret from the General tab into olivetin's config.yaml (example below). The secret will only be displayed once.
+
+== Olivetin Configuration
 
 
 .config.yaml
 .config.yaml
 [source,yaml]
 [source,yaml]
 ----
 ----
-authRequireGuestsToLogin: true
+authRequireGuestsToLogin: false
+
+defaultPermissions:
+  view: false
+  exec: false
+  logs: false
 
 
 accessControlLists:
 accessControlLists:
   - name: admin
   - name: admin
@@ -17,12 +80,17 @@ accessControlLists:
       exec: true
       exec: true
       logs: true
       logs: true
     matchUsergroups:
     matchUsergroups:
-      # Since you can't map properties in userinfo response from Pocket ID I am kind of cheating here:
-      # only I will be able to log in and so I return the "preferred_username" as the group, and I configure my
-      # own username as the "Usergroup" to mean "admin"
-      - myusername
+      - olivetin_admin
     addToEveryAction: true
     addToEveryAction: true
 
 
+  - name: "guests"
+    permissions:
+      view: true
+      exec: true
+      logs: false
+    matchUsernames: [ "guest" ]
+    addToEveryAction: false
+
 authLocalUsers:
 authLocalUsers:
   enabled: false
   enabled: false
 
 
@@ -43,12 +111,17 @@ authOAuth2Providers:
       - profile
       - profile
       - email
       - email
     usernameField: preferred_username
     usernameField: preferred_username
-    userGroupField: preferred_username
+    userGroupField: olivetin_group
     insecureSkipVerify: true
     insecureSkipVerify: true
 
 
 actions:
 actions:
   - title: "Hello world!"
   - title: "Hello world!"
-    shell: echo 'Hello World!'
+    shell: echo 'Hello World! I have guest access'
+    acls:
+      - "guests"
+  - title: "Admins only"
+    shell: echo 'Hello World! I am an admin'
+
 ----
 ----
 
 
 == Pocket ID config
 == Pocket ID config

+ 8 - 2
frontend/resources/vue/App.vue

@@ -8,6 +8,7 @@
         :sidebar-enabled="sidebarEnabled"
         :sidebar-enabled="sidebarEnabled"
         :top-bar-enabled="topbarEnabled"
         :top-bar-enabled="topbarEnabled"
         :navigation="navigation"
         :navigation="navigation"
+        :top-bar-navigation="navigation"
         :username="headerUsername"
         :username="headerUsername"
         :login-route="headerLoginRoute"
         :login-route="headerLoginRoute"
         @toggle-sidebar="toggleSidebar"
         @toggle-sidebar="toggleSidebar"
@@ -184,7 +185,7 @@ import AppShell from 'picocrank/vue/components/AppShell.vue'
 import NotificationBlock from 'picocrank/vue/components/NotificationBlock.vue'
 import NotificationBlock from 'picocrank/vue/components/NotificationBlock.vue'
 import QuickSearch from 'picocrank/vue/components/QuickSearch.vue'
 import QuickSearch from 'picocrank/vue/components/QuickSearch.vue'
 import ConnectionBanner from './components/ConnectionBanner.vue'
 import ConnectionBanner from './components/ConnectionBanner.vue'
-import { connectEventStreamIfNeeded } from '../../js/websocket.js'
+import { connectEventStreamIfNeeded, stopEventStream } from '../../js/websocket.js'
 import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
 import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
 import logoUrl from '../../OliveTinLogo.png'
 import logoUrl from '../../OliveTinLogo.png'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
@@ -257,7 +258,7 @@ const currentThemeName = computed(() => {
 
 
 // Computed properties for navigation style
 // Computed properties for navigation style
 const topbarEnabled = computed(() => {
 const topbarEnabled = computed(() => {
-  return sectionNavigationStyle.value === 'topbar'
+  return sectionNavigationStyle.value === 'topbar' && showNavigation.value
 })
 })
 
 
 const sidebarEnabled = computed(() => {
 const sidebarEnabled = computed(() => {
@@ -294,6 +295,11 @@ async function updateHeaderFromInit () {
   loginRequired.value = !!window.initResponse.loginRequired
   loginRequired.value = !!window.initResponse.loginRequired
   headerSearchEnabled.value = !!window.initResponse.features?.headerSearch
   headerSearchEnabled.value = !!window.initResponse.features?.headerSearch
 
 
+  // The server filters stream events by the identity the stream connected with.
+  if (username.value !== authenticatedUser) {
+    stopEventStream()
+  }
+
   username.value = authenticatedUser
   username.value = authenticatedUser
   isLoggedIn.value = authenticatedUser !== '' && authenticatedUser !== 'guest'
   isLoggedIn.value = authenticatedUser !== '' && authenticatedUser !== 'guest'
   currentVersion.value = window.initResponse.currentVersion
   currentVersion.value = window.initResponse.currentVersion

+ 22 - 0
frontend/resources/vue/utils/localeSelection.js

@@ -9,6 +9,28 @@ export function selectBrowserLocale (availableLocales, browserLanguages) {
 
 
     const parts = lowerCandidate.split('-')
     const parts = lowerCandidate.split('-')
 
 
+    if (parts[0] === 'zh') {
+      let script
+      try {
+        script = new Intl.Locale(candidate).maximize().script
+      } catch (error) {
+        if (error instanceof RangeError) {
+          continue
+        }
+        throw error
+      }
+      const match = availableLocales.find(locale =>
+        locale.toLowerCase().startsWith('zh-') &&
+        new Intl.Locale(locale).maximize().script === script
+      )
+
+      if (match) {
+        return match
+      }
+
+      continue
+    }
+
     for (let length = parts.length - 1; length > 0; length--) {
     for (let length = parts.length - 1; length > 0; length--) {
       const prefix = `${parts.slice(0, length).join('-')}-`
       const prefix = `${parts.slice(0, length).join('-')}-`
       const match = availableLocales.find(locale => locale.toLowerCase().startsWith(prefix))
       const match = availableLocales.find(locale => locale.toLowerCase().startsWith(prefix))

+ 23 - 0
frontend/resources/vue/utils/localeSelection.test.mjs

@@ -17,6 +17,29 @@ test('selectBrowserLocale preserves script subtags during fallback', () => {
   assert.equal(selectBrowserLocale(availableLocales, ['zh-Hant-HK']), 'zh-Hant-TW')
   assert.equal(selectBrowserLocale(availableLocales, ['zh-Hant-HK']), 'zh-Hant-TW')
 })
 })
 
 
+for (const language of ['zh-TW', 'zh-HK', 'zh-MO', 'zh-Hant', 'zh-Hant-CN', 'ZH-tw']) {
+  test(`selectBrowserLocale selects Traditional Chinese for ${language}`, () => {
+    assert.equal(selectBrowserLocale(availableLocales, [language]), 'zh-Hant-TW')
+  })
+}
+
+for (const language of ['zh-CN', 'zh-SG', 'zh-Hans', 'zh-Hans-TW', 'zh']) {
+  test(`selectBrowserLocale selects Simplified Chinese for ${language} regardless of locale order`, () => {
+    assert.equal(selectBrowserLocale([...availableLocales].reverse(), [language]), 'zh-Hans-CN')
+  })
+}
+
+test('selectBrowserLocale skips unavailable Chinese scripts and tries the next browser language', () => {
+  assert.equal(selectBrowserLocale(['en', 'de-DE', 'zh-Hans-CN'], ['zh-TW', 'de-AT']), 'de-DE')
+  assert.equal(selectBrowserLocale(['en', 'zh-Hant-TW'], ['zh-CN']), 'en')
+})
+
 test('selectBrowserLocale uses English when no locale matches', () => {
 test('selectBrowserLocale uses English when no locale matches', () => {
   assert.equal(selectBrowserLocale(availableLocales, ['fr-FR']), 'en')
   assert.equal(selectBrowserLocale(availableLocales, ['fr-FR']), 'en')
 })
 })
+
+test('selectBrowserLocale skips malformed Chinese candidates', () => {
+  assert.equal(selectBrowserLocale(availableLocales, ['zh-', 'zh-TW']), 'zh-Hant-TW')
+  assert.equal(selectBrowserLocale(availableLocales, ['zh-Hant-!', 'de-AT']), 'de-DE')
+  assert.equal(selectBrowserLocale(availableLocales, ['zh-']), 'en')
+})

+ 8 - 0
integration-tests/mochaSetup.mjs

@@ -6,6 +6,14 @@ export async function mochaGlobalSetup () {
   const options = new Options()
   const options = new Options()
   options.addArguments('--headless', '--disable-gpu', '--disable-dev-shm-usage')
   options.addArguments('--headless', '--disable-gpu', '--disable-dev-shm-usage')
 
 
+  // Logging in with a test password can raise Chrome's save-password or
+  // breached-password prompt, which swallows clicks for every later test.
+  options.setUserPreferences({
+    credentials_enable_service: false,
+    'profile.password_manager_enabled': false,
+    'profile.password_manager_leak_detection': false,
+  })
+
   global.webdriver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).build()
   global.webdriver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).build()
   await global.webdriver.manage().setTimeouts({ implicit: 0, pageLoad: 30000, script: 30000 })
   await global.webdriver.manage().setTimeouts({ implicit: 0, pageLoad: 30000, script: 30000 })
 
 

+ 51 - 0
integration-tests/tests/authChangeEventStream/authChangeEventStream.mjs

@@ -0,0 +1,51 @@
+import { By, until } from 'selenium-webdriver'
+import {
+  getRootAndWait,
+  takeScreenshotOnFailure,
+} from '../../lib/elements.js'
+
+const UI_WAIT_MS = 5000
+
+function actionButtonLocator (title) {
+  return By.xpath(`//*[contains(@class, 'action-button')]//button[contains(., '${title}')]`)
+}
+
+async function clickActionAndWaitForSuccess (title) {
+  const button = await webdriver.wait(until.elementLocated(actionButtonLocator(title)), UI_WAIT_MS)
+  await button.click()
+
+  await webdriver.wait(async () => {
+    const classes = await button.getAttribute('class')
+    return classes.includes('action-success')
+  }, UI_WAIT_MS)
+}
+
+describe('config: authChangeEventStream', function () {
+  before(async function () {
+    await runner.start('authChangeEventStream')
+  })
+
+  after(async () => {
+    await webdriver.manage().deleteAllCookies()
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('delivers execution events for the new user after logging in without a page refresh', async () => {
+    await getRootAndWait()
+    await clickActionAndWaitForSuccess('Guest action')
+
+    const loginLink = await webdriver.wait(until.elementLocated(By.css('a.login-link')), UI_WAIT_MS)
+    await loginLink.click()
+
+    const username = await webdriver.wait(until.elementLocated(By.id('username')), UI_WAIT_MS)
+    await username.sendKeys('alice')
+    await webdriver.findElement(By.id('password')).sendKeys('password')
+    await webdriver.findElement(By.css('button.login-button')).click()
+
+    await clickActionAndWaitForSuccess('Admin action')
+  })
+})

+ 46 - 0
integration-tests/tests/authChangeEventStream/config.yaml

@@ -0,0 +1,46 @@
+#
+# Integration Test Config: Event stream identity follows login
+#
+
+listenAddressSingleHTTPFrontend: 0.0.0.0:1337
+
+logLevel: "DEBUG"
+checkForUpdates: false
+authRequireGuestsToLogin: false
+
+authLocalUsers:
+  enabled: true
+  users:
+    - username: "alice"
+      usergroup: "admin"
+      password: "$argon2id$v=19$m=65536,t=4,p=6$ORxyZZGW6E3FWZnbQmHJ9Q$BzIOWeXry/BZ6+JV1T4UASBnebVLB9QJ4f5TmUPXsg4" # notsecret: password
+
+defaultPermissions:
+  view: false
+  exec: false
+  logs: false
+
+accessControlLists:
+  - name: admin
+    addToEveryAction: true
+    matchUsergroups: ["admin"]
+    permissions:
+      view: true
+      exec: true
+      logs: true
+
+  - name: guests
+    matchUsernames: ["guest"]
+    permissions:
+      view: true
+      exec: true
+      logs: true
+
+actions:
+  - title: Guest action
+    shell: echo guest
+    acls:
+      - guests
+
+  - title: Admin action
+    shell: echo admin

+ 22 - 0
integration-tests/tests/topbarNavigation/config.yaml

@@ -0,0 +1,22 @@
+#
+# Integration Test Config: Topbar navigation
+#
+
+listenAddressSingleHTTPFrontend: 0.0.0.0:1337
+
+checkForUpdates: false
+sectionNavigationStyle: topbar
+
+actions:
+  - title: Ping example.com
+    shell: ping example.com -c 1
+    icon: ping
+
+dashboards:
+  - title: Alpha
+    contents:
+      - title: Ping example.com
+
+  - title: Beta
+    contents:
+      - title: Ping example.com

+ 38 - 0
integration-tests/tests/topbarNavigation/topbarNavigation.mjs

@@ -0,0 +1,38 @@
+import { By, until } from 'selenium-webdriver'
+import { expect } from 'chai'
+import {
+  getRootAndWait,
+  takeScreenshotOnFailure,
+} from '../../lib/elements.js'
+
+describe('config: sectionNavigationStyle topbar', function () {
+  let originalWindowRect
+
+  before(async function () {
+    originalWindowRect = await webdriver.manage().window().getRect()
+    await runner.start('topbarNavigation')
+  })
+
+  after(async () => {
+    await webdriver.manage().window().setRect(originalWindowRect)
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('shows dashboard links in the top bar', async () => {
+    await webdriver.manage().window().setRect({ width: 1600, height: 900 })
+    await getRootAndWait()
+
+    const betaLink = await webdriver.wait(
+      until.elementLocated(By.xpath("//nav[contains(@class, 'topbar')]//a[contains(., 'Beta')]")),
+      5000
+    )
+    await betaLink.click()
+
+    await webdriver.wait(until.urlContains('Beta'), 5000)
+    expect(await webdriver.getCurrentUrl()).to.contain('Beta')
+  })
+})