+- Aeternum
- Al Romano
- Alex Balabanov
+- Alex Milanov
- Alex Zen
- Arti Zirk
- Ave
- Brandon Curtis
+- Damien Hottelier
+- Daniel T. Holtzclaw
- Dave 'Sri' Seah
- djagoo
- dz
- Douglas Lassance
+- Ergoflix
- Ernie Reid
- Etienne
- Flemis Jurgenheimer
@@ -407,11 +453,11 @@ Thank you to all our patrons! 🙏 [[Become a patron](https://www.patreon.com/re
- Ian
- Imari Childress
- Iskander Callos
-- Josh Stewart
|
+- Josh Stewart
- Justin Dunsworth
- Keir
- Loïc CRAMPON
@@ -420,14 +466,17 @@ Thank you to all our patrons! 🙏 [[Become a patron](https://www.patreon.com/re
- Mads Rosendahl
- Mark Mansur
- Matt Gedigian
+- Mike Ditton
- Nate Figz
- Patryk
+- Paul O'Fallon
- Philipp Schürch
- Tracey Duffy
- Quaxim
- Richeir
- Sergio Navarro Fernández
- Shad Narcher
+- ShadowVoyd
- SmartNET.works
- Stepan Sokolovskyi
- Zach Crawford
diff --git a/SECURITY.md b/SECURITY.md
index 44cfc409..b905767f 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -13,15 +13,10 @@ If you find such vulnerability, it's important to disclose it in a quick and sec
## Reporting a Vulnerability
-**DO NOT CREATE AN ISSUE ON GITHUB** to report a potential vulnerability / security problem. Instead, choose one of these options:
+> [!CAUTION]
+> **DO NOT CREATE A GITHUB ISSUE / DISCUSSION** to report a potential vulnerability / security problem. Instead, use the process below:
-### A) Disclose on Huntr.dev
-
-Disclose the vulnerability on [Huntr.dev](https://huntr.dev/bounties/disclose) for the repository `https://github.com/Requarks/wiki`.
-
-### B) Send an email
-
-Send an email to security@requarks.io.
+Submit a Vulnerability Report by filling in the form on https://github.com/requarks/wiki/security/advisories/new
Include as much details as possible, such as:
- The version(s) of Wiki.js that are impacted
@@ -31,3 +26,6 @@ Include as much details as possible, such as:
- Your GitHub username if you'd like to be included as a collaborator on the private fix branch
The vulnerability will be investigated ASAP. If deemed valid, a draft security advisory will be created on GitHub and you will be included as a collaborator. A fix will be worked on in a private branch to resolves the issue. Once a fix is available, the advisory will be published.
+
+> [!NOTE]
+> There's no reward for submitting a report. As this is open source project and not corporate owned, we are not able to provide monetary rewards. You will however be credited as the bug reporter in the release notes.
diff --git a/client/client-app.js b/client/client-app.js
index 40f4cc60..cdf27a80 100644
--- a/client/client-app.js
+++ b/client/client-app.js
@@ -103,7 +103,7 @@ const graphQLLink = ApolloLink.from([
// Handle renewed JWT
const newJWT = resp.headers.get('new-jwt')
if (newJWT) {
- Cookies.set('jwt', newJWT, { expires: 365 })
+ Cookies.set('jwt', newJWT, { expires: 365, secure: window.location.protocol === 'https:' })
}
return resp
}
@@ -114,7 +114,11 @@ const graphQLWSLink = new WebSocketLink({
uri: graphQLWSEndpoint,
options: {
reconnect: true,
- lazy: true
+ lazy: true,
+ connectionParams: () => {
+ const token = Cookies.get('jwt')
+ return token ? { token } : {}
+ }
}
})
@@ -148,30 +152,30 @@ Vue.prototype.Velocity = Velocity
// Register Vue Components
// ====================================
-Vue.component('admin', () => import(/* webpackChunkName: "admin" */ './components/admin.vue'))
-Vue.component('comments', () => import(/* webpackChunkName: "comments" */ './components/comments.vue'))
-Vue.component('editor', () => import(/* webpackPrefetch: -100, webpackChunkName: "editor" */ './components/editor.vue'))
-Vue.component('history', () => import(/* webpackChunkName: "history" */ './components/history.vue'))
-Vue.component('loader', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/loader.vue'))
-Vue.component('login', () => import(/* webpackPrefetch: true, webpackChunkName: "login" */ './components/login.vue'))
-Vue.component('nav-header', () => import(/* webpackMode: "eager" */ './components/common/nav-header.vue'))
-Vue.component('new-page', () => import(/* webpackChunkName: "new-page" */ './components/new-page.vue'))
-Vue.component('notify', () => import(/* webpackMode: "eager" */ './components/common/notify.vue'))
-Vue.component('not-found', () => import(/* webpackChunkName: "not-found" */ './components/not-found.vue'))
-Vue.component('page-selector', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/page-selector.vue'))
-Vue.component('page-source', () => import(/* webpackChunkName: "source" */ './components/source.vue'))
-Vue.component('profile', () => import(/* webpackChunkName: "profile" */ './components/profile.vue'))
-Vue.component('register', () => import(/* webpackChunkName: "register" */ './components/register.vue'))
-Vue.component('search-results', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/search-results.vue'))
-Vue.component('social-sharing', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/social-sharing.vue'))
-Vue.component('tags', () => import(/* webpackChunkName: "tags" */ './components/tags.vue'))
-Vue.component('unauthorized', () => import(/* webpackChunkName: "unauthorized" */ './components/unauthorized.vue'))
-Vue.component('v-card-chin', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/v-card-chin.vue'))
-Vue.component('v-card-info', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/v-card-info.vue'))
-Vue.component('welcome', () => import(/* webpackChunkName: "welcome" */ './components/welcome.vue'))
-
-Vue.component('nav-footer', () => import(/* webpackChunkName: "theme" */ './themes/' + siteConfig.theme + '/components/nav-footer.vue'))
-Vue.component('page', () => import(/* webpackChunkName: "theme" */ './themes/' + siteConfig.theme + '/components/page.vue'))
+Vue.component('Admin', () => import(/* webpackChunkName: "admin" */ './components/admin.vue'))
+Vue.component('Comments', () => import(/* webpackChunkName: "comments" */ './components/comments.vue'))
+Vue.component('Editor', () => import(/* webpackPrefetch: -100, webpackChunkName: "editor" */ './components/editor.vue'))
+Vue.component('History', () => import(/* webpackChunkName: "history" */ './components/history.vue'))
+Vue.component('Loader', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/loader.vue'))
+Vue.component('Login', () => import(/* webpackPrefetch: true, webpackChunkName: "login" */ './components/login.vue'))
+Vue.component('NavHeader', () => import(/* webpackMode: "eager" */ './components/common/nav-header.vue'))
+Vue.component('NewPage', () => import(/* webpackChunkName: "new-page" */ './components/new-page.vue'))
+Vue.component('Notify', () => import(/* webpackMode: "eager" */ './components/common/notify.vue'))
+Vue.component('NotFound', () => import(/* webpackChunkName: "not-found" */ './components/not-found.vue'))
+Vue.component('PageSelector', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/page-selector.vue'))
+Vue.component('PageSource', () => import(/* webpackChunkName: "source" */ './components/source.vue'))
+Vue.component('Profile', () => import(/* webpackChunkName: "profile" */ './components/profile.vue'))
+Vue.component('Register', () => import(/* webpackChunkName: "register" */ './components/register.vue'))
+Vue.component('SearchResults', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/search-results.vue'))
+Vue.component('SocialSharing', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/social-sharing.vue'))
+Vue.component('Tags', () => import(/* webpackChunkName: "tags" */ './components/tags.vue'))
+Vue.component('Unauthorized', () => import(/* webpackChunkName: "unauthorized" */ './components/unauthorized.vue'))
+Vue.component('VCardChin', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/v-card-chin.vue'))
+Vue.component('VCardInfo', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/v-card-info.vue'))
+Vue.component('Welcome', () => import(/* webpackChunkName: "welcome" */ './components/welcome.vue'))
+
+Vue.component('NavFooter', () => import(/* webpackChunkName: "theme" */ './themes/' + siteConfig.theme + '/components/nav-footer.vue'))
+Vue.component('Page', () => import(/* webpackChunkName: "theme" */ './themes/' + siteConfig.theme + '/components/page.vue'))
let bootstrap = () => {
// ====================================
diff --git a/client/components/admin/admin-general.vue b/client/components/admin/admin-general.vue
index 8804ee39..07596074 100644
--- a/client/components/admin/admin-general.vue
+++ b/client/components/admin/admin-general.vue
@@ -82,6 +82,15 @@
:return-object='false'
:hint='$t(`admin:general.contentLicenseHint`)'
persistent-hint
+ )
+ v-text-field.mt-3(
+ outlined
+ :label='$t(`admin:general.footerOverride`)'
+ v-model='config.footerOverride'
+ prepend-icon='mdi-page-layout-footer'
+ append-icon='mdi-language-markdown'
+ persistent-hint
+ :hint='$t(`admin:general.footerOverrideHint`)'
)
v-divider
.overline.grey--text.pa-4 SEO
@@ -280,6 +289,7 @@ export default {
analyticsId: '',
company: '',
contentLicense: '',
+ footerOverride: '',
logoUrl: '',
featureAnalytics: false,
featurePageRatings: false,
@@ -308,6 +318,7 @@ export default {
logoUrl: sync('site/logoUrl'),
company: sync('site/company'),
contentLicense: sync('site/contentLicense'),
+ footerOverride: sync('site/footerOverride'),
activeModal: sync('editor/activeModal'),
contentLicenses () {
return [
@@ -346,6 +357,7 @@ export default {
$analyticsId: String
$company: String
$contentLicense: String
+ $footerOverride: String
$logoUrl: String
$pageExtensions: String
$featurePageRatings: Boolean
@@ -369,6 +381,7 @@ export default {
analyticsId: $analyticsId
company: $company
contentLicense: $contentLicense
+ footerOverride: $footerOverride
logoUrl: $logoUrl
pageExtensions: $pageExtensions
featurePageRatings: $featurePageRatings
@@ -401,6 +414,7 @@ export default {
analyticsId: _.get(this.config, 'analyticsId', ''),
company: _.get(this.config, 'company', ''),
contentLicense: _.get(this.config, 'contentLicense', ''),
+ footerOverride: _.get(this.config, 'footerOverride', ''),
logoUrl: _.get(this.config, 'logoUrl', ''),
pageExtensions: _.get(this.config, 'pageExtensions', ''),
featurePageRatings: _.get(this.config, 'featurePageRatings', false),
@@ -426,6 +440,7 @@ export default {
this.siteTitle = this.config.title
this.company = this.config.company
this.contentLicense = this.config.contentLicense
+ this.footerOverride = this.config.footerOverride
this.logoUrl = this.config.logoUrl
} catch (err) {
this.$store.commit('pushGraphError', err)
@@ -461,6 +476,7 @@ export default {
analyticsId
company
contentLicense
+ footerOverride
logoUrl
pageExtensions
featurePageRatings
diff --git a/client/components/admin/admin-groups-edit-permissions.vue b/client/components/admin/admin-groups-edit-permissions.vue
index 91b1b684..62304129 100644
--- a/client/components/admin/admin-groups-edit-permissions.vue
+++ b/client/components/admin/admin-groups-edit-permissions.vue
@@ -149,28 +149,28 @@ export default {
items: [
{
permission: 'write:users',
- hint: 'Can create or authorize new users, but not modify existing ones',
+ hint: 'Can create or authorize new users, but not modify existing ones. Can only assign to non-administrative groups',
warning: false,
restrictedForSystem: true,
disabled: false
},
{
permission: 'manage:users',
- hint: 'Can manage all users (but not users with administrative permissions)',
+ hint: 'Can create, authorize and modify ANY users. Can only assign to non-administrative groups',
warning: false,
restrictedForSystem: true,
disabled: false
},
{
permission: 'write:groups',
- hint: 'Can manage groups and assign CONTENT permissions / page rules',
+ hint: 'Can manage groups and set CONTENT permissions / page rules. Can only assign users to non-administrative groups',
warning: false,
restrictedForSystem: true,
disabled: false
},
{
permission: 'manage:groups',
- hint: 'Can manage groups and assign ANY permissions (but not manage:system) / page rules',
+ hint: 'Can manage groups and set ANY permissions (but not manage:system) / page rules. Can assign users to ANY groups (except groups with the manage:system permission)',
warning: true,
restrictedForSystem: true,
disabled: false
@@ -203,7 +203,7 @@ export default {
},
{
permission: 'manage:system',
- hint: 'Can manage and access everything. Root administrator.',
+ hint: 'Can manage and access everything. Root administrator',
warning: true,
restrictedForSystem: true,
disabled: true
diff --git a/client/components/admin/admin-pages-edit.vue b/client/components/admin/admin-pages-edit.vue
index cd43533b..aef5126c 100644
--- a/client/components/admin/admin-pages-edit.vue
+++ b/client/components/admin/admin-pages-edit.vue
@@ -39,14 +39,14 @@
v-list-item-icon
v-icon(color='indigo') mdi-pencil
v-list-item-title Edit
- v-list-item(@click='', disabled)
- v-list-item-icon
- v-icon(color='grey') mdi-cube-scan
- v-list-item-title Re-Render
- v-list-item(@click='', disabled)
- v-list-item-icon
- v-icon(color='grey') mdi-earth-remove
- v-list-item-title Unpublish
+ //- v-list-item(@click='', disabled)
+ //- v-list-item-icon
+ //- v-icon(color='grey') mdi-cube-scan
+ //- v-list-item-title Re-Render
+ //- v-list-item(@click='', disabled)
+ //- v-list-item-icon
+ //- v-icon(color='grey') mdi-earth-remove
+ //- v-list-item-title Unpublish
v-list-item(:href='`/s/` + page.locale + `/` + page.path')
v-list-item-icon
v-icon(color='indigo') mdi-code-tags
@@ -55,14 +55,14 @@
v-list-item-icon
v-icon(color='indigo') mdi-history
v-list-item-title View History
- v-list-item(@click='', disabled)
- v-list-item-icon
- v-icon(color='grey') mdi-content-duplicate
- v-list-item-title Duplicate
- v-list-item(@click='', disabled)
- v-list-item-icon
- v-icon(color='grey') mdi-content-save-move-outline
- v-list-item-title Move / Rename
+ //- v-list-item(@click='', disabled)
+ //- v-list-item-icon
+ //- v-icon(color='grey') mdi-content-duplicate
+ //- v-list-item-title Duplicate
+ //- v-list-item(@click='', disabled)
+ //- v-list-item-icon
+ //- v-icon(color='grey') mdi-content-save-move-outline
+ //- v-list-item-title Move / Rename
v-dialog(v-model='deletePageDialog', max-width='500')
template(v-slot:activator='{ on }')
v-list-item(v-on='on')
diff --git a/client/components/admin/admin-pages-visualize.vue b/client/components/admin/admin-pages-visualize.vue
index 116b38a9..3c9b20eb 100644
--- a/client/components/admin/admin-pages-visualize.vue
+++ b/client/components/admin/admin-pages-visualize.vue
@@ -104,7 +104,7 @@ export default {
const truncatePath = path => _.take(path.split('/'), depth).join('/')
const descendantsByChild =
Object.entries(_.groupBy(descendants, page => truncatePath(page.path)))
- .map(([childPath, descendantsGroup]) => [getPage(childPath), descendantsGroup])
+ .map(([childPath, descendantsGroup]) => [getPage(childPath), _.sortBy(descendantsGroup, child => child.path)])
.map(([child, descendantsGroup]) =>
[child, _.filter(descendantsGroup, d => d.path !== child.path)])
return {
diff --git a/client/components/admin/admin-pages.vue b/client/components/admin/admin-pages.vue
index 0ca26de1..3f056eef 100644
--- a/client/components/admin/admin-pages.vue
+++ b/client/components/admin/admin-pages.vue
@@ -10,9 +10,9 @@
v-spacer
v-btn.animated.fadeInDown.wait-p1s(icon, color='grey', outlined, @click='refresh')
v-icon.grey--text mdi-refresh
- v-btn.animated.fadeInDown.mx-3(color='primary', outlined, @click='recyclebin', disabled)
- v-icon(left) mdi-delete-outline
- span Recycle Bin
+ //- v-btn.animated.fadeInDown.mx-3(color='primary', outlined, @click='recyclebin', disabled)
+ //- v-icon(left) mdi-delete-outline
+ //- span Recycle Bin
v-btn.animated.fadeInDown(color='primary', depressed, large, to='pages/visualize')
v-icon(left) mdi-graph
span Visualize
diff --git a/client/components/admin/admin-security.vue b/client/components/admin/admin-security.vue
index 7a8d305b..8c062c52 100644
--- a/client/components/admin/admin-security.vue
+++ b/client/components/admin/admin-security.vue
@@ -265,7 +265,7 @@ export default {
securityOpenRedirect: true,
securityIframe: true,
securityReferrerPolicy: true,
- securityTrustProxy: true,
+ securityTrustProxy: false,
securitySRI: true,
securityHSTS: false,
securityHSTSDuration: 0,
diff --git a/client/components/admin/admin-users-create.vue b/client/components/admin/admin-users-create.vue
index 7128149e..a0350067 100644
--- a/client/components/admin/admin-users-create.vue
+++ b/client/components/admin/admin-users-create.vue
@@ -70,13 +70,13 @@
v-model='mustChangePwd'
hide-details
)
- v-checkbox(
- color='primary'
- label='Send a welcome email'
- hide-details
- v-model='sendWelcomeEmail'
- disabled
- )
+ //- v-checkbox(
+ //- color='primary'
+ //- label='Send a welcome email'
+ //- hide-details
+ //- v-model='sendWelcomeEmail'
+ //- disabled
+ //- )
v-card-chin
v-spacer
v-btn(text, @click='isShown = false') Cancel
diff --git a/client/components/admin/admin-users-edit.vue b/client/components/admin/admin-users-edit.vue
index a9e20070..fb224fbd 100644
--- a/client/components/admin/admin-users-edit.vue
+++ b/client/components/admin/admin-users-edit.vue
@@ -337,12 +337,12 @@
.caption.grey--text.mt-3 {{$t('profile:activity.lastLoginOn')}}
.body-2: strong {{ user.lastLoginAt | moment('LLLL') }}
- v-card.mt-3.animated.fadeInUp.wait-p6s
- v-toolbar(color='teal', dense, dark, flat)
- v-icon.mr-2 mdi-file-document-box-multiple-outline
- span Content
- v-card-text
- em.caption.grey--text Coming soon
+ //- v-card.mt-3.animated.fadeInUp.wait-p6s
+ //- v-toolbar(color='teal', dense, dark, flat)
+ //- v-icon.mr-2 mdi-file-document-box-multiple-outline
+ //- span Content
+ //- v-card-text
+ //- em.caption.grey--text Coming soon
v-dialog(v-model='deleteUserDialog', max-width='500')
v-card
diff --git a/client/components/common/nav-header.vue b/client/components/common/nav-header.vue
index d8fc3c80..e5f7acdf 100644
--- a/client/components/common/nav-header.vue
+++ b/client/components/common/nav-header.vue
@@ -15,7 +15,7 @@
prepend-inner-icon='mdi-magnify'
:loading='searchIsLoading'
@keyup.enter='searchEnter'
- autocomplete='none'
+ autocomplete='off'
)
v-layout(row)
v-flex(xs5, md4)
@@ -68,7 +68,7 @@
@blur='searchBlur'
@keyup.down='searchMove(`down`)'
@keyup.up='searchMove(`up`)'
- autocomplete='none'
+ autocomplete='off'
)
v-tooltip(bottom)
template(v-slot:activator='{ on }')
@@ -476,7 +476,11 @@ export default {
window.location.assign('/logout')
},
goHome () {
- window.location.assign('/')
+ if (this.locales && this.locales.length > 0) {
+ window.location.assign(`/${this.locale}/home`)
+ } else {
+ window.location.assign('/')
+ }
}
}
}
diff --git a/client/components/editor/editor-asciidoc.vue b/client/components/editor/editor-asciidoc.vue
index 296b2414..126ba370 100644
--- a/client/components/editor/editor-asciidoc.vue
+++ b/client/components/editor/editor-asciidoc.vue
@@ -228,7 +228,8 @@ export default {
})
this.previewHTML = DOMPurify.sanitize($.html(), {
- ADD_TAGS: ['foreignObject']
+ ADD_TAGS: ['foreignObject'],
+ HTML_INTEGRATION_POINTS: { foreignobject: true }
})
},
/**
diff --git a/client/components/editor/editor-markdown.vue b/client/components/editor/editor-markdown.vue
index 04b5c6aa..baee118d 100644
--- a/client/components/editor/editor-markdown.vue
+++ b/client/components/editor/editor-markdown.vue
@@ -200,7 +200,7 @@ import 'codemirror/addon/fold/foldgutter.css'
import MarkdownIt from 'markdown-it'
import mdAttrs from 'markdown-it-attrs'
import mdDecorate from 'markdown-it-decorate'
-import mdEmoji from 'markdown-it-emoji'
+import { full as mdEmoji } from 'markdown-it-emoji'
import mdTaskLists from 'markdown-it-task-lists'
import mdExpandTabs from 'markdown-it-expand-tabs'
import mdAbbr from 'markdown-it-abbr'
@@ -454,7 +454,8 @@ export default {
// this.$store.set('editor/content', newContent)
this.processMarkers(this.cm.firstLine(), this.cm.lastLine())
this.previewHTML = DOMPurify.sanitize(md.render(newContent), {
- ADD_TAGS: ['foreignObject']
+ ADD_TAGS: ['foreignObject'],
+ HTML_INTEGRATION_POINTS: { foreignobject: true }
})
this.$nextTick(() => {
tabsetHelper.format()
diff --git a/client/components/editor/editor-modal-media.vue b/client/components/editor/editor-modal-media.vue
index 100a36df..67efe122 100644
--- a/client/components/editor/editor-modal-media.vue
+++ b/client/components/editor/editor-modal-media.vue
@@ -83,31 +83,31 @@
v-btn(icon, v-on='on', tile, small, @click.left='currentFileId = props.item.id')
v-icon(color='grey darken-2') mdi-dots-horizontal
v-list(nav, style='border-top: 5px solid #444;')
- v-list-item(@click='', disabled)
- v-list-item-avatar(size='24')
- v-icon(color='teal') mdi-text-short
- v-list-item-content {{$t('common:actions.properties')}}
- template(v-if='props.item.kind === `IMAGE`')
- v-list-item(@click='previewDialog = true', disabled)
- v-list-item-avatar(size='24')
- v-icon(color='green') mdi-image-search-outline
- v-list-item-content {{$t('common:actions.preview')}}
- v-list-item(@click='', disabled)
- v-list-item-avatar(size='24')
- v-icon(color='indigo') mdi-crop-rotate
- v-list-item-content {{$t('common:actions.edit')}}
- v-list-item(@click='', disabled)
- v-list-item-avatar(size='24')
- v-icon(color='purple') mdi-flash-circle
- v-list-item-content {{$t('common:actions.optimize')}}
+ //- v-list-item(@click='', disabled)
+ //- v-list-item-avatar(size='24')
+ //- v-icon(color='teal') mdi-text-short
+ //- v-list-item-content {{$t('common:actions.properties')}}
+ //- template(v-if='props.item.kind === `IMAGE`')
+ //- v-list-item(@click='previewDialog = true', disabled)
+ //- v-list-item-avatar(size='24')
+ //- v-icon(color='green') mdi-image-search-outline
+ //- v-list-item-content {{$t('common:actions.preview')}}
+ //- v-list-item(@click='', disabled)
+ //- v-list-item-avatar(size='24')
+ //- v-icon(color='indigo') mdi-crop-rotate
+ //- v-list-item-content {{$t('common:actions.edit')}}
+ //- v-list-item(@click='', disabled)
+ //- v-list-item-avatar(size='24')
+ //- v-icon(color='purple') mdi-flash-circle
+ //- v-list-item-content {{$t('common:actions.optimize')}}
v-list-item(@click='openRenameDialog')
v-list-item-avatar(size='24')
v-icon(color='orange') mdi-keyboard-outline
v-list-item-content {{$t('common:actions.rename')}}
- v-list-item(@click='', disabled)
- v-list-item-avatar(size='24')
- v-icon(color='blue') mdi-file-move
- v-list-item-content {{$t('common:actions.move')}}
+ //- v-list-item(@click='', disabled)
+ //- v-list-item-avatar(size='24')
+ //- v-icon(color='blue') mdi-file-move
+ //- v-list-item-content {{$t('common:actions.move')}}
v-list-item(@click='deleteDialog = true')
v-list-item-avatar(size='24')
v-icon(color='red') mdi-file-hidden
@@ -154,25 +154,25 @@
v-spacer
v-btn.px-4(color='teal', dark, @click='upload') {{$t('common:actions.upload')}}
- v-card.mt-3.radius-7.animated.fadeInRight.wait-p4s(:light='!$vuetify.theme.dark', :dark='$vuetify.theme.dark')
- v-card-text.pb-0
- v-toolbar.radius-7(:color='$vuetify.theme.dark ? `teal` : `teal lighten-5`', dense, flat)
- v-icon.mr-3(:color='$vuetify.theme.dark ? `white` : `teal`') mdi-cloud-download
- .body-2(:class='$vuetify.theme.dark ? `white--text` : `teal--text`') {{$t('editor:assets.fetchImage')}}
- v-spacer
- v-chip(label, color='white', small).teal--text coming soon
- v-text-field.mt-3(
- v-model='remoteImageUrl'
- outlined
- color='teal'
- single-line
- placeholder='https://example.com/image.jpg'
- )
- v-divider
- v-card-actions.pa-3
- .caption.grey--text.text-darken-2 Max 5 MB
- v-spacer
- v-btn.px-4(color='teal', disabled) {{$t('common:actions.fetch')}}
+ //- v-card.mt-3.radius-7.animated.fadeInRight.wait-p4s(:light='!$vuetify.theme.dark', :dark='$vuetify.theme.dark')
+ //- v-card-text.pb-0
+ //- v-toolbar.radius-7(:color='$vuetify.theme.dark ? `teal` : `teal lighten-5`', dense, flat)
+ //- v-icon.mr-3(:color='$vuetify.theme.dark ? `white` : `teal`') mdi-cloud-download
+ //- .body-2(:class='$vuetify.theme.dark ? `white--text` : `teal--text`') {{$t('editor:assets.fetchImage')}}
+ //- v-spacer
+ //- v-chip(label, color='white', small).teal--text coming soon
+ //- v-text-field.mt-3(
+ //- v-model='remoteImageUrl'
+ //- outlined
+ //- color='teal'
+ //- single-line
+ //- placeholder='https://example.com/image.jpg'
+ //- )
+ //- v-divider
+ //- v-card-actions.pa-3
+ //- .caption.grey--text.text-darken-2 Max 5 MB
+ //- v-spacer
+ //- v-btn.px-4(color='teal', disabled) {{$t('common:actions.fetch')}}
v-card.mt-3.radius-7.animated.fadeInRight.wait-p4s(:light='!$vuetify.theme.dark', :dark='$vuetify.theme.dark')
v-card-text.pb-0
diff --git a/client/components/editor/editor-modal-properties.vue b/client/components/editor/editor-modal-properties.vue
index 0738b333..a6ed1af3 100644
--- a/client/components/editor/editor-modal-properties.vue
+++ b/client/components/editor/editor-modal-properties.vue
@@ -21,7 +21,7 @@
v-tab {{$t('editor:props.info')}}
v-tab {{$t('editor:props.scheduling')}}
v-tab(:disabled='!hasScriptPermission') {{$t('editor:props.scripts')}}
- v-tab(disabled) {{$t('editor:props.social')}}
+ //- v-tab(disabled) {{$t('editor:props.social')}}
v-tab(:disabled='!hasStylePermission') {{$t('editor:props.styles')}}
v-tab-item(transition='fade-transition', reverse-transition='fade-transition')
v-card-text.pt-5
@@ -196,42 +196,42 @@
.editor-props-codeeditor-hint
.caption {{$t('editor:props.htmlHint')}}
- v-tab-item(transition='fade-transition', reverse-transition='fade-transition')
- v-card-text
- .overline {{$t('editor:props.socialFeatures')}}
- v-switch(
- :label='$t(`editor:props.allowComments`)'
- v-model='isPublished'
- color='primary'
- :hint='$t(`editor:props.allowCommentsHint`)'
- persistent-hint
- inset
- )
- v-switch(
- :label='$t(`editor:props.allowRatings`)'
- v-model='isPublished'
- color='primary'
- :hint='$t(`editor:props.allowRatingsHint`)'
- persistent-hint
- disabled
- inset
- )
- v-switch(
- :label='$t(`editor:props.displayAuthor`)'
- v-model='isPublished'
- color='primary'
- :hint='$t(`editor:props.displayAuthorHint`)'
- persistent-hint
- inset
- )
- v-switch(
- :label='$t(`editor:props.displaySharingBar`)'
- v-model='isPublished'
- color='primary'
- :hint='$t(`editor:props.displaySharingBarHint`)'
- persistent-hint
- inset
- )
+ //- v-tab-item(transition='fade-transition', reverse-transition='fade-transition')
+ //- v-card-text
+ //- .overline {{$t('editor:props.socialFeatures')}}
+ //- v-switch(
+ //- :label='$t(`editor:props.allowComments`)'
+ //- v-model='isPublished'
+ //- color='primary'
+ //- :hint='$t(`editor:props.allowCommentsHint`)'
+ //- persistent-hint
+ //- inset
+ //- )
+ //- v-switch(
+ //- :label='$t(`editor:props.allowRatings`)'
+ //- v-model='isPublished'
+ //- color='primary'
+ //- :hint='$t(`editor:props.allowRatingsHint`)'
+ //- persistent-hint
+ //- disabled
+ //- inset
+ //- )
+ //- v-switch(
+ //- :label='$t(`editor:props.displayAuthor`)'
+ //- v-model='isPublished'
+ //- color='primary'
+ //- :hint='$t(`editor:props.displayAuthorHint`)'
+ //- persistent-hint
+ //- inset
+ //- )
+ //- v-switch(
+ //- :label='$t(`editor:props.displaySharingBar`)'
+ //- v-model='isPublished'
+ //- color='primary'
+ //- :hint='$t(`editor:props.displaySharingBarHint`)'
+ //- persistent-hint
+ //- inset
+ //- )
v-tab-item(:transition='false', :reverse-transition='false')
.editor-props-codeeditor-title
@@ -276,10 +276,10 @@ export default {
currentTab: 0,
cm: null,
rules: {
- required: value => !!value || 'This field is required.',
- path: value => {
- return filenamePattern.test(value) || 'Invalid path. Please ensure it does not contain special characters, or begin/end in a slash or hashtag string.'
- }
+ required: value => !!value || 'This field is required.',
+ path: value => {
+ return filenamePattern.test(value) || 'Invalid path. Please ensure it does not contain special characters, or begin/end in a slash or hashtag string.'
+ }
}
}
},
@@ -334,7 +334,7 @@ export default {
this.loadEditor(this.$refs.codejs, 'html')
}, 100)
})
- } else if (newValue === 4) {
+ } else if (newValue === 3) {
this.$nextTick(() => {
setTimeout(() => {
this.loadEditor(this.$refs.codecss, 'css')
diff --git a/client/components/login.vue b/client/components/login.vue
index f74a4744..0bbaa2a5 100644
--- a/client/components/login.vue
+++ b/client/components/login.vue
@@ -641,19 +641,25 @@ export default {
} else {
this.loaderColor = 'green darken-1'
this.loaderTitle = this.$t('auth:loginSuccess')
- Cookies.set('jwt', respObj.jwt, { expires: 365 })
+ Cookies.set('jwt', respObj.jwt, { expires: 365, secure: window.location.protocol === 'https:' })
_.delay(() => {
const loginRedirect = Cookies.get('loginRedirect')
+ const isValidRedirect = loginRedirect && loginRedirect.startsWith('/') && !loginRedirect.startsWith('//') && !loginRedirect.includes('://')
if (loginRedirect === '/' && respObj.redirect) {
Cookies.remove('loginRedirect')
window.location.replace(respObj.redirect)
- } else if (loginRedirect) {
+ } else if (isValidRedirect) {
Cookies.remove('loginRedirect')
window.location.replace(loginRedirect)
- } else if (respObj.redirect) {
- window.location.replace(respObj.redirect)
} else {
- window.location.replace('/')
+ if (loginRedirect) {
+ Cookies.remove('loginRedirect')
+ }
+ if (respObj.redirect) {
+ window.location.replace(respObj.redirect)
+ } else {
+ window.location.replace('/')
+ }
}
}, 1000)
}
diff --git a/client/components/profile/profile.vue b/client/components/profile/profile.vue
index 345476d2..dfb0bba8 100644
--- a/client/components/profile/profile.vue
+++ b/client/components/profile/profile.vue
@@ -129,41 +129,43 @@
//- v-btn(color='purple darken-4', disabled).ml-0 Enable 2FA
//- v-btn(color='purple darken-4', dark, depressed, disabled).ml-0 Disable 2FA
template(v-if='user.providerKey === `local`')
- v-divider.mt-3
- v-subheader.pl-0: span.subtitle-2 {{$t('profile:auth.changePassword')}}
- v-text-field(
- ref='iptCurrentPass'
- v-model='currentPass'
- outlined
- :label='$t(`profile:auth.currentPassword`)'
- type='password'
- prepend-inner-icon='mdi-form-textbox-password'
- )
- v-text-field(
- ref='iptNewPass'
- v-model='newPass'
- outlined
- :label='$t(`profile:auth.newPassword`)'
- type='password'
- prepend-inner-icon='mdi-form-textbox-password'
- autocomplete='off'
- counter='255'
- loading
- )
- password-strength(slot='progress', v-model='newPass')
- v-text-field(
- ref='iptVerifyPass'
- v-model='verifyPass'
- outlined
- :label='$t(`profile:auth.verifyPassword`)'
- type='password'
- prepend-inner-icon='mdi-form-textbox-password'
- autocomplete='off'
- hide-details
- )
+ form#change-password-form(@submit.prevent='changePassword')
+ v-divider.mt-3
+ v-subheader.pl-0: span.subtitle-2 {{$t('profile:auth.changePassword')}}
+ v-text-field(
+ ref='iptCurrentPass'
+ v-model='currentPass'
+ outlined
+ :label='$t(`profile:auth.currentPassword`)'
+ type='password'
+ prepend-inner-icon='mdi-form-textbox-password'
+ autocomplete='current-password'
+ )
+ v-text-field(
+ ref='iptNewPass'
+ v-model='newPass'
+ outlined
+ :label='$t(`profile:auth.newPassword`)'
+ type='password'
+ prepend-inner-icon='mdi-form-textbox-password'
+ autocomplete='off'
+ counter='255'
+ loading
+ )
+ password-strength(slot='progress', v-model='newPass')
+ v-text-field(
+ ref='iptVerifyPass'
+ v-model='verifyPass'
+ outlined
+ :label='$t(`profile:auth.verifyPassword`)'
+ type='password'
+ prepend-inner-icon='mdi-form-textbox-password'
+ autocomplete='off'
+ hide-details
+ )
v-card-chin(v-if='user.providerKey === `local`')
v-spacer
- v-btn.px-4(color='purple darken-4', dark, depressed, @click='changePassword', :loading='changePassLoading')
+ v-btn.px-4(color='purple darken-4', dark, depressed, :loading='changePassLoading', type='submit', form='change-password-form')
v-icon(left) mdi-progress-check
span {{$t('profile:auth.changePassword')}}
v-flex(lg6 xs12)
@@ -755,7 +757,7 @@ export default {
})
const resp = _.get(respRaw, 'data.users.updateProfile.responseResult', {})
if (resp.succeeded) {
- Cookies.set('jwt', _.get(respRaw, 'data.users.updateProfile.jwt', ''), { expires: 365 })
+ Cookies.set('jwt', _.get(respRaw, 'data.users.updateProfile.jwt', ''), { expires: 365, secure: window.location.protocol === 'https:' })
this.$store.set('user/name', this.user.name)
this.$store.commit('showNotification', {
message: this.$t('profile:save.success'),
@@ -863,7 +865,7 @@ export default {
this.currentPass = ''
this.newPass = ''
this.verifyPass = ''
- Cookies.set('jwt', _.get(respRaw, 'data.users.changePassword.jwt', ''), { expires: 365 })
+ Cookies.set('jwt', _.get(respRaw, 'data.users.changePassword.jwt', ''), { expires: 365, secure: window.location.protocol === 'https:' })
this.$store.commit('showNotification', {
message: this.$t('profile:auth.changePassSuccess'),
style: 'success',
diff --git a/client/components/source.vue b/client/components/source.vue
index 6da6906a..2e6b8baa 100644
--- a/client/components/source.vue
+++ b/client/components/source.vue
@@ -20,8 +20,7 @@
v-card.grey.radius-7(flat, :class='$vuetify.theme.dark ? `darken-4` : `lighten-4`')
v-card-text
pre
- code
- slot
+ slot
nav-footer
notify
diff --git a/client/components/tags.vue b/client/components/tags.vue
index 3d717b38..56bc6cbb 100644
--- a/client/components/tags.vue
+++ b/client/components/tags.vue
@@ -98,6 +98,7 @@
:search='innerSearch'
:loading='isLoading'
:options.sync='pagination'
+ @page-count='pageTotal = $event'
hide-default-footer
ref='dude'
)
@@ -183,6 +184,7 @@ export default {
sortDesc: [false]
},
pages: [],
+ pageTotal: 0,
isLoading: true,
scrollStyle: {
vuescroll: {},
@@ -214,9 +216,6 @@ export default {
tagsSelected () {
return _.filter(this.tags, t => _.includes(this.selection, t.tag))
},
- pageTotal () {
- return Math.ceil(this.pages.length / this.pagination.itemsPerPage)
- },
orderByItems () {
return [
{ text: this.$t('tags:orderByField.creationDate'), value: 'createdAt' },
diff --git a/client/store/site.js b/client/store/site.js
index 979468c7..0e3369de 100644
--- a/client/store/site.js
+++ b/client/store/site.js
@@ -5,6 +5,7 @@ import { make } from 'vuex-pathify'
const state = {
company: siteConfig.company,
contentLicense: siteConfig.contentLicense,
+ footerOverride: siteConfig.footerOverride,
dark: siteConfig.darkMode,
tocPosition: siteConfig.tocPosition,
mascot: true,
diff --git a/client/themes/default/components/nav-footer.vue b/client/themes/default/components/nav-footer.vue
index 08e44fcd..93368daa 100644
--- a/client/themes/default/components/nav-footer.vue
+++ b/client/themes/default/components/nav-footer.vue
@@ -1,7 +1,9 @@
v-footer.justify-center(:color='bgColor', inset)
.caption.grey--text(:class='$vuetify.theme.dark ? `text--lighten-1` : `text--darken-1`')
- template(v-if='company && company.length > 0 && contentLicense !== ``')
+ template(v-if='footerOverride')
+ span(v-html='footerOverrideRender + ` | `')
+ template(v-else-if='company && company.length > 0 && contentLicense !== ``')
span(v-if='contentLicense === `alr`') {{ $t('common:footer.copyright', { company: company, year: currentYear, interpolation: { escapeValue: false } }) }} |
span(v-else) {{ $t('common:footer.license', { company: company, license: $t('common:license.' + contentLicense), interpolation: { escapeValue: false } }) }} |
span {{ $t('common:footer.poweredBy') }} #[a(href='https://wiki.js.org', ref='nofollow') Wiki.js]
@@ -9,6 +11,13 @@
diff --git a/server/modules/analytics/umami2/definition.yml b/server/modules/analytics/umami2/definition.yml
new file mode 100644
index 00000000..49336a63
--- /dev/null
+++ b/server/modules/analytics/umami2/definition.yml
@@ -0,0 +1,17 @@
+key: umami2
+title: Umami Analytics v2
+description: Umami is a simple, fast, privacy-focused alternative to Google Analytics.
+author: CDN18
+logo: https://static.requarks.io/logo/umami.svg
+website: https://umami.is
+isAvailable: true
+props:
+ websiteID:
+ type: String
+ title: Website ID
+ order: 1
+ url:
+ type: String
+ title: Umami Server URL
+ hint: The URL of your Umami instance. It should start with http/https and omit the trailing slash. (e.g. https://umami.example.com)
+ order: 2
diff --git a/server/modules/authentication/azure/authentication.js b/server/modules/authentication/azure/authentication.js
index a983d148..ec2164a3 100644
--- a/server/modules/authentication/azure/authentication.js
+++ b/server/modules/authentication/azure/authentication.js
@@ -48,6 +48,19 @@ module.exports = {
picture: ''
}
})
+ if (conf.mapGroups) {
+ const groups = _.get(profile, '_json.groups')
+ if (groups && _.isArray(groups)) {
+ const currentGroups = (await user.$relatedQuery('groups').select('groups.id')).map(g => g.id)
+ const expectedGroups = Object.values(WIKI.auth.groups).filter(g => groups.includes(g.name)).map(g => g.id)
+ for (const groupId of _.difference(expectedGroups, currentGroups)) {
+ await user.$relatedQuery('groups').relate(groupId)
+ }
+ for (const groupId of _.difference(currentGroups, expectedGroups)) {
+ await user.$relatedQuery('groups').unrelate().where('groupId', groupId)
+ }
+ }
+ }
cb(null, user)
} catch (err) {
cb(err, null)
diff --git a/server/modules/authentication/azure/definition.yml b/server/modules/authentication/azure/definition.yml
index ad7d41eb..5c22d727 100644
--- a/server/modules/authentication/azure/definition.yml
+++ b/server/modules/authentication/azure/definition.yml
@@ -27,3 +27,9 @@ props:
title: Cookie Encryption Key String
hint: Random string with 44-character length. Setting this enables workaround for Chrome's SameSite cookies.
order: 3
+ mapGroups:
+ type: Boolean
+ title: Map Groups
+ hint: Map groups matching names from the groups claim value
+ default: false
+ order: 4
diff --git a/server/modules/authentication/github/authentication.js b/server/modules/authentication/github/authentication.js
index 49ac7609..aa0fee0e 100644
--- a/server/modules/authentication/github/authentication.js
+++ b/server/modules/authentication/github/authentication.js
@@ -27,6 +27,14 @@ module.exports = {
passport.use(conf.key,
new GitHubStrategy(githubConfig, async (req, accessToken, refreshToken, profile, cb) => {
try {
+ WIKI.logger.info(`GitHub OAuth: Processing profile for user ${profile.id || profile.username}`)
+
+ // Ensure email is available - passport-github2 should fetch it automatically with user:email scope
+ // but we'll log a warning if it's missing
+ if (!profile.emails || (Array.isArray(profile.emails) && profile.emails.length === 0)) {
+ WIKI.logger.warn(`GitHub OAuth: No email found in profile for user ${profile.id || profile.username}. Make sure 'user:email' scope is granted.`)
+ }
+
const user = await WIKI.models.users.processProfile({
providerKey: req.params.strategy,
profile: {
@@ -34,9 +42,19 @@ module.exports = {
picture: _.get(profile, 'photos[0].value', '')
}
})
+
+ WIKI.logger.info(`GitHub OAuth: Successfully authenticated user ${user.email}`)
cb(null, user)
} catch (err) {
- cb(err, null)
+ WIKI.logger.warn(`GitHub OAuth: Authentication failed for strategy ${req.params.strategy}:`, err)
+ // Provide more user-friendly error messages
+ if (err.message && err.message.includes('email')) {
+ cb(new Error('GitHub authentication failed: Email address is required but not available. Please ensure your GitHub account has a verified email address and grant email access permissions.'), null)
+ } else if (err instanceof WIKI.Error.AuthAccountBanned) {
+ cb(err, null)
+ } else {
+ cb(new Error(`GitHub authentication failed: ${err.message || 'Unknown error'}`), null)
+ }
}
}
))
diff --git a/server/modules/authentication/gitlab/authentication.js b/server/modules/authentication/gitlab/authentication.js
index 15d5229b..f060ad8e 100644
--- a/server/modules/authentication/gitlab/authentication.js
+++ b/server/modules/authentication/gitlab/authentication.js
@@ -15,6 +15,8 @@ module.exports = {
clientSecret: conf.clientSecret,
callbackURL: conf.callbackURL,
baseURL: conf.baseUrl,
+ authorizationURL: conf.authorizationURL || (conf.baseUrl + '/oauth/authorize'),
+ tokenURL: conf.tokenURL || (conf.baseUrl + '/oauth/token'),
scope: ['read_user'],
passReqToCallback: true
}, async (req, accessToken, refreshToken, profile, cb) => {
diff --git a/server/modules/authentication/gitlab/definition.yml b/server/modules/authentication/gitlab/definition.yml
index e18dccb8..ec26d613 100644
--- a/server/modules/authentication/gitlab/definition.yml
+++ b/server/modules/authentication/gitlab/definition.yml
@@ -24,3 +24,13 @@ props:
hint: For self-managed GitLab instances, define the base URL (e.g. https://gitlab.example.com). Leave default for GitLab.com SaaS (https://gitlab.com).
default: https://gitlab.com
order: 3
+ authorizationURL:
+ type: String
+ title: Authorization URL
+ hint: For self-managed GitLab instances, define an alternate authorization URL (e.g. http://example.com/oauth/authorize). Leave empty otherwise.
+ order: 4
+ tokenURL:
+ type: String
+ title: Token URL
+ hint: For self-managed GitLab instances, define an alternate token URL (e.g. http://example.com/oauth/token). Leave empty otherwise.
+ order: 5
diff --git a/server/modules/authentication/google/authentication.js b/server/modules/authentication/google/authentication.js
index 3af03cb2..3c8b17f7 100644
--- a/server/modules/authentication/google/authentication.js
+++ b/server/modules/authentication/google/authentication.js
@@ -16,9 +16,13 @@ module.exports = {
passReqToCallback: true
}, async (req, accessToken, refreshToken, profile, cb) => {
try {
- if (conf.hostedDomain && conf.hostedDomain != profile._json.hd) {
- throw new Error('Google authentication should have been performed with domain ' + conf.hostedDomain)
+ WIKI.logger.info(`Google OAuth: Processing profile for user ${profile.id || profile.displayName}`)
+
+ // Validate hosted domain if configured
+ if (conf.hostedDomain && profile._json.hd !== conf.hostedDomain) {
+ throw new Error(`Google authentication failed: User must be from domain ${conf.hostedDomain}, but got ${profile._json.hd || 'unknown'}`)
}
+
const user = await WIKI.models.users.processProfile({
providerKey: req.params.strategy,
profile: {
@@ -26,9 +30,21 @@ module.exports = {
picture: _.get(profile, 'photos[0].value', '')
}
})
+
+ WIKI.logger.info(`Google OAuth: Successfully authenticated user ${user.email}`)
cb(null, user)
} catch (err) {
- cb(err, null)
+ WIKI.logger.warn(`Google OAuth: Authentication failed for strategy ${req.params.strategy}:`, err)
+ // Provide more user-friendly error messages
+ if (err.message && err.message.includes('domain')) {
+ cb(new Error(`Google authentication failed: ${err.message}`), null)
+ } else if (err.message && err.message.includes('email')) {
+ cb(new Error('Google authentication failed: Email address is required but not available. Please ensure your Google account has a verified email address.'), null)
+ } else if (err instanceof WIKI.Error.AuthAccountBanned) {
+ cb(err, null)
+ } else {
+ cb(new Error(`Google authentication failed: ${err.message || 'Unknown error'}`), null)
+ }
}
})
diff --git a/server/modules/authentication/keycloak/authentication.js b/server/modules/authentication/keycloak/authentication.js
index ce9a00c5..34ceb5ea 100644
--- a/server/modules/authentication/keycloak/authentication.js
+++ b/server/modules/authentication/keycloak/authentication.js
@@ -21,7 +21,7 @@ module.exports = {
clientSecret: conf.clientSecret,
callbackURL: conf.callbackURL,
passReqToCallback: true
- }, async (req, accessToken, refreshToken, profile, cb) => {
+ }, async (req, accessToken, refreshToken, results, profile, cb) => {
let displayName = profile.username
if (_.isString(profile.fullName) && profile.fullName.length > 0) {
displayName = profile.fullName
@@ -36,6 +36,7 @@ module.exports = {
picture: ''
}
})
+ req.session.keycloak_id_token = results.id_token
cb(null, user)
} catch (err) {
cb(err, null)
@@ -43,11 +44,22 @@ module.exports = {
})
)
},
- logout (conf) {
+ logout (conf, context) {
if (!conf.logoutUpstream) {
return '/'
} else if (conf.logoutURL && conf.logoutURL.length > 5) {
- return `${conf.logoutURL}?redirect_uri=${encodeURIComponent(WIKI.config.host)}`
+ const idToken = context.req.session.keycloak_id_token
+ const redirURL = encodeURIComponent(WIKI.config.host)
+ if (conf.logoutUpstreamRedirectLegacy) {
+ // keycloak < 18
+ return `${conf.logoutURL}?redirect_uri=${redirURL}`
+ } else if (idToken) {
+ // keycloak 18+
+ return `${conf.logoutURL}?post_logout_redirect_uri=${redirURL}&id_token_hint=${idToken}`
+ } else {
+ // fall back to no redirect if keycloak_id_token isn't available
+ return conf.logoutURL
+ }
} else {
WIKI.logger.warn('Keycloak logout URL is not configured!')
return '/'
diff --git a/server/modules/authentication/keycloak/definition.yml b/server/modules/authentication/keycloak/definition.yml
index d4ab044c..b3f1d0df 100644
--- a/server/modules/authentication/keycloak/definition.yml
+++ b/server/modules/authentication/keycloak/definition.yml
@@ -35,17 +35,17 @@ props:
authorizationURL:
type: String
title: Authorization Endpoint URL
- hint: e.g. https://KEYCLOAK-HOST/auth/realms/YOUR-REALM/protocol/openid-connect/auth
+ hint: e.g. https://KEYCLOAK-HOST/realms/YOUR-REALM/protocol/openid-connect/auth
order: 5
tokenURL:
type: String
title: Token Endpoint URL
- hint: e.g. https://KEYCLOAK-HOST/auth/realms/YOUR-REALM/protocol/openid-connect/token
+ hint: e.g. https://KEYCLOAK-HOST/realms/YOUR-REALM/protocol/openid-connect/token
order: 6
userInfoURL:
type: String
title: User Info Endpoint URL
- hint: e.g. https://KEYCLOAK-HOST/auth/realms/YOUR-REALM/protocol/openid-connect/userinfo
+ hint: e.g. https://KEYCLOAK-HOST/realms/YOUR-REALM/protocol/openid-connect/userinfo
order: 7
logoutUpstream:
type: Boolean
@@ -55,6 +55,11 @@ props:
logoutURL:
type: String
title: Logout Endpoint URL
- hint: e.g. https://KEYCLOAK-HOST/auth/realms/YOUR-REALM/protocol/openid-connect/logout
+ hint: e.g. https://KEYCLOAK-HOST/realms/YOUR-REALM/protocol/openid-connect/logout
order: 9
+ logoutUpstreamRedirectLegacy:
+ type: Boolean
+ title: Legacy Logout Redirect
+ hint: Pass the legacy 'redirect_uri' parameter to the logout endpoint. Leave disabled for Keycloak 18 and above.
+ order: 10
diff --git a/server/modules/authentication/ldap/authentication.js b/server/modules/authentication/ldap/authentication.js
index 8f5a9817..29d21482 100644
--- a/server/modules/authentication/ldap/authentication.js
+++ b/server/modules/authentication/ldap/authentication.js
@@ -19,6 +19,13 @@ module.exports = {
searchBase: conf.searchBase,
searchFilter: conf.searchFilter,
tlsOptions: getTlsOptions(conf),
+ ...conf.mapGroups && {
+ groupSearchBase: conf.groupSearchBase,
+ groupSearchFilter: conf.groupSearchFilter,
+ groupSearchScope: conf.groupSearchScope,
+ groupDnProperty: conf.groupDnProperty,
+ groupSearchAttributes: [conf.groupNameField]
+ },
includeRaw: true
},
usernameField: 'email',
@@ -40,6 +47,21 @@ module.exports = {
picture: _.get(profile, `_raw.${conf.mappingPicture}`, '')
}
})
+ // map users LDAP groups to wiki groups with the same name, and remove any groups that don't match LDAP
+ if (conf.mapGroups) {
+ const ldapGroups = _.get(profile, '_groups')
+ if (ldapGroups && _.isArray(ldapGroups)) {
+ const groups = ldapGroups.map(g => g[conf.groupNameField])
+ const currentGroups = (await user.$relatedQuery('groups').select('groups.id')).map(g => g.id)
+ const expectedGroups = Object.values(WIKI.auth.groups).filter(g => groups.includes(g.name)).map(g => g.id)
+ for (const groupId of _.difference(expectedGroups, currentGroups)) {
+ await user.$relatedQuery('groups').relate(groupId)
+ }
+ for (const groupId of _.difference(currentGroups, expectedGroups)) {
+ await user.$relatedQuery('groups').unrelate().where('groupId', groupId)
+ }
+ }
+ }
cb(null, user)
} catch (err) {
if (WIKI.config.flags.ldapdebug) {
@@ -59,7 +81,7 @@ function getTlsOptions(conf) {
if (!conf.tlsCertPath) {
return {
- rejectUnauthorized: conf.verifyTLSCertificate,
+ rejectUnauthorized: conf.verifyTLSCertificate
}
}
diff --git a/server/modules/authentication/ldap/definition.yml b/server/modules/authentication/ldap/definition.yml
index 8b0b1b2d..193a9fc0 100644
--- a/server/modules/authentication/ldap/definition.yml
+++ b/server/modules/authentication/ldap/definition.yml
@@ -83,3 +83,39 @@ props:
hint: The field storing the user avatar picture. Usually "jpegPhoto" or "thumbnailPhoto".
maxWidth: 500
order: 23
+ mapGroups:
+ type: Boolean
+ title: Map Groups
+ hint: Map groups matching names from the users LDAP/Active Directory groups. Group Search Base must also be defined for this to work. Note this will remove any groups the user has that doesn't match an LDAP/Active Directory group.
+ default: false
+ order: 24
+ groupSearchBase:
+ type: String
+ title: Group Search Base
+ hint: The base DN from which to search for groups.
+ default: OU=groups,dc=example,dc=com
+ order: 25
+ groupSearchFilter:
+ type: String
+ title: Group Search Filter
+ hint: LDAP search filter for groups. (member={{dn}}) will use the distinguished name of the user and will work in most cases.
+ default: (member={{dn}})
+ order: 26
+ groupSearchScope:
+ type: String
+ title: Group Search Scope
+ hint: How far from the Group Search Base to search for groups. sub (default) will search the entire subtree. base, will only search the Group Search Base dn. one, will search the Group Search Base dn and one additional level.
+ default: sub
+ order: 27
+ groupDnProperty:
+ type: String
+ title: Group DN Property
+ hint: The property of user object to use in {{dn}} interpolation of Group Search Filter.
+ default: dn
+ order: 28
+ groupNameField:
+ type: String
+ title: Group Name Field
+ hint: The field that contains the name of the LDAP group to match on, usually "name" or "cn".
+ default: name
+ order: 29
diff --git a/server/modules/authentication/oauth2/authentication.js b/server/modules/authentication/oauth2/authentication.js
index a2285cff..6ac3e830 100644
--- a/server/modules/authentication/oauth2/authentication.js
+++ b/server/modules/authentication/oauth2/authentication.js
@@ -18,18 +18,34 @@ module.exports = {
userInfoURL: conf.userInfoURL,
callbackURL: conf.callbackURL,
passReqToCallback: true,
- scope: conf.scope
+ scope: conf.scope,
+ state: conf.enableCSRFProtection
}, async (req, accessToken, refreshToken, profile, cb) => {
try {
+ const picture = _.get(profile, conf.pictureClaim, '')
const user = await WIKI.models.users.processProfile({
providerKey: req.params.strategy,
profile: {
...profile,
id: _.get(profile, conf.userIdClaim),
displayName: _.get(profile, conf.displayNameClaim, '???'),
- email: _.get(profile, conf.emailClaim)
+ email: _.get(profile, conf.emailClaim),
+ picture: picture
}
})
+ if (conf.mapGroups) {
+ const groups = _.get(profile, conf.groupsClaim)
+ if (groups && _.isArray(groups)) {
+ const currentGroups = (await user.$relatedQuery('groups').select('groups.id')).map(g => g.id)
+ const expectedGroups = Object.values(WIKI.auth.groups).filter(g => groups.includes(g.name)).map(g => g.id)
+ for (const groupId of _.difference(expectedGroups, currentGroups)) {
+ await user.$relatedQuery('groups').relate(groupId)
+ }
+ for (const groupId of _.difference(currentGroups, expectedGroups)) {
+ await user.$relatedQuery('groups').unrelate().where('groupId', groupId)
+ }
+ }
+ }
cb(null, user)
} catch (err) {
cb(err, null)
diff --git a/server/modules/authentication/oauth2/definition.yml b/server/modules/authentication/oauth2/definition.yml
index 0621aa39..254bec0d 100644
--- a/server/modules/authentication/oauth2/definition.yml
+++ b/server/modules/authentication/oauth2/definition.yml
@@ -54,19 +54,45 @@ props:
default: email
maxWidth: 500
order: 8
+ pictureClaim:
+ type: String
+ title: Picture Claim
+ hint: Field containing the user avatar URL
+ default: picture
+ maxWidth: 500
+ order: 9
+ mapGroups:
+ type: Boolean
+ title: Map Groups
+ hint: Map groups matching names from the groups claim value
+ default: false
+ order: 10
+ groupsClaim:
+ type: String
+ title: Groups Claim
+ hint: Field containing the group names
+ default: groups
+ maxWidth: 500
+ order: 11
logoutURL:
type: String
title: Logout URL
hint: (optional) Logout URL on the OAuth2 provider where the user will be redirected to complete the logout process.
- order: 9
+ order: 12
scope:
type: String
title: Scope
hint: (optional) Application Client permission scopes.
- order: 10
+ order: 13
useQueryStringForAccessToken:
type: Boolean
default: false
title: Pass access token via GET query string to User Info Endpoint
hint: (optional) Pass the access token in an `access_token` parameter attached to the GET query string of the User Info Endpoint URL. Otherwise the access token will be passed in the Authorization header.
- order: 11
+ order: 14
+ enableCSRFProtection:
+ type: Boolean
+ default: true
+ title: Enable CSRF protection
+ hint: Pass a nonce state parameter during authentication to protect against CSRF attacks.
+ order: 15
diff --git a/server/modules/authentication/oidc/authentication.js b/server/modules/authentication/oidc/authentication.js
index de76da41..bfda8c2f 100644
--- a/server/modules/authentication/oidc/authentication.js
+++ b/server/modules/authentication/oidc/authentication.js
@@ -19,16 +19,21 @@ module.exports = {
issuer: conf.issuer,
userInfoURL: conf.userInfoURL,
callbackURL: conf.callbackURL,
- passReqToCallback: true
+ passReqToCallback: true,
+ skipUserProfile: conf.skipUserProfile,
+ acrValues: conf.acrValues
}, async (req, iss, uiProfile, idProfile, context, idToken, accessToken, refreshToken, params, cb) => {
const profile = Object.assign({}, idProfile, uiProfile)
+ const picture = _.get(profile, '_json.' + conf.pictureClaim, '')
try {
const user = await WIKI.models.users.processProfile({
providerKey: req.params.strategy,
profile: {
...profile,
- email: _.get(profile, '_json.' + conf.emailClaim)
+ email: _.get(profile, '_json.' + conf.emailClaim),
+ displayName: _.get(profile, '_json.' + conf.displayNameClaim, ''),
+ picture: picture
}
})
if (conf.mapGroups) {
diff --git a/server/modules/authentication/oidc/definition.yml b/server/modules/authentication/oidc/definition.yml
index ae1c636a..266ea7e9 100644
--- a/server/modules/authentication/oidc/definition.yml
+++ b/server/modules/authentication/oidc/definition.yml
@@ -37,33 +37,58 @@ props:
title: User Info Endpoint URL
hint: User Info Endpoint URL
order: 5
+ skipUserProfile:
+ type: Boolean
+ default: false
+ title: Skip User Profile
+ hint: Skips call to the OIDC UserInfo endpoint
+ order: 6
issuer:
type: String
title: Issuer
hint: Issuer URL
- order: 6
+ order: 7
emailClaim:
type: String
title: Email Claim
hint: Field containing the email address
default: email
maxWidth: 500
- order: 7
+ order: 8
+ displayNameClaim:
+ type: String
+ title: Display Name Claim
+ hint: Field containing the user display name
+ default: displayName
+ maxWidth: 500
+ order: 9
+ pictureClaim:
+ type: String
+ title: Picture Claim
+ hint: Field containing the user avatar URL
+ default: picture
+ maxWidth: 500
+ order: 10
mapGroups:
type: Boolean
title: Map Groups
hint: Map groups matching names from the groups claim value
default: false
- order: 8
+ order: 11
groupsClaim:
type: String
title: Groups Claim
hint: Field containing the group names
default: groups
maxWidth: 500
- order: 9
+ order: 12
logoutURL:
type: String
title: Logout URL
hint: (optional) Logout URL on the OAuth2 provider where the user will be redirected to complete the logout process.
- order: 10
+ order: 13
+ acrValues:
+ type: String
+ title: ACR Values
+ hint: (optional) Authentication Context Class Reference
+ order: 14
diff --git a/server/modules/authentication/rocketchat/authentication.js b/server/modules/authentication/rocketchat/authentication.js
index c966326e..d15c4ce9 100644
--- a/server/modules/authentication/rocketchat/authentication.js
+++ b/server/modules/authentication/rocketchat/authentication.js
@@ -12,7 +12,26 @@ module.exports = {
init (passport, conf) {
const siteURL = conf.siteURL.slice(-1) === '/' ? conf.siteURL.slice(0, -1) : conf.siteURL
- OAuth2Strategy.prototype.userProfile = function (accessToken, cb) {
+ const strategyInstance = new OAuth2Strategy({
+ authorizationURL: `${siteURL}/oauth/authorize`,
+ tokenURL: `${siteURL}/oauth/token`,
+ clientID: conf.clientId,
+ clientSecret: conf.clientSecret,
+ callbackURL: conf.callbackURL,
+ passReqToCallback: true
+ }, async (req, accessToken, refreshToken, profile, cb) => {
+ try {
+ const user = await WIKI.models.users.processProfile({
+ providerKey: req.params.strategy,
+ profile
+ })
+ cb(null, user)
+ } catch (err) {
+ cb(err, null)
+ }
+ })
+
+ strategyInstance.userProfile = function (accessToken, cb) {
this._oauth2.get(`${siteURL}/api/v1/me`, accessToken, (err, body, res) => {
if (err) {
WIKI.logger.warn('Rocket.chat - Failed to fetch user profile.')
@@ -33,26 +52,7 @@ module.exports = {
})
}
- passport.use(conf.key,
- new OAuth2Strategy({
- authorizationURL: `${siteURL}/oauth/authorize`,
- tokenURL: `${siteURL}/oauth/token`,
- clientID: conf.clientId,
- clientSecret: conf.clientSecret,
- callbackURL: conf.callbackURL,
- passReqToCallback: true
- }, async (req, accessToken, refreshToken, profile, cb) => {
- try {
- const user = await WIKI.models.users.processProfile({
- providerKey: req.params.strategy,
- profile
- })
- cb(null, user)
- } catch (err) {
- cb(err, null)
- }
- })
- )
+ passport.use(conf.key, strategyInstance)
},
logout (conf) {
if (!conf.logoutURL) {
diff --git a/server/modules/authentication/saml/authentication.js b/server/modules/authentication/saml/authentication.js
index 6eeef27a..13248907 100644
--- a/server/modules/authentication/saml/authentication.js
+++ b/server/modules/authentication/saml/authentication.js
@@ -56,6 +56,26 @@ module.exports = {
picture: _.get(profile, conf.mappingPicture, '')
}
})
+
+ // map users provider groups to wiki groups with the same name, and remove any groups that don't match
+ // Code copied from the LDAP implementation with a slight variation on the field we extract the value from
+ // In SAML v2 groups come in profile.attributes and can be 1 string or an array of strings
+ if (conf.mapGroups) {
+ const maybeArrayOfGroups = _.get(profile.attributes, conf.mappingGroups)
+ const groups = (maybeArrayOfGroups && !_.isArray(maybeArrayOfGroups)) ? [maybeArrayOfGroups] : maybeArrayOfGroups
+
+ if (groups && _.isArray(groups)) {
+ const currentGroups = (await user.$relatedQuery('groups').select('groups.id')).map(g => g.id)
+ const expectedGroups = Object.values(WIKI.auth.groups).filter(g => groups.includes(g.name)).map(g => g.id)
+ for (const groupId of _.difference(expectedGroups, currentGroups)) {
+ await user.$relatedQuery('groups').relate(groupId)
+ }
+ for (const groupId of _.difference(currentGroups, expectedGroups)) {
+ await user.$relatedQuery('groups').unrelate().where('groupId', groupId)
+ }
+ }
+ }
+
cb(null, user)
} catch (err) {
cb(err, null)
diff --git a/server/modules/authentication/saml/definition.yml b/server/modules/authentication/saml/definition.yml
index bfb24d15..c39dd731 100644
--- a/server/modules/authentication/saml/definition.yml
+++ b/server/modules/authentication/saml/definition.yml
@@ -162,3 +162,15 @@ props:
default: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/picture'
hint: The field storing the user avatar picture. Can be a variable name or a URI-formatted string.
order: 43
+ mapGroups:
+ type: Boolean
+ title: Map Groups
+ hint: Map groups matching names from the provider user groups. User Groups Field Mapping must also be defined for this to work. Note this will remove any groups the user has that doesn't match any group from the provider.
+ default: false
+ order: 44
+ mappingGroups:
+ title: User Groups Field Mapping
+ type: String
+ default: 'memberOf'
+ hint: The field storing the user groups attribute (when Map Groups is enabled). Can be a variable name or a URI-formatted string.
+ order: 45
diff --git a/server/modules/comments/artalk/code.yml b/server/modules/comments/artalk/code.yml
index dc61bd79..b2b3688a 100644
--- a/server/modules/comments/artalk/code.yml
+++ b/server/modules/comments/artalk/code.yml
@@ -6,7 +6,7 @@ head: |
body: |
|