diff --git a/assets/svg/auth-icon-azure.svg b/assets/svg/auth-icon-azure.svg new file mode 100644 index 00000000..cf13b912 --- /dev/null +++ b/assets/svg/auth-icon-azure.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/auth-icon-facebook.svg b/assets/svg/auth-icon-facebook.svg new file mode 100644 index 00000000..fa706aa2 --- /dev/null +++ b/assets/svg/auth-icon-facebook.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/svg/auth-icon-github.svg b/assets/svg/auth-icon-github.svg new file mode 100644 index 00000000..18e9450e --- /dev/null +++ b/assets/svg/auth-icon-github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/auth-icon-google.svg b/assets/svg/auth-icon-google.svg new file mode 100644 index 00000000..06dc52f0 --- /dev/null +++ b/assets/svg/auth-icon-google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/auth-icon-ldap.svg b/assets/svg/auth-icon-ldap.svg new file mode 100644 index 00000000..679fc237 --- /dev/null +++ b/assets/svg/auth-icon-ldap.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/assets/svg/auth-icon-microsoft.svg b/assets/svg/auth-icon-microsoft.svg new file mode 100644 index 00000000..0d47e89a --- /dev/null +++ b/assets/svg/auth-icon-microsoft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/svg/auth-icon-slack.svg b/assets/svg/auth-icon-slack.svg new file mode 100644 index 00000000..47232d6d --- /dev/null +++ b/assets/svg/auth-icon-slack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/scss/login.scss b/client/scss/login.scss deleted file mode 100644 index 725ce498..00000000 --- a/client/scss/login.scss +++ /dev/null @@ -1,13 +0,0 @@ -@charset "utf-8"; - -$primary: 'indigo'; - -@import "base/variables"; -@import "base/colors"; -@import "base/reset"; -@import "base/mixins"; -@import "base/fonts"; -@import "base/base"; - -@import "libs/animate"; -@import 'pages/login'; diff --git a/client/scss/pages/_login.scss b/client/scss/pages/_login.scss index 284bf7f6..440148c0 100644 --- a/client/scss/pages/_login.scss +++ b/client/scss/pages/_login.scss @@ -9,23 +9,65 @@ justify-content: center; &-container { + position: relative; display: flex; - width: 650px; + width: 450px; align-items: stretch; box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22); + + &.is-expanded { + width: 650px; + } + + @include until($tablet) { + width: 350px; + + &.is-expanded { + width: 400px; + } + } + } + + &-error { + position: absolute; + bottom: 100%; + width: 100%; + min-height: 50px; + background-image: radial-gradient(ellipse at top left, rgba(mc('red', '900'),.9) 0%,rgba(mc('red', '400'),.8) 100%); + border: 1px solid #FFF; + color: #FFF; + display: flex; + justify-content: center; + align-items: center; + + strong { + font-weight: 600; + text-transform: uppercase; + display: block; + padding: 0 1rem 0 0; + border-right: 1px solid #FFF; + } + span { + padding: 0 0 0 1rem; + display: block; + } } &-providers { display: flex; flex-direction: column; - width: 200px; + width: 250px; border: 1px solid #FFF; background-color: mc('grey', '900'); z-index: 1; + @include until($tablet) { + width: 50px; + } + button { flex: 1 1; - padding: 0 15px; + padding: 5px 15px; border: none; color: #FFF; background-color: mc('grey', '800'); @@ -34,6 +76,18 @@ font-weight: 600; text-align: left; min-height: 40px; + display: flex; + justify-content: flex-start; + align-items: center; + transition: all .4s ease; + + @include until($tablet) { + justify-content: center; + } + + &:hover { + background-color: mc('grey', '600'); + } &:first-child { border-top: none; @@ -48,10 +102,36 @@ i { margin-right: 10px; font-size: 16px; + + @include until($tablet) { + margin-right: 0; + font-size: 20px; + } + } + + svg { + margin-right: 10px; + width: auto; + height: 20px; + max-width: 18px; + max-height: 20px; + + path { + fill: #FFF; + } + + @include until($tablet) { + margin-right: 0; + font-size: 20px; + } } span { font-weight: 600; + + @include until($tablet) { + display: none; + } } } } @@ -59,7 +139,7 @@ &-frame { background-image: radial-gradient(circle at top left, rgba(255,255,255,1) 0%,rgba(240,240,240,.6) 100%); border: 1px solid #FFF; - width: 450px; + width: 400px; padding: 1rem; color: mc('grey', '700'); display: flex; @@ -67,6 +147,10 @@ flex-direction: column; text-align: center; + @include until($tablet) { + width: 350px; + } + h1 { font-size: 2rem; font-weight: 600; @@ -83,29 +167,6 @@ margin: 0 0 25px 0; } - h3 { - font-size: 1.25rem; - font-weight: normal; - color: #FB8C00; - padding: 0; - margin: 0; - animation: shake 1s ease; - - > .fa { - margin-right: 7px; - } - - } - - h4 { - font-size: .8rem; - font-weight: normal; - color: rgba(255,255,255,0.7); - padding: 0; - margin: 0 0 15px 0; - animation: fadeIn 3s ease; - } - form { display: flex; flex-direction: column; @@ -147,14 +208,10 @@ font-weight: 400; text-shadow: 1px 1px 0 #000; - .icon { - font-size: 1.2rem; - margin: 0 8px; - } - a { font-weight: 600; color: #FFF; + margin-left: .25rem; } } diff --git a/server/authentication/azure.js b/server/authentication/azure.js index c54830ef..c6d71b4d 100644 --- a/server/authentication/azure.js +++ b/server/authentication/azure.js @@ -8,24 +8,29 @@ const AzureAdOAuth2Strategy = require('passport-azure-ad-oauth2').Strategy -module.exports = (passport, conf) => { - const jwt = require('jsonwebtoken') - passport.use('azure_ad_oauth2', - new AzureAdOAuth2Strategy({ - clientID: conf.clientId, - clientSecret: conf.clientSecret, - callbackURL: conf.callbackURL, - resource: conf.resource, - tenant: conf.tenant - }, (accessToken, refreshToken, params, profile, cb) => { - let waadProfile = jwt.decode(params.id_token) - waadProfile.id = waadProfile.oid - waadProfile.provider = 'azure' - wiki.db.User.processProfile(waadProfile).then((user) => { - return cb(null, user) || true - }).catch((err) => { - return cb(err, null) || true - }) - } - )) +module.exports = { + key: 'azure', + title: 'Azure Active Directory', + props: ['clientId', 'clientSecret', 'callbackURL', 'resource', 'tenant'], + init (passport, conf) { + const jwt = require('jsonwebtoken') + passport.use('azure_ad_oauth2', + new AzureAdOAuth2Strategy({ + clientID: conf.clientId, + clientSecret: conf.clientSecret, + callbackURL: conf.callbackURL, + resource: conf.resource, + tenant: conf.tenant + }, (accessToken, refreshToken, params, profile, cb) => { + let waadProfile = jwt.decode(params.id_token) + waadProfile.id = waadProfile.oid + waadProfile.provider = 'azure' + wiki.db.User.processProfile(waadProfile).then((user) => { + return cb(null, user) || true + }).catch((err) => { + return cb(err, null) || true + }) + } + )) + } } diff --git a/server/authentication/facebook.js b/server/authentication/facebook.js index 1a1d3822..03375dac 100644 --- a/server/authentication/facebook.js +++ b/server/authentication/facebook.js @@ -8,19 +8,24 @@ const FacebookStrategy = require('passport-facebook').Strategy -module.exports = (passport, conf) => { - passport.use('facebook', - new FacebookStrategy({ - clientID: conf.clientId, - clientSecret: conf.clientSecret, - callbackURL: conf.callbackURL, - profileFields: ['id', 'displayName', 'email'] - }, function (accessToken, refreshToken, profile, cb) { - wiki.db.User.processProfile(profile).then((user) => { - return cb(null, user) || true - }).catch((err) => { - return cb(err, null) || true - }) - } - )) +module.exports = { + key: 'facebook', + title: 'Facebook', + props: ['clientId', 'clientSecret', 'callbackURL'], + init (passport, conf) { + passport.use('facebook', + new FacebookStrategy({ + clientID: conf.clientId, + clientSecret: conf.clientSecret, + callbackURL: conf.callbackURL, + profileFields: ['id', 'displayName', 'email'] + }, function (accessToken, refreshToken, profile, cb) { + wiki.db.User.processProfile(profile).then((user) => { + return cb(null, user) || true + }).catch((err) => { + return cb(err, null) || true + }) + } + )) + } } diff --git a/server/authentication/github.js b/server/authentication/github.js index 7c225e6a..ff3aec3b 100644 --- a/server/authentication/github.js +++ b/server/authentication/github.js @@ -8,19 +8,24 @@ const GitHubStrategy = require('passport-github2').Strategy -module.exports = (passport, conf) => { - passport.use('github', - new GitHubStrategy({ - clientID: conf.clientId, - clientSecret: conf.clientSecret, - callbackURL: conf.callbackURL, - scope: ['user:email'] - }, (accessToken, refreshToken, profile, cb) => { - wiki.db.User.processProfile(profile).then((user) => { - return cb(null, user) || true - }).catch((err) => { - return cb(err, null) || true - }) - } - )) +module.exports = { + key: 'github', + title: 'GitHub', + props: ['clientId', 'clientSecret', 'callbackURL'], + init (passport, conf) { + passport.use('github', + new GitHubStrategy({ + clientID: conf.clientId, + clientSecret: conf.clientSecret, + callbackURL: conf.callbackURL, + scope: ['user:email'] + }, (accessToken, refreshToken, profile, cb) => { + wiki.db.User.processProfile(profile).then((user) => { + return cb(null, user) || true + }).catch((err) => { + return cb(err, null) || true + }) + } + )) + } } diff --git a/server/authentication/google.js b/server/authentication/google.js index 9531f4a8..371f4c30 100644 --- a/server/authentication/google.js +++ b/server/authentication/google.js @@ -8,18 +8,23 @@ const GoogleStrategy = require('passport-google-oauth20').Strategy -module.exports = (passport, conf) => { - passport.use('google', - new GoogleStrategy({ - clientID: conf.clientId, - clientSecret: conf.clientSecret, - callbackURL: conf.callbackURL - }, (accessToken, refreshToken, profile, cb) => { - wiki.db.User.processProfile(profile).then((user) => { - return cb(null, user) || true - }).catch((err) => { - return cb(err, null) || true - }) - } - )) +module.exports = { + key: 'google', + title: 'Google ID', + props: ['clientId', 'clientSecret', 'callbackURL'], + init (passport, conf) { + passport.use('google', + new GoogleStrategy({ + clientID: conf.clientId, + clientSecret: conf.clientSecret, + callbackURL: conf.callbackURL + }, (accessToken, refreshToken, profile, cb) => { + wiki.db.User.processProfile(profile).then((user) => { + return cb(null, user) || true + }).catch((err) => { + return cb(err, null) || true + }) + } + )) + } } diff --git a/server/authentication/ldap.js b/server/authentication/ldap.js index 0eb4ccbe..c7a993db 100644 --- a/server/authentication/ldap.js +++ b/server/authentication/ldap.js @@ -9,32 +9,37 @@ const LdapStrategy = require('passport-ldapauth').Strategy const fs = require('fs') -module.exports = (passport, conf) => { - passport.use('ldapauth', - new LdapStrategy({ - server: { - url: conf.url, - bindDn: conf.bindDn, - bindCredentials: conf.bindCredentials, - searchBase: conf.searchBase, - searchFilter: conf.searchFilter, - searchAttributes: ['displayName', 'name', 'cn', 'mail'], - tlsOptions: (conf.tlsEnabled) ? { - ca: [ - fs.readFileSync(conf.tlsCertPath) - ] - } : {} - }, - usernameField: 'email', - passReqToCallback: false - }, (profile, cb) => { - profile.provider = 'ldap' - profile.id = profile.dn - wiki.db.User.processProfile(profile).then((user) => { - return cb(null, user) || true - }).catch((err) => { - return cb(err, null) || true - }) - } - )) +module.exports = { + key: 'ldap', + title: 'LDAP / Active Directory', + props: ['url', 'bindDn', 'bindCredentials', 'searchBase', 'searchFilter', 'tlsEnabled', 'tlsCertPath'], + init (passport, conf) { + passport.use('ldapauth', + new LdapStrategy({ + server: { + url: conf.url, + bindDn: conf.bindDn, + bindCredentials: conf.bindCredentials, + searchBase: conf.searchBase, + searchFilter: conf.searchFilter, + searchAttributes: ['displayName', 'name', 'cn', 'mail'], + tlsOptions: (conf.tlsEnabled) ? { + ca: [ + fs.readFileSync(conf.tlsCertPath) + ] + } : {} + }, + usernameField: 'email', + passReqToCallback: false + }, (profile, cb) => { + profile.provider = 'ldap' + profile.id = profile.dn + wiki.db.User.processProfile(profile).then((user) => { + return cb(null, user) || true + }).catch((err) => { + return cb(err, null) || true + }) + } + )) + } } diff --git a/server/authentication/local.js b/server/authentication/local.js index ae00171b..1c4c7c07 100644 --- a/server/authentication/local.js +++ b/server/authentication/local.js @@ -8,25 +8,30 @@ const LocalStrategy = require('passport-local').Strategy -module.exports = (passport, conf) => { - passport.use('local', - new LocalStrategy({ - usernameField: 'email', - passwordField: 'password' - }, (uEmail, uPassword, done) => { - wiki.db.User.findOne({ email: uEmail, provider: 'local' }).then((user) => { - if (user) { - return user.validatePassword(uPassword).then(() => { - return done(null, user) || true - }).catch((err) => { - return done(err, null) - }) - } else { - return done(new Error('INVALID_LOGIN'), null) - } - }).catch((err) => { - done(err, null) - }) - } - )) +module.exports = { + key: 'local', + title: 'Local', + props: [], + init (passport, conf) { + passport.use('local', + new LocalStrategy({ + usernameField: 'email', + passwordField: 'password' + }, (uEmail, uPassword, done) => { + wiki.db.User.findOne({ email: uEmail, provider: 'local' }).then((user) => { + if (user) { + return user.validatePassword(uPassword).then(() => { + return done(null, user) || true + }).catch((err) => { + return done(err, null) + }) + } else { + return done(new Error('INVALID_LOGIN'), null) + } + }).catch((err) => { + done(err, null) + }) + } + )) + } } diff --git a/server/authentication/microsoft.js b/server/authentication/microsoft.js index 6ccd1b88..e8069de2 100644 --- a/server/authentication/microsoft.js +++ b/server/authentication/microsoft.js @@ -8,18 +8,23 @@ const WindowsLiveStrategy = require('passport-windowslive').Strategy -module.exports = (passport, conf) => { - passport.use('windowslive', - new WindowsLiveStrategy({ - clientID: conf.clientId, - clientSecret: conf.clientSecret, - callbackURL: conf.callbackURL - }, function (accessToken, refreshToken, profile, cb) { - wiki.db.User.processProfile(profile).then((user) => { - return cb(null, user) || true - }).catch((err) => { - return cb(err, null) || true - }) - } - )) +module.exports = { + key: 'microsoft', + title: 'Microsoft Account', + props: ['clientId', 'clientSecret', 'callbackURL'], + init (passport, conf) { + passport.use('windowslive', + new WindowsLiveStrategy({ + clientID: conf.clientId, + clientSecret: conf.clientSecret, + callbackURL: conf.callbackURL + }, function (accessToken, refreshToken, profile, cb) { + wiki.db.User.processProfile(profile).then((user) => { + return cb(null, user) || true + }).catch((err) => { + return cb(err, null) || true + }) + } + )) + } } diff --git a/server/authentication/slack.js b/server/authentication/slack.js index 778d6abf..17b7e0ae 100644 --- a/server/authentication/slack.js +++ b/server/authentication/slack.js @@ -8,18 +8,23 @@ const SlackStrategy = require('passport-slack').Strategy -module.exports = (passport, conf) => { - passport.use('slack', - new SlackStrategy({ - clientID: conf.clientId, - clientSecret: conf.clientSecret, - callbackURL: conf.callbackURL - }, (accessToken, refreshToken, profile, cb) => { - wiki.db.User.processProfile(profile).then((user) => { - return cb(null, user) || true - }).catch((err) => { - return cb(err, null) || true - }) - } - )) +module.exports = { + key: 'slack', + title: 'Slack', + props: ['clientId', 'clientSecret', 'callbackURL'], + init (passport, conf) { + passport.use('slack', + new SlackStrategy({ + clientID: conf.clientId, + clientSecret: conf.clientSecret, + callbackURL: conf.callbackURL + }, (accessToken, refreshToken, profile, cb) => { + wiki.db.User.processProfile(profile).then((user) => { + return cb(null, user) || true + }).catch((err) => { + return cb(err, null) || true + }) + } + )) + } } diff --git a/server/controllers/auth.js b/server/controllers/auth.js index a29e2873..179dbae8 100644 --- a/server/controllers/auth.js +++ b/server/controllers/auth.js @@ -37,7 +37,8 @@ const bruteforce = new ExpressBrute(EBstore, { */ router.get('/login', function (req, res, next) { res.render('auth/login', { - usr: res.locals.usr + authStrategies: wiki.auth.strategies, + hasMultipleStrategies: Object.keys(wiki.config.auth.strategies).length > 0 }) }) diff --git a/server/master.js b/server/master.js index 844eee05..9199bc71 100644 --- a/server/master.js +++ b/server/master.js @@ -16,6 +16,7 @@ module.exports = Promise.join( // Load global modules // ---------------------------------------- + wiki.auth = require('./modules/auth').init() wiki.disk = require('./modules/disk').init() wiki.docs = require('./modules/documents').init() wiki.git = require('./modules/git').init(false) @@ -38,7 +39,6 @@ module.exports = Promise.join( const http = require('http') const i18nBackend = require('i18next-node-fs-backend') const path = require('path') - const passport = require('passport') const passportSocketIo = require('passport.socketio') const session = require('express-session') const SessionRedisStore = require('connect-redis')(session) @@ -78,10 +78,6 @@ module.exports = Promise.join( // Passport Authentication // ---------------------------------------- - require('./modules/auth').init(passport) - wiki.rights = require('./modules/rights') - // wiki.rights.init() - let sessionStore = new SessionRedisStore({ client: wiki.redis }) @@ -95,8 +91,8 @@ module.exports = Promise.join( saveUninitialized: false })) app.use(flash()) - app.use(passport.initialize()) - app.use(passport.session()) + app.use(wiki.auth.passport.initialize()) + app.use(wiki.auth.passport.session()) // ---------------------------------------- // SEO @@ -135,6 +131,7 @@ module.exports = Promise.join( // View accessible data // ---------------------------------------- + app.locals.basedir = wiki.ROOTPATH app.locals._ = require('lodash') app.locals.t = wiki.lang.t.bind(wiki.lang) app.locals.moment = require('moment') diff --git a/server/middlewares/flash.js b/server/middlewares/flash.js index 98182b47..09e4adf0 100644 --- a/server/middlewares/flash.js +++ b/server/middlewares/flash.js @@ -9,7 +9,7 @@ * @return {any} void */ module.exports = (req, res, next) => { - res.locals.appflash = req.flash('alert') + res.locals.flash = req.flash('alert') next() } diff --git a/server/modules/auth.js b/server/modules/auth.js index 2fd96206..95befaeb 100644 --- a/server/modules/auth.js +++ b/server/modules/auth.js @@ -3,10 +3,16 @@ /* global wiki */ const _ = require('lodash') +const passport = require('passport') +const fs = require('fs-extra') +const path = require('path') module.exports = { - init(passport) { - // Serialization user methods + strategies: {}, + init() { + this.passport = passport + + // Serialization user methods passport.serializeUser(function (user, done) { done(null, user._id) @@ -27,20 +33,26 @@ module.exports = { // Load authentication strategies - wiki.config.authStrategies = { - list: _.pickBy(wiki.config.auth, strategy => strategy.enabled), - socialEnabled: (_.chain(wiki.config.auth).omit('local').filter(['enabled', true]).value().length > 0) - } - - _.forOwn(wiki.config.authStrategies.list, (strategyConfig, strategyName) => { - strategyConfig.callbackURL = `${wiki.config.site.host}/login/${strategyName}/callback` - require(`../authentication/${strategyName}`)(passport, strategyConfig) - wiki.logger.info(`Authentication Provider ${_.upperFirst(strategyName)}: OK`) + _.forOwn(wiki.config.auth.strategies, (strategyConfig, strategyKey) => { + strategyConfig.callbackURL = `${wiki.config.site.host}${wiki.config.site.path}/login/${strategyKey}/callback` + let strategy = require(`../authentication/${strategyKey}`) + strategy.init(passport, strategyConfig) + fs.readFile(path.join(wiki.ROOTPATH, `assets/svg/auth-icon-${strategyKey}.svg`), 'utf8').then(iconData => { + strategy.icon = iconData + }).catch(err => { + if (err.code === 'ENOENT') { + strategy.icon = '[missing icon]' + } else { + wiki.logger.error(err) + } + }) + this.strategies[strategy.key] = strategy + wiki.logger.info(`Authentication Provider ${strategyKey}: OK`) }) // Create Guest account for first-time - return wiki.db.User.findOne({ + wiki.db.User.findOne({ where: { provider: 'local', email: 'guest@example.com' @@ -88,5 +100,7 @@ module.exports = { // }) // } else { return true } // }) + + return this } } diff --git a/server/views/auth/login.pug b/server/views/auth/login.pug index 14105179..fad51aff 100644 --- a/server/views/auth/login.pug +++ b/server/views/auth/login.pug @@ -3,52 +3,30 @@ extends ../master.pug block body body .login#root - .login-container - if config.authStrategies.socialEnabled + .login-container(:class={ "is-expanded": hasMultipleStrategies }) + if flash.length > 0 + .login-error + strong + i.icon-warning-outline + = flash[0].title + span= flash[0].message + if hasMultipleStrategies .login-providers - button.is-active(onclick='window.location.assign("/login/ms")') + button.is-active(title=t('auth:providers.local')) i.nc-icon-outline.ui-1_database span= t('auth:providers.local') - if config.auth.microsoft && config.auth.microsoft.enabled - button(onclick='window.location.assign("/login/ms")') - i.icon-windows2 - span= t('auth:providers.windowslive') - if config.auth.azure && config.auth.azure.enabled - button(onclick='window.location.assign("/login/azure")') - i.icon-windows2 - span= t('auth:providers.azure') - if config.auth.google && config.auth.google.enabled - button(onclick='window.location.assign("/login/google")') - i.icon-google - span= t('auth:providers.google') - if config.auth.facebook && config.auth.facebook.enabled - button(onclick='window.location.assign("/login/facebook")') - i.icon-facebook - span= t('auth:providers.facebook') - if config.auth.github && config.auth.github.enabled - button(onclick='window.location.assign("/login/github")') - i.icon-github - span= t('auth:providers.github') - if config.auth.slack && config.auth.slack.enabled - button(onclick='window.location.assign("/login/slack")') - i.icon-slack - span= t('auth:providers.slack') + each strategy in authStrategies + button(onclick='window.location.assign("/login/' + strategy.key + '")', title=strategy.title) + != strategy.icon + span= strategy.title .login-frame h1= config.site.title h2= t('auth:loginrequired') - if appflash.length > 0 - h3 - i.icon-warning-outline - = appflash[0].title - h4= appflash[0].message - if config.auth.local.enabled - form(method='post', action='/login') - input#login-user(type='text', name='email', placeholder=t('auth:fields.emailuser')) - input#login-pass(type='password', name='password', placeholder=t('auth:fields.password')) - button.button.is-light-green.is-fullwidth(type='submit') - span= t('auth:actions.login') + form(method='post', action='/login') + input#login-user(type='text', name='email', placeholder=t('auth:fields.emailuser')) + input#login-pass(type='password', name='password', placeholder=t('auth:fields.password')) + button.button.is-light-green.is-fullwidth(type='submit') + span= t('auth:actions.login') .login-copyright - = t('footer.poweredby') + ' ' - a.icon(href='https://github.com/Requarks/wiki') - i.icon-github - a(href='https://wiki.requarks.io/') Wiki.js + = t('footer.poweredby') + a(href='https://wiki.js.org', rel='external', title='Wiki.js') Wiki.js