Преглед изворни кода

Merge branch 'master' into feat/editor-navbar-buttons

utsushiiro пре 7 година
родитељ
комит
4df8635f5d
64 измењених фајлова са 1617 додато и 744 уклоњено
  1. 19 0
      .vscode/extensions.json
  2. 34 1
      CHANGES.md
  3. 17 23
      README.md
  4. 1 1
      config/migrate.js
  5. 8 8
      package.json
  6. 11 10
      resource/cdn-manifests.js
  7. 1 1
      resource/locales/en-US/sandbox.md
  8. 77 11
      resource/locales/en-US/translation.json
  9. 1 1
      resource/locales/ja/sandbox.md
  10. 77 10
      resource/locales/ja/translation.json
  11. 102 0
      src/client/js/components/PageEditor/Cheatsheet.js
  12. 12 126
      src/client/js/components/PageEditor/CodeMirrorEditor.js
  13. 15 7
      src/client/js/components/PageEditor/Editor.jsx
  14. 51 0
      src/client/js/components/PageEditor/SimpleCheatsheet.js
  15. 6 5
      src/client/js/components/PageEditor/TextAreaEditor.js
  16. 30 20
      src/client/js/util/GrowiRenderer.js
  17. 2 2
      src/client/js/util/interceptor/detach-code-blocks.js
  18. 24 0
      src/client/styles/scss/_admin.scss
  19. 5 6
      src/server/crowi/express-init.js
  20. 1 1
      src/server/events/user.js
  21. 0 1
      src/server/form/admin/app.js
  22. 6 6
      src/server/form/admin/securityPassportSaml.js
  23. 8 0
      src/server/form/admin/siteUrl.js
  24. 1 0
      src/server/form/index.js
  25. 1 16
      src/server/models/config.js
  26. 49 18
      src/server/models/page.js
  27. 3 2
      src/server/models/user.js
  28. 64 39
      src/server/routes/admin.js
  29. 1 2
      src/server/routes/attachment.js
  30. 1 7
      src/server/routes/hackmd.js
  31. 7 6
      src/server/routes/index.js
  32. 8 8
      src/server/routes/login-passport.js
  33. 4 4
      src/server/routes/login.js
  34. 2 2
      src/server/routes/me.js
  35. 1 4
      src/server/routes/page.js
  36. 2 1
      src/server/routes/revision.js
  37. 62 0
      src/server/service/config-loader.js
  38. 136 43
      src/server/service/config-manager.js
  39. 1 1
      src/server/service/file-uploader/gridfs.js
  40. 49 14
      src/server/service/passport.js
  41. 4 3
      src/server/util/googleAuth.js
  42. 2 2
      src/server/util/middlewares.js
  43. 6 7
      src/server/util/search.js
  44. 12 15
      src/server/util/slack.js
  45. 30 0
      src/server/util/swigFunctions.js
  46. 169 41
      src/server/views/admin/app.html
  47. 7 9
      src/server/views/admin/customize.html
  48. 10 9
      src/server/views/admin/external-accounts.html
  49. 4 2
      src/server/views/admin/user-group-detail.html
  50. 12 12
      src/server/views/admin/users.html
  51. 3 2
      src/server/views/admin/widget/passport/github.html
  52. 3 2
      src/server/views/admin/widget/passport/google-oauth.html
  53. 329 135
      src/server/views/admin/widget/passport/saml.html
  54. 3 2
      src/server/views/admin/widget/passport/twitter.html
  55. 4 0
      src/server/views/layout-crowi/base/layout.html
  56. 4 0
      src/server/views/layout-growi/base/layout.html
  57. 4 0
      src/server/views/layout-kibela/base/layout.html
  58. 3 1
      src/server/views/layout/layout.html
  59. 1 1
      src/server/views/modal/duplicate.html
  60. 1 1
      src/server/views/modal/rename.html
  61. 3 0
      src/server/views/search.html
  62. 6 0
      src/server/views/widget/alert_siteurl_undefined.html
  63. 25 7
      src/test/models/page.test.js
  64. 72 86
      yarn.lock

+ 19 - 0
.vscode/extensions.json

@@ -0,0 +1,19 @@
+{
+	// See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
+	// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
+
+	// List of extensions which should be recommended for users of this workspace.
+	"recommendations": [
+    "hookyqr.beautify",
+    "msjsdiag.debugger-for-chrome",
+    "hbenl.vscode-firefox-debug",
+    "editorconfig.editorconfig",
+    "dbaeumer.vscode-eslint",
+    "eg2.vscode-npm-script",
+    "christian-kohler.npm-intellisense",
+	],
+	// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
+	"unwantedRecommendations": [
+
+	]
+}

+ 34 - 1
CHANGES.md

@@ -1,10 +1,43 @@
 CHANGES
 CHANGES
 ========
 ========
 
 
-## 3.3.4-RC
+## 3.3.7-RC
 
 
 * 
 * 
 
 
+## 3.3.6
+
+* Improvement: Site URL settings must be set
+* Improvement: Site URL settings can be set with environment variable
+* Fix: "Anyone with the link" ACL doesn't work correctly
+    * Introduced 3.3.0
+* Fix: Related pages list of /admin/user-group-detail/xxx doesn't show anything
+    * Introduced 3.3.0
+* Fix: Diff of revision contents doesn't appeared when notifing with slack
+* Fix: NPE occured on /admin/security when Crowi Classic Auth Mechanism is set
+* Fix: Coudn't render Timing Diagram with PlantUML
+* I18n: Cheatsheet for editor
+* I18n: Some admin pages
+* Support: Upgrade libs
+    * diff
+    * markdown-it-plantuml
+    * mongoose
+    * nodemailer
+    * mongoose-gridfs
+    * sinon
+    * sinon-chai
+
+## 3.3.5 (Missing number)
+
+## 3.3.4
+
+* Improvement: SAML configuration with environment variables
+* Improvement: Upload file with pasting from clipboard
+* Fix: `/_api/revisions.get` doesn't populate author data correctly
+* Fix: Wrong OAuth callback url are shown at admin page
+* Fix: Connecting to MongoDB failed when processing migration
+* Support: Get ready to use new config management system
+
 ## 3.3.3
 ## 3.3.3
 
 
 * Feature: Show line numbers to a code block
 * Feature: Show line numbers to a code block

+ 17 - 23
README.md

@@ -42,8 +42,9 @@ Features
 * **Features**
 * **Features**
   * Create hierarchical pages with markdown
   * Create hierarchical pages with markdown
   * Simultaneously edit with multiple people by [HackMD(CodiMD)](https://hackmd.io/) integration
   * Simultaneously edit with multiple people by [HackMD(CodiMD)](https://hackmd.io/) integration
-  * Support Authentication with LDAP / Active Directory
-  * Slack Incoming Webhooks Integration
+  * Support Authentication with LDAP / Active Directory, OAuth
+  * SSO(Single Sign On) with SAML
+  * Slack/Mattermost, IFTTT Integration
   * [Miscellaneous features](https://github.com/weseek/growi/wiki/Additional-Features)
   * [Miscellaneous features](https://github.com/weseek/growi/wiki/Additional-Features)
 * **[Docker Ready][dockerhub]**
 * **[Docker Ready][dockerhub]**
 * **[Docker Compose Ready][docker-compose]**
 * **[Docker Compose Ready][docker-compose]**
@@ -58,7 +59,7 @@ Using Heroku
 ------------
 ------------
 
 
 1. Go to https://heroku.com/deploy
 1. Go to https://heroku.com/deploy
-1. (Optional) Input INSTALL_PLUGINS to install plugins
+2. (Optional) Input INSTALL_PLUGINS to install plugins
 
 
 Using docker-compose
 Using docker-compose
 ---------------------
 ---------------------
@@ -74,7 +75,7 @@ See also [weseek/growi-docker-compose][docker-compose]
 On-premise
 On-premise
 ----------
 ----------
 
 
-[**Migration Guide from Crowi** is here](https://github.com/weseek/growi/wiki/Migration-Guide-from-Crowi).
+[**Migration Guide from Crowi** is here](https://docs.growi.org/guide/migration-guide/from-crowi-onpremise.html).
 
 
 ### Dependencies
 ### Dependencies
 
 
@@ -83,7 +84,7 @@ On-premise
 - yarn
 - yarn
 - MongoDB 3.x
 - MongoDB 3.x
 
 
-See [confirmed versions](https://github.com/weseek/growi/wiki/Developers-Guide#versions-confirmed-to-work).
+See [confirmed versions](https://docs.growi.org/dev/startup/dev-env.html#versions-confirmed-to-work).
 
 
 #### Optional Dependencies
 #### Optional Dependencies
 
 
@@ -114,7 +115,7 @@ export ELASTICSEARCH_URI=http://ELASTICSEARCH_HOST:ELASTICSEARCH_PORT/growi
 npm start
 npm start
 ```
 ```
 
 
-For more info, see [Developers Guide](https://github.com/weseek/growi/wiki/Developers-Guide) and [Crowi documents](https://github.com/crowi/crowi/wiki/Install-and-Configuration#env-parameters).
+For more info, see [Developers Guide](https://docs.growi.org/dev/).
 
 
 #### Command details
 #### Command details
 
 
@@ -145,9 +146,7 @@ yarn add growi-plugin-lsx
 npm start
 npm start
 ```
 ```
 
 
-
-
-For more info, see [Developers Guide](https://github.com/weseek/growi/wiki/Developers-Guide) on Wiki.
+For more info, see [Developers Guide](https://docs.growi.org/dev/) on docs.growi.org.
 
 
 
 
 Environment Variables
 Environment Variables
@@ -172,11 +171,12 @@ Environment Variables
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
 * **Option to integrate with external systems**
 * **Option to integrate with external systems**
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
-        * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/management-cookbook/integrate-with-hackmd).**
+        * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html).**
     * HACKMD_URI_FOR_SERVER: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server from GROWI Express server. If not set, `HACKMD_URI` will be used.
     * HACKMD_URI_FOR_SERVER: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server from GROWI Express server. If not set, `HACKMD_URI` will be used.
     * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
     * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
     * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
     * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
 * **Option (Overwritable in admin page)**
 * **Option (Overwritable in admin page)**
+    * APP_SITE_URL: Site URL. e.g. `https://example.com`, `https://example.com:8080`
     * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login.
     * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login.
     * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login.
     * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login.
     * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
     * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
@@ -191,10 +191,8 @@ Environment Variables
 Documentation
 Documentation
 ==============
 ==============
 
 
-* [github wiki pages](https://github.com/weseek/growi/wiki)
-  * [Questions and Answers](https://github.com/weseek/growi/wiki/Questions-and-Answers)
-  * [Migration Guide from Crowi](https://github.com/weseek/growi/wiki/Migration-Guide-from-Crowi)
-  * [Developers Guide](https://github.com/weseek/growi/wiki/Developers-Guide)
+- [GROWI Docs](https://docs.growi.org/)
+
 
 
 Contribution
 Contribution
 ============
 ============
@@ -212,24 +210,20 @@ Missing a Feature?
 You can *request* a new feature by [submitting an issue][issues] to our GitHub
 You can *request* a new feature by [submitting an issue][issues] to our GitHub
 Repository. If you would like to *implement* a new feature, firstly please submit the issue with your proposal to make sure we can confirm it. Please clarify what kind of change you would like to propose.
 Repository. If you would like to *implement* a new feature, firstly please submit the issue with your proposal to make sure we can confirm it. Please clarify what kind of change you would like to propose.
 
 
-* For a **Major Feature**, firstly open an issue and outline your proposal so it can be discussed. 
+* For a **Major Feature**, firstly open an issue and outline your proposal so it can be discussed.  
 It also allows us to coordinate better, prevent duplication of work and help you to create the change so it can be successfully accepted into the project.
 It also allows us to coordinate better, prevent duplication of work and help you to create the change so it can be successfully accepted into the project.
 * **Small Features** can be created and directly [submitted as a Pull Request][pulls].
 * **Small Features** can be created and directly [submitted as a Pull Request][pulls].
 
 
 Translation
 Translation
 --------------
 --------------
 
 
-### for GROWI system
+We have some Transifex Projects.
 
 
-We have [the Transifex Project for GROWI](https://www.transifex.com/weseek-inc/growi).  
-Please join to our team!
-
-### for documents
+* [GROWI (Internationalize)](https://www.transifex.com/weseek-inc/growi)
+* [GROWI Docs (Internationalize)](https://www.transifex.com/weseek-inc/growi-docs)
 
 
-*We have [Gitbook site](https://docs.growi.org), but currently Gitbook doesn't support Multi-langage.*  
--> https://docs.gitbook.com/v2-changes/important-differences#multi-language-books
+Please join to our team!
 
 
-*We have to wait until it is implemented.*
 
 
 Language on GitHub
 Language on GitHub
 ------------------
 ------------------

+ 1 - 1
config/migrate.js

@@ -18,7 +18,7 @@ const match = mongoUri.match(/^(.+)\/([^/]+)$/);
 module.exports = {
 module.exports = {
   mongoUri,
   mongoUri,
   mongodb: {
   mongodb: {
-    url: match[1],
+    url: match[0],
     databaseName: match[2],
     databaseName: match[2],
     options: {
     options: {
       useNewUrlParser: true, // removes a deprecation warning when connecting
       useNewUrlParser: true, // removes a deprecation warning when connecting

+ 8 - 8
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "3.3.4-RC",
+  "version": "3.3.7-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -71,7 +71,7 @@
     "cookie-parser": "^1.4.3",
     "cookie-parser": "^1.4.3",
     "cross-env": "^5.0.5",
     "cross-env": "^5.0.5",
     "csrf": "~3.0.3",
     "csrf": "~3.0.3",
-    "diff": "^3.5.0",
+    "diff": "^4.0.1",
     "elasticsearch": "^15.0.0",
     "elasticsearch": "^15.0.0",
     "entities": "^1.1.1",
     "entities": "^1.1.1",
     "env-cmd": "^8.0.1",
     "env-cmd": "^8.0.1",
@@ -96,12 +96,12 @@
     "migrate-mongo": "^5.0.1",
     "migrate-mongo": "^5.0.1",
     "mkdirp": "~0.5.1",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
     "module-alias": "^2.0.6",
-    "mongoose": "^5.3.1",
-    "mongoose-gridfs": "^0.5.0",
+    "mongoose": "^5.4.4",
+    "mongoose-gridfs": "^0.6.2",
     "mongoose-paginate": "^5.0.3",
     "mongoose-paginate": "^5.0.3",
     "mongoose-unique-validator": "^2.0.2",
     "mongoose-unique-validator": "^2.0.2",
     "multer": "~1.4.0",
     "multer": "~1.4.0",
-    "nodemailer": "^4.0.1",
+    "nodemailer": "^5.1.1",
     "nodemailer-ses-transport": "~1.5.0",
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
     "npm-run-all": "^4.1.2",
     "passport": "^0.4.0",
     "passport": "^0.4.0",
@@ -165,7 +165,7 @@
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-mathjax": "^2.0.0",
     "markdown-it-mathjax": "^2.0.0",
     "markdown-it-named-headers": "^0.0.4",
     "markdown-it-named-headers": "^0.0.4",
-    "markdown-it-plantuml": "^1.0.0",
+    "markdown-it-plantuml": "^1.3.0",
     "markdown-it-task-checkbox": "^1.0.6",
     "markdown-it-task-checkbox": "^1.0.6",
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "markdown-table": "^1.1.1",
@@ -196,8 +196,8 @@
     "reveal.js": "^3.5.0",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.1.0",
     "sass-loader": "^7.1.0",
     "simple-load-script": "^1.0.2",
     "simple-load-script": "^1.0.2",
-    "sinon": "^7.0.0",
-    "sinon-chai": "^3.2.0",
+    "sinon": "^7.2.2",
+    "sinon-chai": "^3.3.0",
     "socket.io-client": "^2.0.3",
     "socket.io-client": "^2.0.3",
     "stream-to-promise": "^2.2.0",
     "stream-to-promise": "^2.2.0",
     "style-loader": "^0.23.0",
     "style-loader": "^0.23.0",

+ 11 - 10
resource/cdn-manifests.js

@@ -10,7 +10,7 @@ module.exports = {
     },
     },
     {
     {
       name: 'highlight',
       name: 'highlight',
-      url: 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js',
+      url: 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.13.0/build/highlight.min.js',
       groups: ['basis'],
       groups: ['basis'],
       args: {
       args: {
         integrity: '',
         integrity: '',
@@ -19,14 +19,15 @@ module.exports = {
     {
     {
       name: 'highlight-addons',
       name: 'highlight-addons',
       url: 'https://cdn.jsdelivr.net/combine/' +
       url: 'https://cdn.jsdelivr.net/combine/' +
-'gh/highlightjs/cdn-release@9.12.0/build/languages/dockerfile.min.js,' +
-'gh/highlightjs/cdn-release@9.12.0/build/languages/go.min.js,' +
-'gh/highlightjs/cdn-release@9.12.0/build/languages/gradle.min.js,' +
-'gh/highlightjs/cdn-release@9.12.0/build/languages/json.min.js,' +
-'gh/highlightjs/cdn-release@9.12.0/build/languages/less.min.js,' +
-'gh/highlightjs/cdn-release@9.12.0/build/languages/scss.min.js,' +
-'gh/highlightjs/cdn-release@9.12.0/build/languages/typescript.min.js,' +
-'gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js,' +
+'gh/highlightjs/cdn-release@9.13.0/build/languages/dockerfile.min.js,' +
+'gh/highlightjs/cdn-release@9.13.0/build/languages/go.min.js,' +
+'gh/highlightjs/cdn-release@9.13.0/build/languages/gradle.min.js,' +
+'gh/highlightjs/cdn-release@9.13.0/build/languages/json.min.js,' +
+'gh/highlightjs/cdn-release@9.13.0/build/languages/less.min.js,' +
+'gh/highlightjs/cdn-release@9.13.0/build/languages/plaintext.min.js,' +
+'gh/highlightjs/cdn-release@9.13.0/build/languages/scss.min.js,' +
+'gh/highlightjs/cdn-release@9.13.0/build/languages/typescript.min.js,' +
+'gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js,' +
 'npm/highlightjs-line-numbers.js@2.6.0/dist/highlightjs-line-numbers.min.js',
 'npm/highlightjs-line-numbers.js@2.6.0/dist/highlightjs-line-numbers.min.js',
       args: {
       args: {
         async: true,
         async: true,
@@ -120,7 +121,7 @@ module.exports = {
     },
     },
     {
     {
       name: 'highlight-theme-github',
       name: 'highlight-theme-github',
-      url: 'https://cdn.jsdelivr.net/npm/highlight.js@9.12.0/styles/github.css',
+      url: 'https://cdn.jsdelivr.net/npm/highlight.js@9.13.0/styles/github.css',
       args: {
       args: {
         integrity: ''
         integrity: ''
       },
       },

+ 1 - 1
resource/locales/en-US/sandbox.md

@@ -77,7 +77,7 @@ piyo
 
 
 ## Code コード
 ## Code コード
 
 
-`` `バッククオート` `` 3つ、あるいはダッシュ`~`3つで囲みます。
+`` `バッククオート` `` 3つ、あるいはチルダ`~`3つで囲みます。
 
 
 ```
 ```
 print 'hoge'
 print 'hoge'

+ 77 - 11
resource/locales/en-US/translation.json

@@ -13,6 +13,8 @@
   "Admin": "Admin",
   "Admin": "Admin",
   "New": "New",
   "New": "New",
   "Shortcuts": "Shortcuts",
   "Shortcuts": "Shortcuts",
+  "eg": "e.g.",
+  "Undo": "Undo",
 
 
   "Update": "Update",
   "Update": "Update",
   "Update Page": "Update Page",
   "Update Page": "Update Page",
@@ -101,6 +103,10 @@
   "Deleted Pages": "Deleted Pages",
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
   "Sign out": "Logout",
 
 
+  "form_validation": {
+    "required": "<code>%s</code> is required"
+  },
+
   "installer": {
   "installer": {
     "setup": "Setup",
     "setup": "Setup",
     "create_initial_account": "Create an initial account",
     "create_initial_account": "Create an initial account",
@@ -268,6 +274,38 @@
     }
     }
   },
   },
 
 
+  "sandbox": {
+    "header": "Header",
+    "header_x": "Header {{index}}",
+    "block": "Paragraph",
+    "block_detail": "makes a paragraph",
+    "empty_line": "Empty Line",
+    "line_break": "Line Break",
+    "line_break_detail": "(2 spaces) make a line break",
+    "typography": "Typography",
+    "italics": "Italics",
+    "bold": "Bold",
+    "italic_bold": "Italic Bold",
+    "strikethrough": "strikethrough",
+    "link": "Link",
+    "code_highlight": "Code Highlight",
+    "list": "List",
+    "unordered_list_x": "Unordered List {{index}}",
+    "ordered_list_x": "Ordered List {{index}}",
+    "task": "Task",
+    "task_checked": "Checked",
+    "task_unchecked": "Unchecked",
+    "quote": "Quote",
+    "quote1": "You can write",
+    "quote2": "multi-line quotations",
+    "quote_nested": "Nested Quote",
+    "table": "Table",
+    "image": "Image",
+    "alt_text": "Alt Text",
+    "insert_image": "inserts an image",
+    "open_sandbox": "Open Sandbox"
+  },
+
   "admin_top": {
   "admin_top": {
     "Management Wiki": "Management Wiki",
     "Management Wiki": "Management Wiki",
     "System Information": "System Information",
     "System Information": "System Information",
@@ -283,7 +321,8 @@
     "Site Name": "Site name",
     "Site Name": "Site name",
     "sitename_change": "You can change Site Name which is used for header and HTML title.",
     "sitename_change": "You can change Site Name which is used for header and HTML title.",
     "header_content": "The contents entered here will be shown in the header etc.",
     "header_content": "The contents entered here will be shown in the header etc.",
-    "Site URL": "Site URL",
+    "Site URL desc": "This is for the site URL setting.",
+    "Site URL warn": "Some features don't work because the site URL is not set.",
     "siteurl_help": "Site full URL beginning from <code>http://</code> or <code>https://</code>.",
     "siteurl_help": "Site full URL beginning from <code>http://</code> or <code>https://</code>.",
     "Confidential name": "Confidential name",
     "Confidential name": "Confidential name",
     "Default Language for new users": "Default Language for new users",
     "Default Language for new users": "Default Language for new users",
@@ -302,7 +341,7 @@
     "Port": "Port",
     "Port": "Port",
     "User": "User",
     "User": "User",
     "AWS settings": "AWS settings",
     "AWS settings": "AWS settings",
-    "AWS_access": "This is for AWS settings. If you complete AWS settings, file upload function, progile picture function etc will be enabled.",
+    "AWS_access": "This is for AWS settings. If you complete AWS settings, file upload function, profile picture function etc will be enabled.",
     "No_SMTP_setting": "If you do not have SMTP settings, e-mails will be sent via SES. You need to verify from e-mail address and production settings.",
     "No_SMTP_setting": "If you do not have SMTP settings, e-mails will be sent via SES. You need to verify from e-mail address and production settings.",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "region": "Region",
     "region": "Region",
@@ -311,8 +350,8 @@
     "Enable plugin loading": "Enable plugin loading",
     "Enable plugin loading": "Enable plugin loading",
     "Load plugins": "Load plugins",
     "Load plugins": "Load plugins",
     "Enable": "Enable",
     "Enable": "Enable",
-    "Disable": "Disable"
-
+    "Disable": "Disable",
+    "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>%s</code> is used."
   },
   },
   "security_setting": {
   "security_setting": {
 		"Basic authentication": "Basic authentication",
 		"Basic authentication": "Basic authentication",
@@ -341,7 +380,7 @@
     "auth_mechanism": "authentication mechanism",
     "auth_mechanism": "authentication mechanism",
     "recommended": "Recommended",
     "recommended": "Recommended",
     "username_email_password": "Username, Email and Password authentication",
     "username_email_password": "Username, Email and Password authentication",
-    "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Define it from %s",
+    "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the %s",
     "ldap_auth": "LDAP authentication",
     "ldap_auth": "LDAP authentication",
     "saml_auth": "SAML authentication",
     "saml_auth": "SAML authentication",
     "google_auth2": "Google OAuth authentication",
     "google_auth2": "Google OAuth authentication",
@@ -382,6 +421,8 @@
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
     "Treat email matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
     "Treat email matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
     "Use env var if empty": "Use env var <code>%s</code> if empty",
     "Use env var if empty": "Use env var <code>%s</code> if empty",
+    "Use default if both are empty": "If both ​​are empty, the default value <code>%s</code> is used.",
+    "missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
     "ldap": {
     "ldap": {
       "server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
       "server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
       "bind_mode": "Binding Mode",
       "bind_mode": "Binding Mode",
@@ -414,15 +455,12 @@
     },
     },
     "SAML": {
     "SAML": {
       "name": "SAML",
       "name": "SAML",
-      "entry_point": "Entry Point",
-      "issuer": "Issuer",
-      "First Name": "First Name",
-      "Last Name": "Last Name",
       "id_detail": "Specification of the name of attribute which can identify the user in SAML Identity Provider",
       "id_detail": "Specification of the name of attribute which can identify the user in SAML Identity Provider",
       "username_detail": "Specification of mappings for <code>username</code> when creating new users",
       "username_detail": "Specification of mappings for <code>username</code> when creating new users",
       "mapping_detail": "Specification of mappings for %s when creating new users",
       "mapping_detail": "Specification of mappings for %s when creating new users",
-      "cert_detail1": "PEM-encoded X.509 signing certificate to validate the response from IdP",
-      "cert_detail2": "Use env var <code>SAML_CERT</code> if empty, and no validation is processed if the variable is also undefined"
+      "cert_detail": "PEM-encoded X.509 signing certificate to validate the response from IdP",
+      "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>%s</code> is used.",
+      "note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>%s</code> ."
     },
     },
     "OAuth": {
     "OAuth": {
       "register": "Register for %s",
       "register": "Register for %s",
@@ -457,6 +495,16 @@
         "github": "How to configure GitHub OAuth?",
         "github": "How to configure GitHub OAuth?",
         "twitter": "How to configure Twitter OAuth?"
         "twitter": "How to configure Twitter OAuth?"
       }
       }
+    },
+    "form_item_name": {
+      "security:passport-saml:entryPoint": "Entry point",
+      "security:passport-saml:issuer": "Issuer",
+      "security:passport-saml:cert": "Certificate",
+      "security:passport-saml:attrMapId": "ID",
+      "security:passport-saml:attrMapUsername": "Username",
+      "security:passport-saml:attrMapMail": "Mail Address",
+      "security:passport-saml:attrMapFirstName": "First Name",
+      "security:passport-saml:attrMapLastName": "Last Name"
     }
     }
 	},
 	},
 
 
@@ -525,6 +573,10 @@
     "save_edit": "Save edit tab and history tab switching in the browser and make it object for forward/back command of the browser.",
     "save_edit": "Save edit tab and history tab switching in the browser and make it object for forward/back command of the browser.",
     "by_invalidating": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
     "by_invalidating": "By invalidating, you can make page transition as the only object for forward/back command of the browser.",
     "nocdn_desc": "This function is disabled when the environment variable <code>NO_CDN=true</code>.<br>Github style has been forcibly applied.",
     "nocdn_desc": "This function is disabled when the environment variable <code>NO_CDN=true</code>.<br>Github style has been forcibly applied.",
+    "custom_title": "Custom Title",
+    "custom_title_detail": "You can customize <code>%s</code> tag.<br><code>%s</code> will be automatically replaced with the app name, and <code>%s</code> will be replaced with the page name/path.",
+    "custom_header": "Custom HTML Header",
+    "custom_header_detail": "You can customize HTML header that applies all pages. Your custom script will be inserted in <code>%s</code> but above other <code>%s</code> tags.<br>Relaod page to see changes.",
     "Custom CSS": "Custom CSS",
     "Custom CSS": "Custom CSS",
     "write_CSS": "You can write CSS that is applied to whole system.",
     "write_CSS": "You can write CSS that is applied to whole system.",
     "reflect_change": "You need to reload the page to reflect the change.",
     "reflect_change": "You need to reload the page to reflect the change.",
@@ -540,14 +592,28 @@
   "user_management": {
   "user_management": {
     "User management": "User management",
     "User management": "User management",
     "invite_users": "Invite new users",
     "invite_users": "Invite new users",
+    "emails": "Emails",
+    "invite_thru_email": "Send Invitation Email",
+    "invite": "Invite",
+    "give_admin_access": "Give admin access",
+    "remove_admin_access": "Remove admin access",
     "external_account": "External account management",
     "external_account": "External account management",
     "user_list": "List of users",
     "user_list": "List of users",
+    "external_account_list": "External Account List",
+    "back_to_user_management": "Back to User Management",
+    "authentication_provider": "Authentication Provider",
     "Date created": "Date created",
     "Date created": "Date created",
     "Last login": "Last login",
     "Last login": "Last login",
     "Manage": "Manage",
     "Manage": "Manage",
     "Edit menu": "Edit menu",
     "Edit menu": "Edit menu",
+    "password_setting": "Password Setting",
+    "set": "Yes",
+    "unset": "No",
+    "password_setting_help": "Show whether the related user has a password set",
     "Reissue password": "Reissue password",
     "Reissue password": "Reissue password",
+    "related_username": "Related user's <code>%s</code>",
     "Status":"Status",
     "Status":"Status",
+    "accept": "Accept",
     "Deactivate account":"Deactivate account",
     "Deactivate account":"Deactivate account",
     "your_own":"You cannot deactivate your own account",
     "your_own":"You cannot deactivate your own account",
     "Administrator menu":"Administrator menu",
     "Administrator menu":"Administrator menu",

+ 1 - 1
resource/locales/ja/sandbox.md

@@ -77,7 +77,7 @@ piyo
 
 
 ## Code コード
 ## Code コード
 
 
-`` `バッククオート` `` 3つ、あるいはダッシュ`~`3つで囲みます。
+`` `バッククオート` `` 3つ、あるいはチルダ`~`3つで囲みます。
 
 
 ```
 ```
 print 'hoge'
 print 'hoge'

+ 77 - 10
resource/locales/ja/translation.json

@@ -13,6 +13,8 @@
   "Admin": "管理",
   "Admin": "管理",
   "New": "作成",
   "New": "作成",
   "Shortcuts": "ショートカット",
   "Shortcuts": "ショートカット",
+  "eg": "例:",
+  "Undo": "元に戻す",
 
 
   "Update": "更新",
   "Update": "更新",
   "Update Page": "ページを更新",
   "Update Page": "ページを更新",
@@ -86,6 +88,7 @@
   "Table of Contents": "目次",
   "Table of Contents": "目次",
   "Management Wiki Home": "Wiki管理トップ",
   "Management Wiki Home": "Wiki管理トップ",
   "App settings": "アプリ設定",
   "App settings": "アプリ設定",
+  "Site URL settings": "サイトURL設定",
   "Markdown settings": "マークダウン設定",
   "Markdown settings": "マークダウン設定",
   "Customize": "カスタマイズ",
   "Customize": "カスタマイズ",
   "Notification settings": "通知設定",
   "Notification settings": "通知設定",
@@ -118,6 +121,10 @@
   "Deleted Pages": "削除済みページ",
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
   "Sign out": "ログアウト",
 
 
+  "form_validation": {
+    "required": "<code>%s</code> に値を入力してください"
+  },
+
   "installer": {
   "installer": {
     "setup": "セットアップ",
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",
     "create_initial_account": "最初のアカウントの作成",
@@ -281,6 +288,38 @@
     }
     }
   },
   },
 
 
+  "sandbox": {
+    "header": "見出し",
+    "header_x": "見出し {{index}}",
+    "block": "ブロック",
+    "block_detail": "を挟むことで段落になります",
+    "empty_line": "空白行",
+    "line_break": "改行",
+    "line_break_detail": "(スペース2つ) で改行されます",
+    "typography": "タイポグラフィー",
+    "italics": "斜体",
+    "bold": "強調",
+    "italic_bold": "イタリックボールド",
+    "strikethrough": "取り消し線",
+    "link": "リンク",
+    "code_highlight": "コードハイライト",
+    "list": "リスト",
+    "unordered_list_x": "リスト {{index}}",
+    "ordered_list_x": "番号付きリスト {{index}}",
+    "task": "タスク",
+    "task_checked": "チェック付き",
+    "task_unchecked": "チェックなし",
+    "quote": "引用",
+    "quote1": "複数行の引用文を",
+    "quote2": "書くことができます",
+    "table": "テーブル",
+    "quote_nested": "多重引用",
+    "image": "画像",
+    "alt_text": "Alt文字列",
+    "insert_image": "で画像を挿入できます",
+    "open_sandbox": "Sandbox を開く"
+  },
+
   "admin_top": {
   "admin_top": {
     "Management Wiki": "Wiki管理",
     "Management Wiki": "Wiki管理",
     "System Information": "システム情報",
     "System Information": "システム情報",
@@ -296,8 +335,9 @@
     "Site Name": "サイト名",
     "Site Name": "サイト名",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
     "header_content": "ここに入力した内容は、ヘッダー等に表示されます。",
     "header_content": "ここに入力した内容は、ヘッダー等に表示されます。",
-    "Site URL": "サイトURL",
-    "siteurl_help": "<code>http://</code> または <code>https://</code> から始まるサイトのURL。",
+    "Site URL desc": "サイトURLを設定します。",
+    "Site URL warn": "サイトURLが設定されていないため、一部機能が動作しない状態になっています。",
+    "siteurl_help": "<code>http://</code> または <code>https://</code> から始まるサイトのURL",
     "Confidential name": "コンフィデンシャル表示",
     "Confidential name": "コンフィデンシャル表示",
     "Default Language for new users": "新規ユーザーのデフォルト設定言語",
     "Default Language for new users": "新規ユーザーのデフォルト設定言語",
     "ex): internal use only": "例: 社外秘",
     "ex): internal use only": "例: 社外秘",
@@ -324,8 +364,8 @@
     "Enable plugin loading": "プラグインの読み込みを有効にします。",
     "Enable plugin loading": "プラグインの読み込みを有効にします。",
     "Load plugins": "プラグインを読み込む",
     "Load plugins": "プラグインを読み込む",
     "Enable": "有効",
     "Enable": "有効",
-    "Disable": "無効"
-
+    "Disable": "無効",
+    "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>%s</code> の値を利用します"
    },
    },
 
 
   "security_setting": {
   "security_setting": {
@@ -395,6 +435,8 @@
     "Treat email matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat email matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat email matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Treat email matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
     "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
+    "Use default if both are empty": "どちらの値も空の場合、デフォルト値 <code>%s</code> を利用します",
+    "missing mandatory configs": "以下の必須項目の値がデータベースと環境変数のどちらにも設定されていません",
     "ldap": {
     "ldap": {
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "bind_mode": "Bind モード",
       "bind_mode": "Bind モード",
@@ -427,15 +469,12 @@
     },
     },
     "SAML": {
     "SAML": {
       "name": "SAML",
       "name": "SAML",
-      "entry_point": "エントリーポイント",
-      "issuer": "発行者",
-      "First Name": "姓",
-      "Last Name": "名",
       "id_detail": "SAML Identity プロバイダ内で一意に識別可能な値を格納している属性",
       "id_detail": "SAML Identity プロバイダ内で一意に識別可能な値を格納している属性",
       "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
       "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
       "mapping_detail": "新規ユーザーの%sに関連付ける属性",
       "mapping_detail": "新規ユーザーの%sに関連付ける属性",
-      "cert_detail1": "IdP からのレスポンスの validation を行うための、PEMエンコードされた X.509 証明書",
-      "cert_detail2": "空の場合は環境変数 <code>SAML_CERT</code> を利用し、そちらも存在しない場合は validation 自体を行いません"
+      "cert_detail": "IdP からのレスポンスの validation を行うためのPEMエンコードされた X.509 証明書",
+      "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>%s</code> の値を利用します",
+      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>%s</code> の値をfalseに変更もしくは削除してください"
     },
     },
     "OAuth": {
     "OAuth": {
       "register": "%sに登録",
       "register": "%sに登録",
@@ -470,6 +509,16 @@
         "github": "GitHub OAuth の設定方法",
         "github": "GitHub OAuth の設定方法",
         "twitter": "Twitter OAuth の設定方法"
         "twitter": "Twitter OAuth の設定方法"
       }
       }
+    },
+    "form_item_name": {
+      "security:passport-saml:entryPoint": "エントリーポイント",
+      "security:passport-saml:issuer": "発行者",
+      "security:passport-saml:cert": "証明書",
+      "security:passport-saml:attrMapId": "ID",
+      "security:passport-saml:attrMapUsername": "ユーザー名",
+      "security:passport-saml:attrMapMail": "メールアドレス",
+      "security:passport-saml:attrMapFirstName": "姓",
+      "security:passport-saml:attrMapLastName": "名"
     }
     }
   },
   },
   "markdown_setting": {
   "markdown_setting": {
@@ -537,6 +586,10 @@
     "save_edit": "編集タブやヒストリータブ等の切り替えをブラウザ履歴に保存し、ブラウザの戻る/進む操作の対象にします。",
     "save_edit": "編集タブやヒストリータブ等の切り替えをブラウザ履歴に保存し、ブラウザの戻る/進む操作の対象にします。",
     "by_invalidating": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
     "by_invalidating": "無効化することで、ページ遷移のみを戻る/進む操作の対象にすることができます。",
     "nocdn_desc": "この機能は、環境変数 <code>NO_CDN=true</code> の時は無効化されます。<br>GitHub スタイルが適用されています。",
     "nocdn_desc": "この機能は、環境変数 <code>NO_CDN=true</code> の時は無効化されます。<br>GitHub スタイルが適用されています。",
+    "custom_title": "カスタム Title",
+    "custom_title_detail": "<code>%s</code>タグのコンテンツをカスタマイズできます。<br><code>%s</code>がサイト名、<code>%s</code>がページ名またはページパスに置換されます。",
+    "custom_header": "カスタム HTML Header",
+    "custom_header_detail": "システム全体に適用される HTML を記述できます。<code>%s</code> タグ内の他の <code>%s</code> タグ読み込み前に展開されます。<br>変更の反映はページの更新が必要です。",
     "Custom CSS": "カスタム CSS",
     "Custom CSS": "カスタム CSS",
     "write_CSS": " システム全体に適用されるCSSを記述できます。",
     "write_CSS": " システム全体に適用されるCSSを記述できます。",
     "reflect_change": "変更の反映はページの更新が必要です。",
     "reflect_change": "変更の反映はページの更新が必要です。",
@@ -552,14 +605,28 @@
   "user_management": {
   "user_management": {
     "User management": "ユーザー管理",
     "User management": "ユーザー管理",
     "invite_users": "新規ユーザーの招待",
     "invite_users": "新規ユーザーの招待",
+    "emails": "メールアドレス (複数行入力で複数人招待可能)",
+    "invite_thru_email": "招待をメールで送信",
+    "invite": "招待する",
+    "give_admin_access": "管理者にする",
+    "remove_admin_access": "管理者から外す",
     "external_account": "外部アカウントの管理",
     "external_account": "外部アカウントの管理",
     "user_list": "ユーザー一覧",
     "user_list": "ユーザー一覧",
+    "external_account_list": "外部アカウント一覧",
+    "back_to_user_management": "ユーザー管理に戻る",
+    "authentication_provider": "認証情報プロバイダ",
     "Date created": "作成日",
     "Date created": "作成日",
     "Last login": "最終ログイン",
     "Last login": "最終ログイン",
     "Manage": "操作",
     "Manage": "操作",
     "Edit menu": "編集メニュー",
     "Edit menu": "編集メニュー",
+    "password_setting": "パスワード設定",
+    "password_setting_help": "関連付けられているユーザーがパスワードを設定しているかどうかを表示します",
+    "set": "設定済み",
+    "unset": "未設定",
     "Reissue password": "パスワードの再発行",
     "Reissue password": "パスワードの再発行",
+    "related_username": "関連付けられているユーザーの <code>%s</code>",
     "Status": "ステータス",
     "Status": "ステータス",
+    "accept": "承認する",
     "Deactivate account": "アカウント停止",
     "Deactivate account": "アカウント停止",
     "your_own": "自分自身のアカウントを停止することはできません",
     "your_own": "自分自身のアカウントを停止することはできません",
     "Administrator menu": "管理者メニュー",
     "Administrator menu": "管理者メニュー",

+ 102 - 0
src/client/js/components/PageEditor/Cheatsheet.js

@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
+
+class Cheatsheet extends React.Component {
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div className="row small">
+        <div className="col-sm-6">
+          <h4>{t('sandbox.header')}</h4>
+          <ul className="hljs">
+            <li><code># </code>{t('sandbox.header_x', {index: '1'})}</li>
+            <li><code>## </code>{t('sandbox.header_x', {index: '2'})}</li>
+            <li><code>### </code>{t('sandbox.header_x', {index: '3'})}</li>
+          </ul>
+          <h4>{t('sandbox.block')}</h4>
+          <p className="mb-1"><code>[{t('sandbox.empty_line')}]</code>{t('sandbox.block_detail')}</p>
+          <ul className="hljs">
+            <li>text</li>
+            <li></li>
+            <li>text</li>
+          </ul>
+          <h4>{t('sandbox.line_break')}</h4>
+          <p className="mb-1"><code>[ ][ ]</code> {t('sandbox.line_break_detail')}</p>
+          <ul className="hljs">
+            <li>text</li>
+            <li>text</li>
+          </ul>
+          <h4>{t('sandbox.typography')}</h4>
+          <ul className="hljs">
+            <li><i>*{t('sandbox.italics')}*</i></li>
+            <li><b>**{t('sandbox.bold')}**</b></li>
+            <li><i><b>***{t('sandbox.italic_bold')}***</b></i></li>
+            <li>~~{t('sandbox.strikethrough')}~~ => <s>{t('sandbox.strikethrough')}</s></li>
+          </ul>
+          <h4>{t('sandbox.link')}</h4>
+          <ul className="hljs">
+            <li>[Google](https://www.google.co.jp/)</li>
+            <li>[/Page1/ChildPage1]</li>
+          </ul>
+          <h4>{t('sandbox.code_highlight')}</h4>
+          <ul className="hljs">
+            <li>```javascript:index.js</li>
+            <li>writeCode();</li>
+            <li>```</li>
+          </ul>
+        </div>
+        <div className="col-sm-6">
+          <h4>{t('sandbox.list')}</h4>
+          <ul className="hljs">
+            <li>- {t('sandbox.unordered_list_x', {index: '1'})}</li>
+            <li>&nbsp;&nbsp;- {t('sandbox.unordered_list_x', {index: '1.1'})}</li>
+            <li>- {t('sandbox.unordered_list_x', {index: '2'})}</li>
+          </ul>
+          <ul className="hljs">
+            <li>1. {t('sandbox.ordered_list_x', {index: '1'})}</li>
+            <li>1. {t('sandbox.ordered_list_x', {index: '2'})}</li>
+          </ul>
+          <ul className="hljs">
+            <li>- [ ] {t('sandbox.task')}({t('sandbox.task_unchecked')})</li>
+            <li>- [x] {t('sandbox.task')}({t('sandbox.task_checked')})</li>
+          </ul>
+          <h4>{t('sandbox.quote')}</h4>
+          <ul className="hljs">
+            <li>> {t('sandbox.quote1')}</li>
+            <li>> {t('sandbox.quote2')}</li>
+          </ul>
+          <ul className="hljs">
+            <li>>> {t('sandbox.quote_nested')}</li>
+            <li>>>> {t('sandbox.quote_nested')}</li>
+            <li>>>>> {t('sandbox.quote_nested')}</li>
+          </ul>
+          <h4>{t('sandbox.table')}</h4>
+          <ul className="hljs text-center">
+            <li>|Left&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;Mid&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Right|</li>
+            <li>|:----------|:---------:|----------:|</li>
+            <li>|col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|</li>
+            <li>|col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|</li>
+          </ul>
+          <h4>{t('sandbox.image')}</h4>
+          <p className="mb-1"><code> ![{t('sandbox.alt_text')}](URL)</code> {t('sandbox.insert_image')}</p>
+          <ul className="hljs">
+            <li>![ex](https://example.com/image.png)</li>
+          </ul>
+
+          <hr />
+          <a href="/Sandbox" className="btn btn-info btn-block" target="_blank">
+            <i className="icon-share-alt"/> {t('sandbox.open_sandbox')}
+          </a>
+        </div>
+      </div>
+    );
+  }
+}
+
+Cheatsheet.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
+};
+
+export default translate()(Cheatsheet);

+ 12 - 126
src/client/js/components/PageEditor/CodeMirrorEditor.js

@@ -44,6 +44,9 @@ require('../../util/codemirror/autorefresh.ext');
 
 
 import AbstractEditor from './AbstractEditor';
 import AbstractEditor from './AbstractEditor';
 
 
+import SimpleCheatsheet from './SimpleCheatsheet';
+import Cheatsheet from './Cheatsheet';
+
 import pasteHelper from './PasteHelper';
 import pasteHelper from './PasteHelper';
 import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 
 
@@ -462,14 +465,16 @@ export default class CodeMirrorEditor extends AbstractEditor {
   pasteHandler(editor, event) {
   pasteHandler(editor, event) {
     const types = event.clipboardData.types;
     const types = event.clipboardData.types;
 
 
-    // text
-    if (types.includes('text/plain')) {
-      pasteHelper.pasteText(this, event);
-    }
     // files
     // files
-    else if (types.includes('Files')) {
+    if (types.includes('Files')) {
+      event.preventDefault();
       this.dispatchPasteFiles(event);
       this.dispatchPasteFiles(event);
     }
     }
+    // text
+    else if (types.includes('text/plain')) {
+      pasteHelper.pasteText(this, event);
+    }
+
   }
   }
 
 
   /**
   /**
@@ -509,130 +514,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
   }
 
 
   renderSimpleCheatsheet() {
   renderSimpleCheatsheet() {
-    return (
-      <div className="panel panel-default gfm-cheatsheet mb-0">
-        <div className="panel-body small p-b-0">
-          <div className="row">
-            <div className="col-xs-6">
-              <p>
-                # 見出し1<br />
-                ## 見出し2
-              </p>
-              <p><i>*斜体*</i>&nbsp;&nbsp;<b>**強調**</b></p>
-              <p>
-                [リンク](http://..)<br />
-                [/ページ名/子ページ名]
-              </p>
-              <p>
-                ```javascript:index.js<br />
-                writeCode();<br />
-                ```
-              </p>
-            </div>
-            <div className="col-xs-6">
-              <p>
-                - リスト 1<br />
-                &nbsp;&nbsp;&nbsp;&nbsp;- リスト 1_1<br />
-                - リスト 2<br />
-                1. 番号付きリスト 1<br />
-                1. 番号付きリスト 2
-              </p>
-              <hr />
-              <p>行末にスペース2つ[ ][ ]<br />で改行</p>
-            </div>
-          </div>
-        </div>
-      </div>
-    );
+    return <SimpleCheatsheet />;
   }
   }
 
 
   renderCheatsheetModalBody() {
   renderCheatsheetModalBody() {
-    return (
-      <div className="row small">
-        <div className="col-sm-6">
-          <h4>Header</h4>
-          <ul className="hljs">
-            <li><code># </code>見出し1</li>
-            <li><code>## </code>見出し2</li>
-            <li><code>### </code>見出し3</li>
-          </ul>
-          <h4>Block</h4>
-          <p className="mb-1"><code>[空白行]</code>を挟むことで段落になります</p>
-          <ul className="hljs">
-            <li>text</li>
-            <li></li>
-            <li>text</li>
-          </ul>
-          <h4>Line breaks</h4>
-          <p className="mb-1">段落中、<code>[space][space]</code>(スペース2つ) で改行されます</p>
-          <ul className="hljs">
-            <li>text<code> </code><code> </code></li>
-            <li>text</li>
-          </ul>
-          <h4>Typography</h4>
-          <ul className="hljs">
-            <li><i>*イタリック*</i></li>
-            <li><b>**ボールド**</b></li>
-            <li><i><b>***イタリックボールド***</b></i></li>
-            <li>~~取り消し線~~ => <s>striked text</s></li>
-          </ul>
-          <h4>Link</h4>
-          <ul className="hljs">
-            <li>[Google](https://www.google.co.jp/)</li>
-            <li>[/Page1/ChildPage1]</li>
-          </ul>
-          <h4>コードハイライト</h4>
-          <ul className="hljs">
-            <li>```javascript:index.js</li>
-            <li>writeCode();</li>
-            <li>```</li>
-          </ul>
-        </div>
-        <div className="col-sm-6">
-          <h4>リスト</h4>
-          <ul className="hljs">
-            <li>- リスト 1</li>
-            <li>&nbsp;&nbsp;- リスト 1_1</li>
-            <li>- リスト 2</li>
-          </ul>
-          <ul className="hljs">
-            <li>1. 番号付きリスト 1</li>
-            <li>1. 番号付きリスト 2</li>
-          </ul>
-          <ul className="hljs">
-            <li>- [ ] タスク(チェックなし)</li>
-            <li>- [x] タスク(チェック付き)</li>
-          </ul>
-          <h4>引用</h4>
-          <ul className="hljs">
-            <li>> 複数行の引用文を</li>
-            <li>> 書くことができます</li>
-          </ul>
-          <ul className="hljs">
-            <li>>> 多重引用</li>
-            <li>>>> 多重引用</li>
-            <li>>>>> 多重引用</li>
-          </ul>
-          <h4>Table</h4>
-          <ul className="hljs text-center">
-            <li>|&nbsp;&nbsp;&nbsp;左寄せ&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;中央寄せ&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;右寄せ&nbsp;&nbsp;&nbsp;|</li>
-            <li>|:-----------|:----------:|-----------:|</li>
-            <li>|column 1&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;column 2&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;column 3|</li>
-            <li>|column 1&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;column 2&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;column 3|</li>
-          </ul>
-          <h4>Images</h4>
-          <p className="mb-1"><code> ![Alt文字列](URL)</code> で<span className="text-info">&lt;img&gt;</span>タグを挿入できます</p>
-          <ul className="hljs">
-            <li>![ex](https://example.com/images/a.png)</li>
-          </ul>
-
-          <hr />
-          <a href="/Sandbox" className="btn btn-info btn-block" target="_blank">
-            <i className="icon-share-alt"/> Sandbox を開く
-          </a>
-        </div>
-      </div>
-    );
+    return <Cheatsheet />;
   }
   }
 
 
   renderCheatsheetModalButton() {
   renderCheatsheetModalButton() {

+ 15 - 7
src/client/js/components/PageEditor/Editor.jsx

@@ -109,17 +109,25 @@ export default class Editor extends AbstractEditor {
     const items = event.clipboardData.items || event.clipboardData.files || [];
     const items = event.clipboardData.items || event.clipboardData.files || [];
 
 
     // abort if length is not 1
     // abort if length is not 1
-    if (items.length != 1) {
+    if (items.length < 1) {
       return;
       return;
     }
     }
 
 
-    const file = items[0].getAsFile();
-    // check type and size
-    if (pasteHelper.fileAccepted(file, dropzone.props.accept) &&
-        pasteHelper.fileMatchSize(file, dropzone.props.maxSize, dropzone.props.minSize)) {
+    for (let i = 0; i < items.length; i++) {
+      try {
+        const file = items[i].getAsFile();
+        // check type and size
+        if (file != null &&
+            pasteHelper.fileAccepted(file, dropzone.props.accept) &&
+            pasteHelper.fileMatchSize(file, dropzone.props.maxSize, dropzone.props.minSize)) {
 
 
-      this.dispatchUpload(file);
-      this.setState({ isUploading: true });
+          this.dispatchUpload(file);
+          this.setState({ isUploading: true });
+        }
+      }
+      catch (e) {
+        this.logger.error(e);
+      }
     }
     }
   }
   }
 
 

+ 51 - 0
src/client/js/components/PageEditor/SimpleCheatsheet.js

@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
+
+class SimpleCheatsheet extends React.Component {
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div className="panel panel-default gfm-cheatsheet mb-0">
+        <div className="panel-body small p-b-0">
+          <div className="row">
+            <div className="col-xs-6">
+              <p>
+                # {t('sandbox.header_x', {index: '1'})}<br />
+                ## {t('sandbox.header_x', {index: '2'})}
+              </p>
+              <p><i>*{t('sandbox.italics')}*</i>&nbsp;&nbsp;<b>**{t('sandbox.bold')}**</b></p>
+              <p>
+                [{t('sandbox.link')}](http://..)<br />
+                [/Page1/ChildPage1]
+              </p>
+              <p>
+                ```javascript:index.js<br />
+                writeCode();<br />
+                ```
+              </p>
+            </div>
+            <div className="col-xs-6">
+              <p>
+                - {t('sandbox.unordered_list_x', {index: '1'})}<br />
+                &nbsp;&nbsp;&nbsp;- {t('sandbox.unordered_list_x', {index: '1.1'})}<br />
+                - {t('sandbox.unordered_list_x', {index: '2'})}<br />
+                1. {t('sandbox.ordered_list_x', {index: '1'})}<br />
+                1. {t('sandbox.ordered_list_x', {index: '2'})}
+              </p>
+              <hr />
+              <p>[ ][ ] {t('sandbox.block_detail')}</p>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+SimpleCheatsheet.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
+};
+
+export default translate()(SimpleCheatsheet);

+ 6 - 5
src/client/js/components/PageEditor/TextAreaEditor.js

@@ -218,14 +218,15 @@ export default class TextAreaEditor extends AbstractEditor {
   pasteHandler(event) {
   pasteHandler(event) {
     const types = event.clipboardData.types;
     const types = event.clipboardData.types;
 
 
-    // text
-    if (types.includes('text/plain')) {
-      pasteHelper.pasteText(this, event);
-    }
     // files
     // files
-    else if (types.includes('Files')) {
+    if (types.includes('Files')) {
+      event.preventDefault();
       this.dispatchPasteFiles(event);
       this.dispatchPasteFiles(event);
     }
     }
+    // text
+    else if (types.includes('text/plain')) {
+      pasteHelper.pasteText(this, event);
+    }
   }
   }
 
 
   dragEnterHandler(event) {
   dragEnterHandler(event) {

+ 30 - 20
src/client/js/util/GrowiRenderer.js

@@ -1,5 +1,4 @@
 import MarkdownIt from 'markdown-it';
 import MarkdownIt from 'markdown-it';
-import xss from 'xss';
 
 
 import Linker        from './PreProcessor/Linker';
 import Linker        from './PreProcessor/Linker';
 import CsvToTable    from './PreProcessor/CsvToTable';
 import CsvToTable    from './PreProcessor/CsvToTable';
@@ -19,6 +18,9 @@ import BlockdiagConfigurer from './markdown-it/blockdiag';
 import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
 import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
 import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
 import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
 
 
+const logger = require('@alias/logger')('growi:util:GrowiRenderer');
+
+
 export default class GrowiRenderer {
 export default class GrowiRenderer {
 
 
   /**
   /**
@@ -34,8 +36,6 @@ export default class GrowiRenderer {
       { isAutoSetup: true },      // default options
       { isAutoSetup: true },      // default options
       options || {});             // specified options
       options || {});             // specified options
 
 
-    this.xssFilterForCode = new xss.FilterXSS();
-
     // initialize processors
     // initialize processors
     //  that will be retrieved if originRenderer exists
     //  that will be retrieved if originRenderer exists
     this.preProcessors = this.originRenderer.preProcessors || [
     this.preProcessors = this.originRenderer.preProcessors || [
@@ -166,33 +166,43 @@ export default class GrowiRenderer {
     const config = this.crowi.getConfig();
     const config = this.crowi.getConfig();
     const noborder = (!config.highlightJsStyleBorder) ? 'hljs-no-border' : '';
     const noborder = (!config.highlightJsStyleBorder) ? 'hljs-no-border' : '';
 
 
+    let citeTag = '';
+    let hljsLang = 'plaintext';
+    let showLinenumbers = false;
+
     if (langExt) {
     if (langExt) {
-      // https://regex101.com/r/qGs7eZ/1
-      const match = langExt.match(/^([^:=\n]+)(=([^:=\n]*))?(:([^:=\n]+))?(=([^:=\n]*))?$/);
+      // https://regex101.com/r/qGs7eZ/3
+      const match = langExt.match(/^([^:=\n]+)?(=([^:=\n]*))?(:([^:=\n]*))?(=([^:=\n]*))?$/);
 
 
       const lang = match[1];
       const lang = match[1];
       const fileName = match[5] || null;
       const fileName = match[5] || null;
-      const showLinenumbers = (match[2] != null) || (match[6] != null);
-
-      const citeTag = (fileName) ? `<cite>${fileName}</cite>` : '';
+      showLinenumbers = (match[2] != null) || (match[6] != null);
 
 
+      if (fileName != null) {
+        citeTag = `<cite>${fileName}</cite>`;
+      }
       if (hljs.getLanguage(lang)) {
       if (hljs.getLanguage(lang)) {
-        try {
-          const highlightCode = showLinenumbers ? hljs.lineNumbersValue(hljs.highlight(lang, code, true).value) : hljs.highlight(lang, code, true).value;
-          return `<pre class="hljs ${noborder}">${citeTag}<code class="language-${lang}">${highlightCode}</code></pre>`;
-        }
-        catch (__) {
-          return `<pre class="hljs ${noborder}">${citeTag}<code class="language-${lang}">${code}}</code></pre>`;
-        }
+        hljsLang = lang;
       }
       }
-      else {
-        const escapedCode = this.xssFilterForCode.process(code);
-        return `<pre class="hljs ${noborder}">${citeTag}<code>${escapedCode}</code></pre>`;
+    }
+
+    let highlightCode = code;
+    try {
+      highlightCode = hljs.highlight(hljsLang, code, true).value;
+
+      // add line numbers
+      if (showLinenumbers) {
+        highlightCode = hljs.lineNumbersValue((highlightCode));
       }
       }
     }
     }
+    catch (err) {
+      logger.error(err);
+    }
+
+    return `<pre class="hljs ${noborder}">${citeTag}<code>${highlightCode}</code></pre>`;
+  }
 
 
-    const escapedCode = this.xssFilterForCode.process(code);
-    return `<pre class="hljs ${noborder}"><code>${escapedCode}</code></pre>`;
+  highlightCode(code, lang) {
   }
   }
 
 
 }
 }

+ 2 - 2
src/client/js/util/interceptor/detach-code-blocks.js

@@ -50,8 +50,8 @@ export class DetachCodeBlockInterceptor extends BasicInterceptor {
 
 
     context.dcbContextMap = {};
     context.dcbContextMap = {};
 
 
-    // see: https://regex101.com/r/8PAEcC/4
-    context[targetKey] = context[targetKey].replace(/((```|~~~)(.|[\r\n])*?(```|~~~))|(`[^\r\n]*?`)|(<pre>(.|[\r\n])*?<\/pre>)|(<pre\s[^>]*>(.|[\r\n])*?<\/pre>)/gm, (all) => {
+    // see: https://regex101.com/r/8PAEcC/5
+    context[targetKey] = context[targetKey].replace(/(^(```|~~~)(.|[\r\n])*?(```|~~~)$)|(`[^\r\n]*?`)|(<pre>(.|[\r\n])*?<\/pre>)|(<pre\s[^>]*>(.|[\r\n])*?<\/pre>)/gm, (all) => {
       // create ID
       // create ID
       const replaceId = 'dcb-' + this.createRandomStr(8);
       const replaceId = 'dcb-' + this.createRandomStr(8);
       this.logger.debug(`'replaceId'=${replaceId} : `, all);
       this.logger.debug(`'replaceId'=${replaceId} : `, all);

+ 24 - 0
src/client/styles/scss/_admin.scss

@@ -79,6 +79,14 @@
     .btn.active[data-active-class="primary"] {
     .btn.active[data-active-class="primary"] {
       @include active-color($btn-primary-color, $btn-primary-bg, $btn-primary-border);
       @include active-color($btn-primary-color, $btn-primary-bg, $btn-primary-border);
     }
     }
+
+    // disabled btn-group styles
+    &.btn-group-disabled {
+      .btn:hover {
+        background-color: unset;
+        cursor: not-allowed;
+      }
+    }
   }
   }
 
 
   // theme selector
   // theme selector
@@ -117,4 +125,20 @@
       }
       }
     }
     }
   }
   }
+
+  .settings-table {
+    table-layout: fixed;
+
+    .item-name {
+      width: 150px;
+    }
+
+    td.unused {
+      opacity: 0.5;
+    }
+
+    &.use-only-env-vars .from-env-vars {
+      background-color: rgba($info, 0.1);
+    }
+  }
 }
 }

+ 5 - 6
src/server/crowi/express-init.js

@@ -45,7 +45,10 @@ module.exports = function(crowi, app) {
       detection: {
       detection: {
         order: ['userSettingDetector', 'header', 'navigator'],
         order: ['userSettingDetector', 'header', 'navigator'],
       },
       },
-      overloadTranslationOptionHandler: i18nSprintf.overloadTranslationOptionHandler
+      overloadTranslationOptionHandler: i18nSprintf.overloadTranslationOptionHandler,
+
+      // change nsSeparator from ':' to '::' because ':' is used in config keys and these are used in i18n keys
+      nsSeparator: '::'
     });
     });
 
 
   app.use(helmet());
   app.use(helmet());
@@ -63,12 +66,8 @@ module.exports = function(crowi, app) {
     req.config = config;
     req.config = config;
     req.csrfToken = null;
     req.csrfToken = null;
 
 
-    config.crowi['app:siteUrl:fixed'] = (config.crowi['app:siteUrl'] != null)
-      ? config.crowi['app:siteUrl']                                                                         // prioritized with v3.2.4 and above
-      : (req.headers['x-forwarded-proto'] == 'https' ? 'https' : req.protocol) + '://' + req.get('host');   // auto generate (default with v3.2.3 and below)
-
     res.locals.req      = req;
     res.locals.req      = req;
-    res.locals.baseUrl  = config.crowi['app:siteUrl:fixed'];
+    res.locals.baseUrl  = crowi.configManager.getSiteUrl();
     res.locals.config   = config;
     res.locals.config   = config;
     res.locals.env      = env;
     res.locals.env      = env;
     res.locals.now      = now;
     res.locals.now      = now;

+ 1 - 1
src/server/events/user.js

@@ -14,7 +14,7 @@ UserEvent.prototype.onActivated = async function(user) {
 
 
   const userPagePath = Page.getUserPagePath(user);
   const userPagePath = Page.getUserPagePath(user);
 
 
-  const page = await Page.findByPathAndViewer(userPagePath, user);
+  const page = await Page.findByPath(userPagePath, user);
 
 
   if (page == null) {
   if (page == null) {
     const body = `# ${user.username}\nThis is ${user.username}'s page`;
     const body = `# ${user.username}\nThis is ${user.username}'s page`;

+ 0 - 1
src/server/form/admin/app.js

@@ -5,7 +5,6 @@ var form = require('express-form')
 
 
 module.exports = form(
 module.exports = form(
   field('settingForm[app:title]').trim(),
   field('settingForm[app:title]').trim(),
-  field('settingForm[app:siteUrl]').trim().required().isUrl(),
   field('settingForm[app:confidential]'),
   field('settingForm[app:confidential]'),
   field('settingForm[app:globalLang]'),
   field('settingForm[app:globalLang]'),
   field('settingForm[app:fileUpload]').trim().toBooleanStrict()
   field('settingForm[app:fileUpload]').trim().toBooleanStrict()

+ 6 - 6
src/server/form/admin/securityPassportSaml.js

@@ -4,12 +4,12 @@ const form = require('express-form');
 const field = form.field;
 const field = form.field;
 
 
 module.exports = form(
 module.exports = form(
-  field('settingForm[security:passport-saml:isEnabled]').trim().toBooleanStrict().required(),
-  field('settingForm[security:passport-saml:entryPoint]').trim().required().isUrl(),
-  field('settingForm[security:passport-saml:issuer]').trim().required(),
-  field('settingForm[security:passport-saml:attrMapId]').trim().required(),
-  field('settingForm[security:passport-saml:attrMapUsername]').trim().required(),
-  field('settingForm[security:passport-saml:attrMapMail]').trim().required(),
+  field('settingForm[security:passport-saml:isEnabled]').trim().toBooleanStrict(),
+  field('settingForm[security:passport-saml:entryPoint]').trim().isUrl(),
+  field('settingForm[security:passport-saml:issuer]').trim(),
+  field('settingForm[security:passport-saml:attrMapId]').trim(),
+  field('settingForm[security:passport-saml:attrMapUsername]').trim(),
+  field('settingForm[security:passport-saml:attrMapMail]').trim(),
   field('settingForm[security:passport-saml:attrMapFirstName]').trim(),
   field('settingForm[security:passport-saml:attrMapFirstName]').trim(),
   field('settingForm[security:passport-saml:attrMapLastName]').trim(),
   field('settingForm[security:passport-saml:attrMapLastName]').trim(),
   field('settingForm[security:passport-saml:cert]').trim(),
   field('settingForm[security:passport-saml:cert]').trim(),

+ 8 - 0
src/server/form/admin/siteUrl.js

@@ -0,0 +1,8 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('settingForm[app:siteUrl]').trim().isUrl()
+);

+ 1 - 0
src/server/form/index.js

@@ -12,6 +12,7 @@ module.exports = {
   },
   },
   admin: {
   admin: {
     app: require('./admin/app'),
     app: require('./admin/app'),
+    siteUrl: require('./admin/siteUrl'),
     mail: require('./admin/mail'),
     mail: require('./admin/mail'),
     aws: require('./admin/aws'),
     aws: require('./admin/aws'),
     importerEsa: require('./admin/importerEsa'),
     importerEsa: require('./admin/importerEsa'),

+ 1 - 16
src/server/models/config.js

@@ -322,11 +322,6 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
     return getValueForCrowiNS(config, key);
   };
   };
 
 
-  configSchema.statics.isEnabledPassportSaml = function(config) {
-    const key = 'security:passport-saml:isEnabled';
-    return getValueForCrowiNS(config, key);
-  };
-
   configSchema.statics.isEnabledPassportGoogle = function(config) {
   configSchema.statics.isEnabledPassportGoogle = function(config) {
     const key = 'security:passport-google:isEnabled';
     const key = 'security:passport-google:isEnabled';
     return getValueForCrowiNS(config, key);
     return getValueForCrowiNS(config, key);
@@ -342,16 +337,6 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
     return getValueForCrowiNS(config, key);
   };
   };
 
 
-  configSchema.statics.isSameUsernameTreatedAsIdenticalUser = function(config, providerType) {
-    const key = `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`;
-    return getValueForCrowiNS(config, key);
-  };
-
-  configSchema.statics.isSameEmailTreatedAsIdenticalUser = function(config, providerType) {
-    const key = `security:passport-${providerType}:isSameEmailTreatedAsIdenticalUser`;
-    return getValueForCrowiNS(config, key);
-  };
-
   configSchema.statics.isUploadable = function(config) {
   configSchema.statics.isUploadable = function(config) {
     const method = process.env.FILE_UPLOAD || 'aws';
     const method = process.env.FILE_UPLOAD || 'aws';
 
 
@@ -619,7 +604,7 @@ module.exports = function(crowi) {
     const local_config = {
     const local_config = {
       crowi: {
       crowi: {
         title: Config.appTitle(crowi),
         title: Config.appTitle(crowi),
-        url: config.crowi['app:siteUrl:fixed'] || '',
+        url: crowi.configManager.getSiteUrl(),
       },
       },
       upload: {
       upload: {
         image: Config.isUploadable(config),
         image: Config.isUploadable(config),

+ 49 - 18
src/server/models/page.js

@@ -62,6 +62,26 @@ const pageSchema = new mongoose.Schema({
 pageSchema.plugin(uniqueValidator);
 pageSchema.plugin(uniqueValidator);
 
 
 
 
+/**
+ * return an array of ancestors paths that is extracted from specified pagePath
+ * e.g.
+ *  when `pagePath` is `/foo/bar/baz`,
+ *  this method returns [`/foo/bar/baz`, `/foo/bar`, `/foo`, `/`]
+ *
+ * @param {string} pagePath
+ * @return {string[]} ancestors paths
+ */
+const extractToAncestorsPaths = (pagePath) => {
+  const ancestorsPaths = [];
+
+  let parentPath;
+  while (parentPath !== '/') {
+    parentPath = nodePath.dirname(parentPath || pagePath);
+    ancestorsPaths.push(parentPath);
+  }
+
+  return ancestorsPaths;
+};
 
 
 const addSlashOfEnd = (path) => {
 const addSlashOfEnd = (path) => {
   let returnPath = path;
   let returnPath = path;
@@ -168,22 +188,24 @@ class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
-  addConditionToFilteringByViewer(user, userGroups, showPagesRestrictedByOwner, showPagesRestrictedByGroup) {
+  addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup) {
     const grantConditions = [
     const grantConditions = [
       {grant: null},
       {grant: null},
       {grant: GRANT_PUBLIC},
       {grant: GRANT_PUBLIC},
     ];
     ];
 
 
+    if (showAnyoneKnowsLink) {
+      grantConditions.push({grant: GRANT_RESTRICTED});
+    }
+
     if (showPagesRestrictedByOwner) {
     if (showPagesRestrictedByOwner) {
       grantConditions.push(
       grantConditions.push(
-        {grant: GRANT_RESTRICTED},
         {grant: GRANT_SPECIFIED},
         {grant: GRANT_SPECIFIED},
         {grant: GRANT_OWNER},
         {grant: GRANT_OWNER},
       );
       );
     }
     }
     else if (user != null) {
     else if (user != null) {
       grantConditions.push(
       grantConditions.push(
-        {grant: GRANT_RESTRICTED, grantedUsers: user._id},
         {grant: GRANT_SPECIFIED, grantedUsers: user._id},
         {grant: GRANT_SPECIFIED, grantedUsers: user._id},
         {grant: GRANT_OWNER, grantedUsers: user._id},
         {grant: GRANT_OWNER, grantedUsers: user._id},
       );
       );
@@ -549,7 +571,7 @@ module.exports = function(crowi) {
     }
     }
 
 
     const queryBuilder = new PageQueryBuilder(baseQuery);
     const queryBuilder = new PageQueryBuilder(baseQuery);
-    queryBuilder.addConditionToFilteringByViewer(user, userGroups);
+    queryBuilder.addConditionToFilteringByViewer(user, userGroups, true);
 
 
     const count = await queryBuilder.query.exec();
     const count = await queryBuilder.query.exec();
     return count > 0;
     return count > 0;
@@ -571,7 +593,7 @@ module.exports = function(crowi) {
     }
     }
 
 
     const queryBuilder = new PageQueryBuilder(baseQuery);
     const queryBuilder = new PageQueryBuilder(baseQuery);
-    queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
+    queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
 
 
     return await queryBuilder.query.exec();
     return await queryBuilder.query.exec();
   };
   };
@@ -604,7 +626,7 @@ module.exports = function(crowi) {
     }
     }
 
 
     const queryBuilder = new PageQueryBuilder(baseQuery);
     const queryBuilder = new PageQueryBuilder(baseQuery);
-    queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
+    queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
 
 
     return await queryBuilder.query.exec();
     return await queryBuilder.query.exec();
   };
   };
@@ -623,7 +645,10 @@ module.exports = function(crowi) {
       return null;
       return null;
     }
     }
 
 
-    const parentPath = nodePath.dirname(path);
+    const ancestorsPaths = extractToAncestorsPaths(path);
+
+    // pick the longest one
+    const baseQuery = this.findOne({path: { $in: ancestorsPaths }}).sort({path: -1});
 
 
     let relatedUserGroups = userGroups;
     let relatedUserGroups = userGroups;
     if (user != null && relatedUserGroups == null) {
     if (user != null && relatedUserGroups == null) {
@@ -632,11 +657,10 @@ module.exports = function(crowi) {
       relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
       relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
     }
 
 
-    const page = await this.findByPathAndViewer(parentPath, user, relatedUserGroups);
+    const queryBuilder = new PageQueryBuilder(baseQuery);
+    queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
 
 
-    return (page != null)
-      ? page
-      : this.findAncestorByPathAndViewer(parentPath, user, relatedUserGroups);
+    return await queryBuilder.query.exec();
   };
   };
 
 
   pageSchema.statics.findByRedirectTo = function(path) {
   pageSchema.statics.findByRedirectTo = function(path) {
@@ -650,7 +674,7 @@ module.exports = function(crowi) {
     const builder = new PageQueryBuilder(this.find());
     const builder = new PageQueryBuilder(this.find());
     builder.addConditionToListWithDescendants(path, option);
     builder.addConditionToListWithDescendants(path, option);
 
 
-    return await findListFromBuilderAndViewer(builder, user, option);
+    return await findListFromBuilderAndViewer(builder, user, false, option);
   };
   };
 
 
   /**
   /**
@@ -660,7 +684,7 @@ module.exports = function(crowi) {
     const builder = new PageQueryBuilder(this.find());
     const builder = new PageQueryBuilder(this.find());
     builder.addConditionToListByStartWith(path, option);
     builder.addConditionToListByStartWith(path, option);
 
 
-    return await findListFromBuilderAndViewer(builder, user, option);
+    return await findListFromBuilderAndViewer(builder, user, false, option);
   };
   };
 
 
   /**
   /**
@@ -674,7 +698,12 @@ module.exports = function(crowi) {
     const opt = Object.assign({sort: 'createdAt', desc: -1}, option);
     const opt = Object.assign({sort: 'createdAt', desc: -1}, option);
     const builder = new PageQueryBuilder(this.find({ creator: targetUser._id }));
     const builder = new PageQueryBuilder(this.find({ creator: targetUser._id }));
 
 
-    return await findListFromBuilderAndViewer(builder, currentUser, opt);
+    let showAnyoneKnowsLink = null;
+    if (targetUser != null && currentUser != null) {
+      showAnyoneKnowsLink = targetUser._id.equals(currentUser._id);
+    }
+
+    return await findListFromBuilderAndViewer(builder, currentUser, showAnyoneKnowsLink, opt);
   };
   };
 
 
   pageSchema.statics.findListByPageIds = async function(ids, option) {
   pageSchema.statics.findListByPageIds = async function(ids, option) {
@@ -700,9 +729,10 @@ module.exports = function(crowi) {
    * find pages by PageQueryBuilder
    * find pages by PageQueryBuilder
    * @param {PageQueryBuilder} builder
    * @param {PageQueryBuilder} builder
    * @param {User} user
    * @param {User} user
+   * @param {boolean} showAnyoneKnowsLink
    * @param {any} option
    * @param {any} option
    */
    */
-  async function findListFromBuilderAndViewer(builder, user, option) {
+  async function findListFromBuilderAndViewer(builder, user, showAnyoneKnowsLink, option) {
     validateCrowi();
     validateCrowi();
 
 
     const User = crowi.model('User');
     const User = crowi.model('User');
@@ -721,7 +751,7 @@ module.exports = function(crowi) {
     }
     }
 
 
     // add grant conditions
     // add grant conditions
-    await addConditionToFilteringByViewerForList(builder, user);
+    await addConditionToFilteringByViewerForList(builder, user, showAnyoneKnowsLink);
 
 
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
 
 
@@ -740,8 +770,9 @@ module.exports = function(crowi) {
    *
    *
    * @param {PageQueryBuilder} builder
    * @param {PageQueryBuilder} builder
    * @param {User} user
    * @param {User} user
+   * @param {boolean} showAnyoneKnowsLink
    */
    */
-  async function addConditionToFilteringByViewerForList(builder, user) {
+  async function addConditionToFilteringByViewerForList(builder, user, showAnyoneKnowsLink) {
     validateCrowi();
     validateCrowi();
 
 
     const Config = crowi.model('Config');
     const Config = crowi.model('Config');
@@ -758,7 +789,7 @@ module.exports = function(crowi) {
       userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
       userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
     }
 
 
-    return builder.addConditionToFilteringByViewer(user, userGroups, !hidePagesRestrictedByOwner, !hidePagesRestrictedByGroup);
+    return builder.addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink, !hidePagesRestrictedByOwner, !hidePagesRestrictedByGroup);
   }
   }
 
 
   /**
   /**

+ 3 - 2
src/server/models/user.js

@@ -1,5 +1,6 @@
 module.exports = function(crowi) {
 module.exports = function(crowi) {
   const debug = require('debug')('growi:models:user')
   const debug = require('debug')('growi:models:user')
+    , logger = require('@alias/logger')('growi:models:user')
     , path = require('path')
     , path = require('path')
     , mongoose = require('mongoose')
     , mongoose = require('mongoose')
     , mongoosePaginate = require('mongoose-paginate')
     , mongoosePaginate = require('mongoose-paginate')
@@ -706,7 +707,7 @@ module.exports = function(crowi) {
                 vars: {
                 vars: {
                   email: user.email,
                   email: user.email,
                   password: user.password,
                   password: user.password,
-                  url: config.crowi['app:siteUrl:fixed'],
+                  url: crowi.configManager.getSiteUrl(),
                   appTitle: Config.appTitle(config),
                   appTitle: Config.appTitle(config),
                 }
                 }
               },
               },
@@ -767,7 +768,7 @@ module.exports = function(crowi) {
 
 
     newUser.save(function(err, userData) {
     newUser.save(function(err, userData) {
       if (err) {
       if (err) {
-        debug('createUserByEmailAndPassword failed: ', err);
+        logger.error('createUserByEmailAndPasswordAndStatus failed: ', err);
         return callback(err);
         return callback(err);
       }
       }
 
 

+ 64 - 39
src/server/routes/admin.js

@@ -692,48 +692,37 @@ module.exports = function(crowi, app) {
   };
   };
 
 
   // グループ詳細
   // グループ詳細
-  actions.userGroup.detail = function(req, res) {
+  actions.userGroup.detail = async function(req, res) {
     const userGroupId = req.params.id;
     const userGroupId = req.params.id;
     const renderVar = {
     const renderVar = {
       userGroup: null,
       userGroup: null,
       userGroupRelations: [],
       userGroupRelations: [],
-      pageGroupRelations: [],
-      notRelatedusers: []
+      notRelatedusers: [],
+      relatedPages: [],
     };
     };
-    let targetUserGroup = null;
-    UserGroup.findOne({ _id: userGroupId})
-      .then(function(userGroup) {
-        targetUserGroup = userGroup;
-        if (targetUserGroup == null) {
-          req.flash('errorMessage', 'グループがありません');
-          throw new Error('no userGroup is exists. ', name);
-        }
-        else {
-          renderVar.userGroup = targetUserGroup;
-
-          return Promise.all([
-            // get all user and group relations
-            UserGroupRelation.findAllRelationForUserGroup(targetUserGroup),
-            // get all page and group relations
-            PageGroupRelation.findAllRelationForUserGroup(targetUserGroup),
-            // get all not related users for group
-            UserGroupRelation.findUserByNotRelatedGroup(targetUserGroup),
-          ]);
-        }
-      })
-      .then((resolves) => {
-        renderVar.userGroupRelations = resolves[0];
-        renderVar.pageGroupRelations = resolves[1];
-        renderVar.notRelatedusers = resolves[2];
-        debug('notRelatedusers', renderVar.notRelatedusers);
 
 
-        return res.render('admin/user-group-detail', renderVar);
-      })
-      .catch((err) => {
-        req.flash('errorMessage', 'ユーザグループの検索に失敗しました');
-        debug('Error on get userGroupDetail', err);
-        return res.redirect('/admin/user-groups');
-      });
+    const userGroup = await UserGroup.findOne({ _id: userGroupId});
+
+    if (userGroup == null) {
+      logger.error('no userGroup is exists. ', userGroupId);
+      req.flash('errorMessage', 'グループがありません');
+      return res.redirect('/admin/user-groups');
+    }
+    renderVar.userGroup = userGroup;
+
+    const resolves = await Promise.all([
+      // get all user and group relations
+      UserGroupRelation.findAllRelationForUserGroup(userGroup),
+      // get all not related users for group
+      UserGroupRelation.findUserByNotRelatedGroup(userGroup),
+      // get all related pages
+      Page.find({grant: Page.GRANT_USER_GROUP, grantedGroup: { $in: [userGroup] }}),
+    ]);
+    renderVar.userGroupRelations = resolves[0];
+    renderVar.notRelatedusers = resolves[1];
+    renderVar.relatedPages = resolves[2];
+
+    return res.render('admin/user-group-detail', renderVar);
   };
   };
 
 
   //グループの生成
   //グループの生成
@@ -1015,6 +1004,25 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
+  actions.api.asyncAppSetting = async(req, res) => {
+    const form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+
+    debug('form content', form);
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', form);
+      return res.json({status: true});
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json({status: false});
+    }
+  };
+
   actions.api.securitySetting = function(req, res) {
   actions.api.securitySetting = function(req, res) {
     const form = req.form.settingForm;
     const form = req.form.settingForm;
     const config = crowi.getConfig();
     const config = crowi.getConfig();
@@ -1070,18 +1078,19 @@ module.exports = function(crowi, app) {
   actions.api.securityPassportSamlSetting = async(req, res) => {
   actions.api.securityPassportSamlSetting = async(req, res) => {
     const form = req.form.settingForm;
     const form = req.form.settingForm;
 
 
+    validateSamlSettingForm(req.form, req.t);
+
     if (!req.form.isValid) {
     if (!req.form.isValid) {
       return res.json({status: false, message: req.form.errors.join('\n')});
       return res.json({status: false, message: req.form.errors.join('\n')});
     }
     }
 
 
     debug('form content', form);
     debug('form content', form);
-    await saveSettingAsync(form);
-    const config = await crowi.getConfig();
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', form);
 
 
     // reset strategy
     // reset strategy
     await crowi.passportService.resetSamlStrategy();
     await crowi.passportService.resetSamlStrategy();
     // setup strategy
     // setup strategy
-    if (Config.isEnabledPassportSaml(config)) {
+    if (crowi.configManager.getConfig('crowi', 'security:passport-saml:isEnabled')) {
       try {
       try {
         await crowi.passportService.setupSamlStrategy(true);
         await crowi.passportService.setupSamlStrategy(true);
       }
       }
@@ -1489,6 +1498,22 @@ module.exports = function(crowi, app) {
     }, callback);
     }, callback);
   }
   }
 
 
+  /**
+   * validate setting form values for SAML
+   *
+   * This validation checks, for the value of each mandatory items,
+   * whether it from the environment variables is empty and form value to update it is empty.
+   */
+  function validateSamlSettingForm(form, t) {
+    for (const key of crowi.passportService.mandatoryConfigKeysForSaml) {
+      const formValue = form.settingForm[key];
+      if (crowi.configManager.getConfigFromEnvVars('crowi', key) === null && formValue === '') {
+        const formItemName = t(`security_setting.form_item_name.${key}`);
+        form.errors.push(t('form_validation.required', formItemName));
+      }
+    }
+  }
+
   return actions;
   return actions;
 };
 };
 
 

+ 1 - 2
src/server/routes/attachment.js

@@ -92,8 +92,7 @@ module.exports = function(crowi, app) {
       //   1. this is buggy (doesn't work on Win)
       //   1. this is buggy (doesn't work on Win)
       //   2. ensure backward compatibility of data
       //   2. ensure backward compatibility of data
 
 
-      // var config = crowi.getConfig();
-      // var baseUrl = (config.crowi['app:siteUrl:fixed'] || '');
+      // var baseUrl = crowi.configManager.getSiteUrl();
       return res.json(ApiResponse.success({
       return res.json(ApiResponse.success({
         attachments: attachments.map(at => {
         attachments: attachments.map(at => {
           const fileUrl = at.fileUrl;
           const fileUrl = at.fileUrl;

+ 1 - 7
src/server/routes/hackmd.js

@@ -39,13 +39,7 @@ module.exports = function(crowi, app) {
       agentScriptContentTpl = swig.compileFile(agentScriptPath);
       agentScriptContentTpl = swig.compileFile(agentScriptPath);
     }
     }
 
 
-    let origin = `${req.protocol}://${req.get('host')}`;
-
-    // use config.crowi['app:siteUrl:fixed'] when exist req.headers['x-forwarded-proto'].
-    // refs: lib/crowi/express-init.js
-    if (config.crowi && config.crowi['app:siteUrl:fixed']) {
-      origin = config.crowi['app:siteUrl:fixed'];
-    }
+    const origin = crowi.configManager.getSiteUrl();
 
 
     // generate definitions to replace
     // generate definitions to replace
     const definitions = {
     const definitions = {

+ 7 - 6
src/server/routes/index.js

@@ -54,12 +54,13 @@ module.exports = function(crowi, app) {
   app.get('/login/google'            , login.loginGoogle);
   app.get('/login/google'            , login.loginGoogle);
   app.get('/logout'                  , logout.logout);
   app.get('/logout'                  , logout.logout);
 
 
-  app.get('/admin'                      , loginRequired(crowi, app) , middleware.adminRequired() , admin.index);
-  app.get('/admin/app'                  , loginRequired(crowi, app) , middleware.adminRequired() , admin.app.index);
-  app.post('/_api/admin/settings/app'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.app, admin.api.appSetting);
-  app.post('/_api/admin/settings/mail'  , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.mail, admin.api.appSetting);
-  app.post('/_api/admin/settings/aws'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.aws, admin.api.appSetting);
-  app.post('/_api/admin/settings/plugin', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.plugin, admin.api.appSetting);
+  app.get('/admin'                          , loginRequired(crowi, app) , middleware.adminRequired() , admin.index);
+  app.get('/admin/app'                      , loginRequired(crowi, app) , middleware.adminRequired() , admin.app.index);
+  app.post('/_api/admin/settings/app'       , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.app, admin.api.appSetting);
+  app.post('/_api/admin/settings/siteUrl'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.siteUrl, admin.api.asyncAppSetting);
+  app.post('/_api/admin/settings/mail'      , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.mail, admin.api.appSetting);
+  app.post('/_api/admin/settings/aws'       , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.aws, admin.api.appSetting);
+  app.post('/_api/admin/settings/plugin'    , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.plugin, admin.api.appSetting);
 
 
   // security admin
   // security admin
   app.get('/admin/security'                     , loginRequired(crowi, app) , middleware.adminRequired() , admin.security.index);
   app.get('/admin/security'                     , loginRequired(crowi, app) , middleware.adminRequired() , admin.security.index);

+ 8 - 8
src/server/routes/login-passport.js

@@ -5,7 +5,6 @@ module.exports = function(crowi, app) {
     , logger = require('@alias/logger')('growi:routes:login-passport')
     , logger = require('@alias/logger')('growi:routes:login-passport')
     , passport = require('passport')
     , passport = require('passport')
     , config = crowi.getConfig()
     , config = crowi.getConfig()
-    , Config = crowi.model('Config')
     , ExternalAccount = crowi.model('ExternalAccount')
     , ExternalAccount = crowi.model('ExternalAccount')
     , passportService = crowi.passportService
     , passportService = crowi.passportService
     ;
     ;
@@ -355,11 +354,11 @@ module.exports = function(crowi, app) {
   const loginPassportSamlCallback = async(req, res) => {
   const loginPassportSamlCallback = async(req, res) => {
     const providerId = 'saml';
     const providerId = 'saml';
     const strategyName = 'saml';
     const strategyName = 'saml';
-    const attrMapId = config.crowi['security:passport-saml:attrMapId'];
-    const attrMapUsername = config.crowi['security:passport-saml:attrMapUsername'];
-    const attrMapMail = config.crowi['security:passport-saml:attrMapMail'];
-    const attrMapFirstName = config.crowi['security:passport-saml:attrMapFirstName'] || 'firstName';
-    const attrMapLastName = config.crowi['security:passport-saml:attrMapLastName'] || 'lastName';
+    const attrMapId = crowi.configManager.getConfig('crowi', 'security:passport-saml:attrMapId');
+    const attrMapUsername = crowi.configManager.getConfig('crowi', 'security:passport-saml:attrMapUsername');
+    const attrMapMail = crowi.configManager.getConfig('crowi', 'security:passport-saml:attrMapMail');
+    const attrMapFirstName = crowi.configManager.getConfig('crowi', 'security:passport-saml:attrMapFirstName') || 'firstName';
+    const attrMapLastName = crowi.configManager.getConfig('crowi', 'security:passport-saml:attrMapLastName') || 'lastName';
 
 
     let response;
     let response;
     try {
     try {
@@ -428,8 +427,9 @@ module.exports = function(crowi, app) {
 
 
   const getOrCreateUser = async(req, res, userInfo, providerId) => {
   const getOrCreateUser = async(req, res, userInfo, providerId) => {
     // get option
     // get option
-    const isSameUsernameTreatedAsIdenticalUser = Config.isSameUsernameTreatedAsIdenticalUser(config, providerId);
-    const isSameEmailTreatedAsIdenticalUser = Config.isSameEmailTreatedAsIdenticalUser(config, providerId);
+    const isSameUsernameTreatedAsIdenticalUser = crowi.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
+    const isSameEmailTreatedAsIdenticalUser = crowi.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
+
     try {
     try {
       // find or register(create) user
       // find or register(create) user
       const externalAccount = await ExternalAccount.findOrRegister(
       const externalAccount = await ExternalAccount.findOrRegister(

+ 4 - 4
src/server/routes/login.js

@@ -106,7 +106,7 @@ module.exports = function(crowi, app) {
   };
   };
 
 
   actions.loginGoogle = function(req, res) {
   actions.loginGoogle = function(req, res) {
-    var googleAuth = require('../util/googleAuth')(config);
+    var googleAuth = require('../util/googleAuth')(crowi);
     var code = req.session.googleAuthCode || null;
     var code = req.session.googleAuthCode || null;
 
 
     if (!code) {
     if (!code) {
@@ -140,7 +140,7 @@ module.exports = function(crowi, app) {
   };
   };
 
 
   actions.register = function(req, res) {
   actions.register = function(req, res) {
-    var googleAuth = require('../util/googleAuth')(config);
+    var googleAuth = require('../util/googleAuth')(crowi);
 
 
     // ログイン済みならさようなら
     // ログイン済みならさようなら
     if (req.user) {
     if (req.user) {
@@ -212,7 +212,7 @@ module.exports = function(crowi, app) {
                       vars: {
                       vars: {
                         createdUser: userData,
                         createdUser: userData,
                         adminUser: adminUser,
                         adminUser: adminUser,
-                        url: config.crowi['app:siteUrl:fixed'],
+                        url: crowi.configManager.getSiteUrl(),
                         appTitle: appTitle,
                         appTitle: appTitle,
                       }
                       }
                     },
                     },
@@ -321,7 +321,7 @@ module.exports = function(crowi, app) {
   };
   };
 
 
   actions.registerGoogle = function(req, res) {
   actions.registerGoogle = function(req, res) {
-    var googleAuth = require('../util/googleAuth')(config);
+    var googleAuth = require('../util/googleAuth')(crowi);
     googleAuth.createAuthUrl(req, function(err, redirectUrl) {
     googleAuth.createAuthUrl(req, function(err, redirectUrl) {
       if (err) {
       if (err) {
         // TODO
         // TODO

+ 2 - 2
src/server/routes/me.js

@@ -384,7 +384,7 @@ module.exports = function(crowi, app) {
   };
   };
 
 
   actions.authGoogle = function(req, res) {
   actions.authGoogle = function(req, res) {
-    var googleAuth = require('../util/googleAuth')(config);
+    var googleAuth = require('../util/googleAuth')(crowi);
 
 
     var userData = req.user;
     var userData = req.user;
 
 
@@ -413,7 +413,7 @@ module.exports = function(crowi, app) {
   };
   };
 
 
   actions.authGoogleCallback = function(req, res) {
   actions.authGoogleCallback = function(req, res) {
-    var googleAuth = require('../util/googleAuth')(config);
+    var googleAuth = require('../util/googleAuth')(crowi);
     var userData = req.user;
     var userData = req.user;
 
 
     googleAuth.handleCallback(req, function(err, tokenInfo) {
     googleAuth.handleCallback(req, function(err, tokenInfo) {

+ 1 - 4
src/server/routes/page.js

@@ -120,7 +120,6 @@ module.exports = function(crowi, app) {
     if (userData != null) {
     if (userData != null) {
       renderVars.pageUser = userData;
       renderVars.pageUser = userData;
       renderVars.bookmarkList = await Bookmark.findByUser(userData, {limit: 10, populatePage: true, requestUser: requestUser});
       renderVars.bookmarkList = await Bookmark.findByUser(userData, {limit: 10, populatePage: true, requestUser: requestUser});
-      renderVars.createdList = await Page.findListByCreator(userData, {limit: 10}, requestUser);
     }
     }
   }
   }
 
 
@@ -622,9 +621,9 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
       options.grantUserGroupId = grantUserGroupId;
     }
     }
 
 
-    try {
       const Revision = crowi.model('Revision');
       const Revision = crowi.model('Revision');
       const previousRevision = await Revision.findById(revisionId);
       const previousRevision = await Revision.findById(revisionId);
+    try {
       page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
       page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
     }
     }
     catch (err) {
     catch (err) {
@@ -651,8 +650,6 @@ module.exports = function(crowi, app) {
 
 
     // user notification
     // user notification
     if (isSlackEnabled && slackChannels != null) {
     if (isSlackEnabled && slackChannels != null) {
-      const Revision = crowi.model('Revision');
-      const previousRevision = await Revision.findById(page.revision);
       await notifyToSlackByUser(page, req.user, slackChannels, 'update', previousRevision);
       await notifyToSlackByUser(page, req.user, slackChannels, 'update', previousRevision);
     }
     }
   };
   };

+ 2 - 1
src/server/routes/revision.js

@@ -5,6 +5,7 @@ module.exports = function(crowi, app) {
   const logger = require('@alias/logger')('growi:routes:revision');
   const logger = require('@alias/logger')('growi:routes:revision');
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
   const Revision = crowi.model('Revision');
   const Revision = crowi.model('Revision');
+  const User = crowi.model('User');
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
 
 
   const actions = {};
   const actions = {};
@@ -33,7 +34,7 @@ module.exports = function(crowi, app) {
     }
     }
 
 
     try {
     try {
-      const revision = await Revision.findById(revisionId).populate('author', 'User');
+      const revision = await Revision.findById(revisionId).populate('author', User.USER_PUBLIC_FIELDS);
       return res.json(ApiResponse.success({ revision }));
       return res.json(ApiResponse.success({ revision }));
     }
     }
     catch (err) {
     catch (err) {

+ 62 - 0
src/server/service/config-loader.js

@@ -110,6 +110,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
   //   type:    ,
   //   type:    ,
   //   default:
   //   default:
   // },
   // },
+  APP_SITE_URL: {
+    ns:      'crowi',
+    key:     'app:siteUrl',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
+    ns:      'crowi',
+    key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',
+    type:    TYPES.BOOLEAN,
+    default: false
+  },
+  SAML_ENABLED: {
+    ns:      'crowi',
+    key:     'security:passport-saml:isEnabled',
+    type:    TYPES.BOOLEAN,
+    default: null
+  },
   SAML_ENTRY_POINT: {
   SAML_ENTRY_POINT: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'security:passport-saml:entryPoint',
     key:     'security:passport-saml:entryPoint',
@@ -128,6 +146,36 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     type:    TYPES.STRING,
     default: null
     default: null
   },
   },
+  SAML_ATTR_MAPPING_ID: {
+    ns:      'crowi',
+    key:     'security:passport-saml:attrMapId',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_ATTR_MAPPING_USERNAME: {
+    ns:      'crowi',
+    key:     'security:passport-saml:attrMapUsername',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_ATTR_MAPPING_MAIL: {
+    ns:      'crowi',
+    key:     'security:passport-saml:attrMapMail',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_ATTR_MAPPING_FIRST_NAME: {
+    ns:      'crowi',
+    key:     'security:passport-saml:attrMapFirstName',
+    type:    TYPES.STRING,
+    default: null
+  },
+  SAML_ATTR_MAPPING_LAST_NAME: {
+    ns:      'crowi',
+    key:     'security:passport-saml:attrMapLastName',
+    type:    TYPES.STRING,
+    default: null
+  },
   SAML_CERT: {
   SAML_CERT: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'security:passport-saml:cert',
     key:     'security:passport-saml:cert',
@@ -153,6 +201,20 @@ class ConfigLoader {
     let mergedConfigFromDB = Object.assign({'crowi': this.configModel.getDefaultCrowiConfigsObject()}, configFromDB);
     let mergedConfigFromDB = Object.assign({'crowi': this.configModel.getDefaultCrowiConfigsObject()}, configFromDB);
     mergedConfigFromDB = Object.assign({'markdown': this.configModel.getDefaultMarkdownConfigsObject()}, mergedConfigFromDB);
     mergedConfigFromDB = Object.assign({'markdown': this.configModel.getDefaultMarkdownConfigsObject()}, mergedConfigFromDB);
 
 
+
+    // In getConfig API, only null is used as a value to indicate that a config is not set.
+    // So, if a value loaded from the database is emtpy,
+    // it is converted to null because an empty string is used as the same meaning in the config model.
+    // By this processing, whether a value is loaded from the database or from the environment variable,
+    // only null indicates a config is not set.
+    for (const namespace of Object.keys(mergedConfigFromDB)) {
+      for (const key of Object.keys(mergedConfigFromDB[namespace])) {
+        if (mergedConfigFromDB[namespace][key] === '') {
+          mergedConfigFromDB[namespace][key] = null;
+        }
+      }
+    }
+
     return {
     return {
       fromDB: mergedConfigFromDB,
       fromDB: mergedConfigFromDB,
       fromEnvVars: configFromEnvVars
       fromEnvVars: configFromEnvVars

+ 136 - 43
src/server/service/config-manager.js

@@ -1,6 +1,18 @@
 const ConfigLoader = require('../service/config-loader')
 const ConfigLoader = require('../service/config-loader')
   , debug = require('debug')('growi:service:ConfigManager');
   , debug = require('debug')('growi:service:ConfigManager');
 
 
+const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
+  'security:passport-saml:isEnabled',
+  'security:passport-saml:entryPoint',
+  'security:passport-saml:issuer',
+  'security:passport-saml:attrMapId',
+  'security:passport-saml:attrMapUsername',
+  'security:passport-saml:attrMapMail',
+  'security:passport-saml:attrMapFirstName',
+  'security:passport-saml:attrMapLastName',
+  'security:passport-saml:cert'
+];
+
 class ConfigManager {
 class ConfigManager {
 
 
   constructor(configModel) {
   constructor(configModel) {
@@ -21,24 +33,101 @@ class ConfigManager {
   /**
   /**
    * get a config specified by namespace & key
    * get a config specified by namespace & key
    *
    *
-   * Basically, search a specified config from configs loaded from database at first
-   * and then from configs loaded from env vars.
+   * Basically, this searches a specified config from configs loaded from the database at first
+   * and then from configs loaded from the environment variables.
+   *
+   * In some case, this search method changes.
    *
    *
-   * In some case, this search method changes.(not yet implemented)
+   * the followings are the meanings of each special return value.
+   * - null:      a specified config is not set.
+   * - undefined: a specified config does not exist.
    */
    */
   getConfig(namespace, key) {
   getConfig(namespace, key) {
+    if (this.searchOnlyFromEnvVarConfigs('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions')) {
+      return this.searchInSAMLUseOnlyEnvMode(namespace, key);
+    }
+
     return this.defaultSearch(namespace, key);
     return this.defaultSearch(namespace, key);
   }
   }
 
 
   /**
   /**
-   * private api
+   * get a config specified by namespace & key from configs loaded from the database
    *
    *
-   * Search a specified config from configs loaded from database at first
-   * and then from configs loaded from env vars.
+   * **Do not use this unless absolutely necessary. Use getConfig instead.**
+   */
+  getConfigFromDB(namespace, key) {
+    return this.searchOnlyFromDBConfigs(namespace, key);
+  }
+
+  /**
+   * get a config specified by namespace & key from configs loaded from the environment variables
    *
    *
-   * the followings are the meanings of each special return value.
-   * - null:      a specified config is not set.
-   * - undefined: a specified config does not exist.
+   * **Do not use this unless absolutely necessary. Use getConfig instead.**
+   */
+  getConfigFromEnvVars(namespace, key) {
+    return this.searchOnlyFromEnvVarConfigs(namespace, key);
+  }
+
+  /**
+   * get the site url
+   *
+   * If the config for the site url is not set, this returns a message "[The site URL is not set. Please set it!]".
+   *
+   * With version 3.2.3 and below, there is no config for the site URL, so the system always uses auto-generated site URL.
+   * With version 3.2.4 to 3.3.4, the system uses the auto-generated site URL only if the config is not set.
+   * With version 3.3.5 and above, the system use only a value from the config.
+   */
+  getSiteUrl() {
+    const siteUrl = this.getConfig('crowi', 'app:siteUrl');
+    if (siteUrl != null) {
+      return siteUrl;
+    }
+    else {
+      return '[The site URL is not set. Please set it!]';
+    }
+  }
+
+  /**
+   * update configs in the same namespace
+   *
+   * Specified values are encoded by convertInsertValue.
+   * In it, an empty string is converted to null that indicates a config is not set.
+   *
+   * For example:
+   * ```
+   *  updateConfigsInTheSameNamespace(
+   *   'some namespace',
+   *   {
+   *    'some key 1': 'value 1',
+   *    'some key 2': 'value 2',
+   *    ...
+   *   }
+   *  );
+   * ```
+   */
+  async updateConfigsInTheSameNamespace(namespace, configs) {
+    let queries = [];
+    for (const key of Object.keys(configs)) {
+      queries.push({
+        updateOne: {
+          filter: { ns: namespace, key: key },
+          update: { ns: namespace, key: key, value: this.convertInsertValue(configs[key]) },
+          upsert: true
+        }
+      });
+    }
+    await this.configModel.bulkWrite(queries);
+
+    await this.loadConfigs();
+  }
+
+  /*
+   * All of the methods below are private APIs.
+   */
+
+  /**
+   * search a specified config from configs loaded from the database at first
+   * and then from configs loaded from the environment variables
    */
    */
   defaultSearch(namespace, key) {
   defaultSearch(namespace, key) {
     if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
     if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
@@ -63,9 +152,44 @@ class ConfigManager {
     }
     }
   }
   }
 
 
+  /**
+   * For the configs specified by KEYS_FOR_SAML_USE_ONLY_ENV_OPTION,
+   * this searches only from configs loaded from the environment variables.
+   * For the other configs, this searches as the same way to defaultSearch.
+   */
+  searchInSAMLUseOnlyEnvMode(namespace, key) {
+    if (namespace === 'crowi' && KEYS_FOR_SAML_USE_ONLY_ENV_OPTION.includes(key)) {
+      return this.searchOnlyFromEnvVarConfigs(namespace, key);
+    }
+    else {
+      return this.defaultSearch(namespace, key);
+    }
+  }
+
+  /**
+   * search a specified config from configs loaded from the database
+   */
+  searchOnlyFromDBConfigs(namespace, key) {
+    if (!this.configExistsInDB(namespace, key)) {
+      return undefined;
+    }
+
+    return this.configObject.fromDB[namespace][key];
+  }
+
+  /**
+   * search a specified config from configs loaded from the environment variables
+   */
+  searchOnlyFromEnvVarConfigs(namespace, key) {
+    if (!this.configExistsInEnvVars(namespace, key)) {
+      return undefined;
+    }
+
+    return this.configObject.fromEnvVars[namespace][key];
+  }
+
   /**
   /**
    * check whether a specified config exists in configs loaded from the database
    * check whether a specified config exists in configs loaded from the database
-   * @returns {boolean}
    */
    */
   configExistsInDB(namespace, key) {
   configExistsInDB(namespace, key) {
     if (this.configObject.fromDB[namespace] === undefined) {
     if (this.configObject.fromDB[namespace] === undefined) {
@@ -77,7 +201,6 @@ class ConfigManager {
 
 
   /**
   /**
    * check whether a specified config exists in configs loaded from the environment variables
    * check whether a specified config exists in configs loaded from the environment variables
-   * @returns {boolean}
    */
    */
   configExistsInEnvVars(namespace, key) {
   configExistsInEnvVars(namespace, key) {
     if (this.configObject.fromEnvVars[namespace] === undefined) {
     if (this.configObject.fromEnvVars[namespace] === undefined) {
@@ -87,38 +210,8 @@ class ConfigManager {
     return this.configObject.fromEnvVars[namespace][key] !== undefined;
     return this.configObject.fromEnvVars[namespace][key] !== undefined;
   }
   }
 
 
-  /**
-   * update configs by a iterable object consisting of several objects with ns, key, value fields
-   *
-   * For example:
-   * ```
-   *  updateConfigs(
-   *   [{
-   *     ns:    'some namespace 1',
-   *     key:   'some key 1',
-   *     value: 'some value 1'
-   *   }, {
-   *     ns:    'some namespace 2',
-   *     key:   'some key 2',
-   *     value: 'some value 2'
-   *   }]
-   *  );
-   * ```
-   */
-  async updateConfigs(configs) {
-    const results = [];
-    for (const config of configs) {
-      results.push(
-        this.configModel.findOneAndUpdate(
-          { ns: config.ns, key: config.key },
-          { ns: config.ns, key: config.key, value: JSON.stringify(config.value) },
-          { upsert: true, }
-        ).exec()
-      );
-    }
-    await Promise.all(results);
-
-    await this.loadConfigs();
+  convertInsertValue(value) {
+    return JSON.stringify(value === '' ? null : value);
   }
   }
 }
 }
 
 

+ 1 - 1
src/server/service/file-uploader/gridfs.js

@@ -97,7 +97,7 @@ module.exports = function(crowi) {
 
 
   lib.getFileData = async function(filePath) {
   lib.getFileData = async function(filePath) {
     const file = await getFile(filePath);
     const file = await getFile(filePath);
-    const id = file.id;
+    const id = file._id;
     const contentType = file.contentType;
     const contentType = file.contentType;
     const data = await readFileData(id);
     const data = await readFileData(id);
     return {
     return {

+ 49 - 14
src/server/service/passport.js

@@ -53,6 +53,19 @@ class PassportService {
      * the flag whether serializer/deserializer are set up successfully
      * the flag whether serializer/deserializer are set up successfully
      */
      */
     this.isSerializerSetup = false;
     this.isSerializerSetup = false;
+
+    /**
+     * the keys of mandatory configs for SAML
+     */
+    this.mandatoryConfigKeysForSaml = [
+      'security:passport-saml:isEnabled',
+      'security:passport-saml:entryPoint',
+      'security:passport-saml:issuer',
+      'security:passport-saml:cert',
+      'security:passport-saml:attrMapId',
+      'security:passport-saml:attrMapUsername',
+      'security:passport-saml:attrMapMail'
+    ];
   }
   }
 
 
   /**
   /**
@@ -298,8 +311,8 @@ class PassportService {
     passport.use(new GoogleStrategy({
     passport.use(new GoogleStrategy({
       clientId: config.crowi['security:passport-google:clientId'] || process.env.OAUTH_GOOGLE_CLIENT_ID,
       clientId: config.crowi['security:passport-google:clientId'] || process.env.OAUTH_GOOGLE_CLIENT_ID,
       clientSecret: config.crowi['security:passport-google:clientSecret'] || process.env.OAUTH_GOOGLE_CLIENT_SECRET,
       clientSecret: config.crowi['security:passport-google:clientSecret'] || process.env.OAUTH_GOOGLE_CLIENT_SECRET,
-      callbackURL: (config.crowi['app:siteUrl'] != null)
-        ? `${config.crowi['app:siteUrl']}/passport/google/callback`                                         // auto-generated with v3.2.4 and above
+      callbackURL: (this.crowi.configManager.getConfig('crowi', 'app:siteUrl') != null)
+        ? `${this.crowi.configManager.getSiteUrl()}/passport/google/callback`                               // auto-generated with v3.2.4 and above
         : config.crowi['security:passport-google:callbackUrl'] || process.env.OAUTH_GOOGLE_CALLBACK_URI,    // DEPRECATED: backward compatible with v3.2.3 and below
         : config.crowi['security:passport-google:callbackUrl'] || process.env.OAUTH_GOOGLE_CALLBACK_URI,    // DEPRECATED: backward compatible with v3.2.3 and below
       skipUserProfile: false,
       skipUserProfile: false,
     }, function(accessToken, refreshToken, profile, done) {
     }, function(accessToken, refreshToken, profile, done) {
@@ -345,8 +358,8 @@ class PassportService {
     passport.use(new GitHubStrategy({
     passport.use(new GitHubStrategy({
       clientID: config.crowi['security:passport-github:clientId'] || process.env.OAUTH_GITHUB_CLIENT_ID,
       clientID: config.crowi['security:passport-github:clientId'] || process.env.OAUTH_GITHUB_CLIENT_ID,
       clientSecret: config.crowi['security:passport-github:clientSecret'] || process.env.OAUTH_GITHUB_CLIENT_SECRET,
       clientSecret: config.crowi['security:passport-github:clientSecret'] || process.env.OAUTH_GITHUB_CLIENT_SECRET,
-      callbackURL: (config.crowi['app:siteUrl'] != null)
-        ? `${config.crowi['app:siteUrl']}/passport/github/callback`                                         // auto-generated with v3.2.4 and above
+      callbackURL: (this.crowi.configManager.getConfig('crowi', 'app:siteUrl') != null)
+        ? `${this.crowi.configManager.getSiteUrl()}/passport/github/callback`                               // auto-generated with v3.2.4 and above
         : config.crowi['security:passport-github:callbackUrl'] || process.env.OAUTH_GITHUB_CALLBACK_URI,    // DEPRECATED: backward compatible with v3.2.3 and below
         : config.crowi['security:passport-github:callbackUrl'] || process.env.OAUTH_GITHUB_CALLBACK_URI,    // DEPRECATED: backward compatible with v3.2.3 and below
       skipUserProfile: false,
       skipUserProfile: false,
     }, function(accessToken, refreshToken, profile, done) {
     }, function(accessToken, refreshToken, profile, done) {
@@ -392,8 +405,8 @@ class PassportService {
     passport.use(new TwitterStrategy({
     passport.use(new TwitterStrategy({
       consumerKey: config.crowi['security:passport-twitter:consumerKey'] || process.env.OAUTH_TWITTER_CONSUMER_KEY,
       consumerKey: config.crowi['security:passport-twitter:consumerKey'] || process.env.OAUTH_TWITTER_CONSUMER_KEY,
       consumerSecret: config.crowi['security:passport-twitter:consumerSecret'] || process.env.OAUTH_TWITTER_CONSUMER_SECRET,
       consumerSecret: config.crowi['security:passport-twitter:consumerSecret'] || process.env.OAUTH_TWITTER_CONSUMER_SECRET,
-      callbackURL: (config.crowi['app:siteUrl'] != null)
-        ? `${config.crowi['app:siteUrl']}/passport/twitter/callback`                                         // auto-generated with v3.2.4 and above
+      callbackURL: (this.crowi.configManager.getConfig('crowi', 'app:siteUrl') != null)
+        ? `${this.crowi.configManager.getSiteUrl()}/passport/twitter/callback`                               // auto-generated with v3.2.4 and above
         : config.crowi['security:passport-twitter:callbackUrl'] || process.env.OAUTH_TWITTER_CALLBACK_URI,   // DEPRECATED: backward compatible with v3.2.3 and below
         : config.crowi['security:passport-twitter:callbackUrl'] || process.env.OAUTH_TWITTER_CALLBACK_URI,   // DEPRECATED: backward compatible with v3.2.3 and below
       skipUserProfile: false,
       skipUserProfile: false,
     }, function(accessToken, refreshToken, profile, done) {
     }, function(accessToken, refreshToken, profile, done) {
@@ -427,8 +440,8 @@ class PassportService {
     }
     }
 
 
     const config = this.crowi.config;
     const config = this.crowi.config;
-    const Config = this.crowi.model('Config');
-    const isSamlEnabled = Config.isEnabledPassportSaml(config);
+    const configManager = this.crowi.configManager;
+    const isSamlEnabled = configManager.getConfig('crowi', 'security:passport-saml:isEnabled');
 
 
     // when disabled
     // when disabled
     if (!isSamlEnabled) {
     if (!isSamlEnabled) {
@@ -437,12 +450,12 @@ class PassportService {
 
 
     debug('SamlStrategy: setting up..');
     debug('SamlStrategy: setting up..');
     passport.use(new SamlStrategy({
     passport.use(new SamlStrategy({
-      entryPoint: config.crowi['security:passport-saml:entryPoint'] || process.env.SAML_ENTRY_POINT,
-      callbackUrl: (config.crowi['app:siteUrl'] != null)
-        ? `${config.crowi['app:siteUrl']}/passport/saml/callback`                                 // auto-generated with v3.2.4 and above
-        : config.crowi['security:passport-saml:callbackUrl'] || process.env.SAML_CALLBACK_URI,    // DEPRECATED: backward compatible with v3.2.3 and below
-      issuer: config.crowi['security:passport-saml:issuer'] || process.env.SAML_ISSUER,
-      cert: config.crowi['security:passport-saml:cert'] || process.env.SAML_CERT,
+      entryPoint: configManager.getConfig('crowi', 'security:passport-saml:entryPoint'),
+      callbackUrl: (this.crowi.configManager.getConfig('crowi', 'app:siteUrl') != null)
+        ? `${this.crowi.configManager.getSiteUrl()}/passport/saml/callback`          // auto-generated with v3.2.4 and above
+        : configManager.getConfig('crowi', 'security:passport-saml:callbackUrl'),    // DEPRECATED: backward compatible with v3.2.3 and below
+      issuer: configManager.getConfig('crowi', 'security:passport-saml:issuer'),
+      cert: configManager.getConfig('crowi', 'security:passport-saml:cert'),
     }, function(profile, done) {
     }, function(profile, done) {
       if (profile) {
       if (profile) {
         return done(null, profile);
         return done(null, profile);
@@ -467,6 +480,19 @@ class PassportService {
     this.isSamlStrategySetup = false;
     this.isSamlStrategySetup = false;
   }
   }
 
 
+  /**
+   * return the keys of the configs mandatory for SAML whose value are empty.
+   */
+  getSamlMissingMandatoryConfigKeys() {
+    const missingRequireds = [];
+    for (const key of this.mandatoryConfigKeysForSaml) {
+      if (this.crowi.configManager.getConfig('crowi', key) === null) {
+        missingRequireds.push(key);
+      }
+    }
+    return missingRequireds;
+  }
+
   /**
   /**
    * setup serializer and deserializer
    * setup serializer and deserializer
    *
    *
@@ -494,6 +520,15 @@ class PassportService {
     this.isSerializerSetup = true;
     this.isSerializerSetup = true;
   }
   }
 
 
+  isSameUsernameTreatedAsIdenticalUser(providerType) {
+    const key = `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`;
+    return this.crowi.configManager.getConfig('crowi', key);
+  }
+
+  isSameEmailTreatedAsIdenticalUser(providerType) {
+    const key = `security:passport-${providerType}:isSameEmailTreatedAsIdenticalUser`;
+    return this.crowi.configManager.getConfig('crowi', key);
+  }
 }
 }
 
 
 module.exports = PassportService;
 module.exports = PassportService;

+ 4 - 3
src/server/util/googleAuth.js

@@ -2,12 +2,13 @@
  * googleAuth utility
  * googleAuth utility
  */
  */
 
 
-module.exports = function(config) {
+module.exports = function(crowi) {
   'use strict';
   'use strict';
 
 
   const { GoogleApis } = require('googleapis');
   const { GoogleApis } = require('googleapis');
   var google = new GoogleApis()
   var google = new GoogleApis()
     , debug = require('debug')('growi:lib:googleAuth')
     , debug = require('debug')('growi:lib:googleAuth')
+    , config = crowi.getConfig()
     , lib = {}
     , lib = {}
     ;
     ;
 
 
@@ -20,7 +21,7 @@ module.exports = function(config) {
   }
   }
 
 
   lib.createAuthUrl = function(req, callback) {
   lib.createAuthUrl = function(req, callback) {
-    var callbackUrl = config.crowi['app:siteUrl:fixed'] + '/google/callback';
+    var callbackUrl = crowi.configManager.getSiteUrl() + '/google/callback';
     var oauth2Client = createOauth2Client(callbackUrl);
     var oauth2Client = createOauth2Client(callbackUrl);
     google.options({auth: oauth2Client});
     google.options({auth: oauth2Client});
 
 
@@ -33,7 +34,7 @@ module.exports = function(config) {
   };
   };
 
 
   lib.handleCallback = function(req, callback) {
   lib.handleCallback = function(req, callback) {
-    var callbackUrl = config.crowi['app:siteUrl:fixed'] + '/google/callback';
+    var callbackUrl = crowi.configManager.getSiteUrl() + '/google/callback';
     var oauth2Client = createOauth2Client(callbackUrl);
     var oauth2Client = createOauth2Client(callbackUrl);
     google.options({auth: oauth2Client});
     google.options({auth: oauth2Client});
 
 

+ 2 - 2
src/server/util/middlewares.js

@@ -283,7 +283,7 @@ exports.applicationNotInstalled = function() {
   return function(req, res, next) {
   return function(req, res, next) {
     var config = req.config;
     var config = req.config;
 
 
-    if (Object.keys(config.crowi).length !== 1) {
+    if (Object.keys(config.crowi).length !== 0) {
       req.flash('errorMessage', 'Application already installed.');
       req.flash('errorMessage', 'Application already installed.');
       return res.redirect('admin'); // admin以外はadminRequiredで'/'にリダイレクトされる
       return res.redirect('admin'); // admin以外はadminRequiredで'/'にリダイレクトされる
     }
     }
@@ -323,7 +323,7 @@ exports.applicationInstalled = function() {
   return function(req, res, next) {
   return function(req, res, next) {
     var config = req.config;
     var config = req.config;
 
 
-    if (Object.keys(config.crowi).length === 1) { // app:url is set by process
+    if (Object.keys(config.crowi).length === 0) {
       return res.redirect('/installer');
       return res.redirect('/installer');
     }
     }
 
 

+ 6 - 7
src/server/util/search.js

@@ -541,23 +541,22 @@ SearchClient.prototype.filterPagesByViewer = async function(query, user, userGro
 
 
   const grantConditions = [
   const grantConditions = [
     { term: { grant: GRANT_PUBLIC } },
     { term: { grant: GRANT_PUBLIC } },
+    { bool: {
+      must: [
+        { term: { grant: GRANT_RESTRICTED } },
+        { term: { granted_users: user._id.toString() } }
+      ]
+    } },
   ];
   ];
 
 
   if (showPagesRestrictedByOwner) {
   if (showPagesRestrictedByOwner) {
     grantConditions.push(
     grantConditions.push(
-      { term: { grant: GRANT_RESTRICTED } },
       { term: { grant: GRANT_SPECIFIED } },
       { term: { grant: GRANT_SPECIFIED } },
       { term: { grant: GRANT_OWNER } },
       { term: { grant: GRANT_OWNER } },
     );
     );
   }
   }
   else if (user != null) {
   else if (user != null) {
     grantConditions.push(
     grantConditions.push(
-      { bool: {
-        must: [
-          { term: { grant: GRANT_RESTRICTED } },
-          { term: { granted_users: user._id.toString() } }
-        ]
-      } },
       { bool: {
       { bool: {
         must: [
         must: [
           { term: { grant: GRANT_SPECIFIED } },
           { term: { grant: GRANT_SPECIFIED } },

+ 12 - 15
src/server/util/slack.js

@@ -44,11 +44,8 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-  const convertMarkdownToMrkdwn = function(body) {
-    var url = '';
-    if (config.crowi && config.crowi['app:siteUrl:fixed']) {
-      url = config.crowi['app:siteUrl:fixed'];
-    }
+  const convertMarkdownToMarkdown = function(body) {
+    const url = crowi.configManager.getSiteUrl();
 
 
     body = body
     body = body
       .replace(/\n\*\s(.+)/g, '\n• $1')
       .replace(/\n\*\s(.+)/g, '\n• $1')
@@ -61,22 +58,22 @@ module.exports = function(crowi) {
   };
   };
 
 
   const prepareAttachmentTextForCreate = function(page, user) {
   const prepareAttachmentTextForCreate = function(page, user) {
-    var body = page.revision.body;
+    let body = page.revision.body;
     if (body.length > 2000) {
     if (body.length > 2000) {
       body = body.substr(0, 2000) + '...';
       body = body.substr(0, 2000) + '...';
     }
     }
 
 
-    return convertMarkdownToMrkdwn(body);
+    return convertMarkdownToMarkdown(body);
   };
   };
 
 
   const prepareAttachmentTextForUpdate = function(page, user, previousRevision) {
   const prepareAttachmentTextForUpdate = function(page, user, previousRevision) {
-    var diff = require('diff');
-    var diffText = '';
+    const diff = require('diff');
+    let diffText = '';
 
 
     diff.diffLines(previousRevision.body, page.revision.body).forEach(function(line) {
     diff.diffLines(previousRevision.body, page.revision.body).forEach(function(line) {
       debug('diff line', line);
       debug('diff line', line);
       /* eslint-disable no-unused-vars */
       /* eslint-disable no-unused-vars */
-      var value = line.value.replace(/\r\n|\r/g, '\n');
+      const value = line.value.replace(/\r\n|\r/g, '\n');
       /* eslint-enable */
       /* eslint-enable */
       if (line.added) {
       if (line.added) {
         diffText += `${line.value} ... :lower_left_fountain_pen:`;
         diffText += `${line.value} ... :lower_left_fountain_pen:`;
@@ -105,7 +102,7 @@ module.exports = function(crowi) {
     }
     }
 
 
     if (comment.isMarkdown) {
     if (comment.isMarkdown) {
-      return convertMarkdownToMrkdwn(body);
+      return convertMarkdownToMarkdown(body);
     }
     }
     else {
     else {
       return body;
       return body;
@@ -113,7 +110,7 @@ module.exports = function(crowi) {
   };
   };
 
 
   const prepareSlackMessageForPage = function(page, user, channel, updateType, previousRevision) {
   const prepareSlackMessageForPage = function(page, user, channel, updateType, previousRevision) {
-    const url = config.crowi['app:siteUrl:fixed'] || '';
+    const url = crowi.configManager.getSiteUrl();
     let body = page.revision.body;
     let body = page.revision.body;
 
 
     if (updateType == 'create') {
     if (updateType == 'create') {
@@ -148,7 +145,7 @@ module.exports = function(crowi) {
   };
   };
 
 
   const prepareSlackMessageForComment = function(comment, user, channel, path) {
   const prepareSlackMessageForComment = function(comment, user, channel, path) {
-    const url = config.crowi['app:siteUrl:fixed'] || '';
+    const url = crowi.configManager.getSiteUrl();
     const body = prepareAttachmentTextForComment(comment);
     const body = prepareAttachmentTextForComment(comment);
 
 
     const attachment = {
     const attachment = {
@@ -175,7 +172,7 @@ module.exports = function(crowi) {
 
 
   const getSlackMessageTextForPage = function(path, user, updateType) {
   const getSlackMessageTextForPage = function(path, user, updateType) {
     let text;
     let text;
-    const url = config.crowi['app:siteUrl:fixed'] || '';
+    const url = crowi.configManager.getSiteUrl();
 
 
     const pageUrl = `<${url}${path}|${path}>`;
     const pageUrl = `<${url}${path}|${path}>`;
     if (updateType == 'create') {
     if (updateType == 'create') {
@@ -189,7 +186,7 @@ module.exports = function(crowi) {
   };
   };
 
 
   const getSlackMessageTextForComment = function(path, user) {
   const getSlackMessageTextForComment = function(path, user) {
-    const url = config.crowi['app:siteUrl:fixed'] || '';
+    const url = crowi.configManager.getSiteUrl();
     const pageUrl = `<${url}${path}|${path}>`;
     const pageUrl = `<${url}${path}|${path}>`;
     const text = `:speech_balloon: ${user.username} commented on ${pageUrl}`;
     const text = `:speech_balloon: ${user.username} commented on ${pageUrl}`;
 
 

+ 30 - 0
src/server/util/swigFunctions.js

@@ -42,6 +42,26 @@ module.exports = function(crowi, app, req, locals) {
     return fontSize;
     return fontSize;
   };
   };
 
 
+  /**
+   * @see ConfigManager#getConfig
+   */
+  locals.getConfig = function(namespace, key) {
+    return crowi.configManager.getConfig(namespace, key);
+  };
+
+  /**
+   * **Do not use this unless absolutely necessary. Use getConfig instead.**
+   */
+  locals.getConfigFromDB = function(namespace, key) {
+    return crowi.configManager.getConfigFromDB(namespace, key);
+  };
+  /**
+   * **Do not use this unless absolutely necessary. Use getConfig instead.**
+   */
+  locals.getConfigFromEnvVars = function(namespace, key) {
+    return crowi.configManager.getConfigFromEnvVars(namespace, key);
+  };
+
   /**
   /**
    * return app title
    * return app title
    */
    */
@@ -120,6 +140,16 @@ module.exports = function(crowi, app, req, locals) {
     return locals.isEnabledPassport() && config.crowi['security:passport-saml:isEnabled'];
     return locals.isEnabledPassport() && config.crowi['security:passport-saml:isEnabled'];
   };
   };
 
 
+  locals.getSamlMissingMandatoryConfigKeys = function() {
+    // return an empty array if Passport is not enabled
+    // because crowi.passportService is null.
+    if (!locals.isEnabledPassport()) {
+      return [];
+    }
+
+    return crowi.passportService.getSamlMissingMandatoryConfigKeys();
+  };
+
   locals.googleLoginEnabled = function() {
   locals.googleLoginEnabled = function() {
     // return false if Passport is enabled
     // return false if Passport is enabled
     // because official crowi mechanism is not used.
     // because official crowi mechanism is not used.

+ 169 - 41
src/server/views/admin/app.html

@@ -2,6 +2,9 @@
 
 
 {% block html_title %}{{ customTitle(t('App settings')) }}{% endblock %}
 {% block html_title %}{{ customTitle(t('App settings')) }}{% endblock %}
 
 
+{% block head_warn %} {# remove including block for './widget/alert_siteurl_undefined.html' #}
+{% endblock %}
+
 {% block content_header %}
 {% block content_header %}
 <div class="header-wrap">
 <div class="header-wrap">
   <header id="page-header">
   <header id="page-header">
@@ -38,23 +41,25 @@
         <div class="form-group">
         <div class="form-group">
           <label for="settingForm[app:title]" class="col-xs-3 control-label">{{ t('app_setting.Site Name') }}</label>
           <label for="settingForm[app:title]" class="col-xs-3 control-label">{{ t('app_setting.Site Name') }}</label>
           <div class="col-xs-6">
           <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[app:title]" value="{{ settingForm['app:title'] | default('') }}" placeholder="GROWI">
+            <input class="form-control"
+                   id="settingForm[app:title]"
+                   type="text"
+                   name="settingForm[app:title]"
+                   value="{{ settingForm['app:title'] | default('') }}"
+                   placeholder="GROWI">
             <p class="help-block">{{ t("app_setting.sitename_change") }}</p>
             <p class="help-block">{{ t("app_setting.sitename_change") }}</p>
           </div>
           </div>
         </div>
         </div>
 
 
-        <div class="form-group">
-          <label for="settingForm[app:siteUrl]" class="col-xs-3 control-label">{{ t('app_setting.Site URL') }}</label>
-          <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[app:siteUrl]" value="{{ settingForm['app:siteUrl'] | default('') }}" placeholder="e.g. https://my.growi.org">
-            <p class="help-block">{{ t("app_setting.siteurl_help") }}</p>
-          </div>
-        </div>
-
         <div class="form-group">
         <div class="form-group">
           <label for="settingForm[app:confidential]" class="col-xs-3 control-label">{{ t('app_setting.Confidential name') }}</label>
           <label for="settingForm[app:confidential]" class="col-xs-3 control-label">{{ t('app_setting.Confidential name') }}</label>
           <div class="col-xs-6">
           <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[app:confidential]" value="{{ settingForm['app:confidential'] }}" placeholder="{{ t('app_setting. ex): internal use only') }}">
+            <input class="form-control"
+                   id="settingForm[app:confidential]"
+                   type="text"
+                   name="settingForm[app:confidential]"
+                   value="{{ settingForm['app:confidential'] }}"
+                   placeholder="{{ t('app_setting. ex): internal use only') }}">
             <p class="help-block">{{ t("app_setting.header_content") }}</p>
             <p class="help-block">{{ t("app_setting.header_content") }}</p>
           </div>
           </div>
         </div>
         </div>
@@ -63,11 +68,19 @@
           <label class="col-xs-3 control-label">{{ t('app_setting.Default Language for new users') }}</label>
           <label class="col-xs-3 control-label">{{ t('app_setting.Default Language for new users') }}</label>
           <div class="col-xs-6">
           <div class="col-xs-6">
             <div class="radio radio-primary radio-inline">
             <div class="radio radio-primary radio-inline">
-                <input type="radio" id="radioLangEn" name="settingForm[app:globalLang]" value="{{ consts.language.LANG_EN_US }}" {% if appGlobalLang() == consts.language.LANG_EN_US %}checked="checked"{% endif %}>
+                <input type="radio"
+                       id="radioLangEn"
+                       name="settingForm[app:globalLang]"
+                       value="{{ consts.language.LANG_EN_US }}"
+                       {% if appGlobalLang() == consts.language.LANG_EN_US %}checked="checked"{% endif %}>
                 <label for="radioLangEn">{{ t('English') }}</label>
                 <label for="radioLangEn">{{ t('English') }}</label>
             </div>
             </div>
             <div class="radio radio-primary radio-inline">
             <div class="radio radio-primary radio-inline">
-                <input type="radio" id="radioLangJa" name="settingForm[app:globalLang]" value="{{ consts.language.LANG_JA }}" {% if appGlobalLang() == consts.language.LANG_JA %}checked="checked"{% endif %}>
+                <input type="radio"
+                       id="radioLangJa"
+                       name="settingForm[app:globalLang]"
+                       value="{{ consts.language.LANG_JA }}"
+                       {% if appGlobalLang() == consts.language.LANG_JA %}checked="checked"{% endif %}>
                 <label for="radioLangJa">{{ t('Japanese') }}</label>
                 <label for="radioLangJa">{{ t('Japanese') }}</label>
             </div>
             </div>
           </div>
           </div>
@@ -77,15 +90,12 @@
           <label class="col-xs-3 control-label">{{ t('app_setting.File Uploading') }}</label>
           <label class="col-xs-3 control-label">{{ t('app_setting.File Uploading') }}</label>
           <div class="col-xs-6">
           <div class="col-xs-6">
             <div class="checkbox checkbox-info">
             <div class="checkbox checkbox-info">
-              <input type="checkbox" id="cbFileUpload" name="settingForm[app:fileUpload]" value="1"
-                {% if settingForm['app:fileUpload'] %}
-                checked
-                {% endif %}
-                {% if not isUploadable() %}
-                disabled="disabled"
-                {% else %}
-                {% endif %}
-                >
+              <input type="checkbox"
+                     id="cbFileUpload"
+                     name="settingForm[app:fileUpload]"
+                     value="1"
+                     {% if settingForm['app:fileUpload'] %}checked{% endif %}
+                     {% if not isUploadable() %}disabled="disabled"{% endif %}>
               <label for="cbFileUpload">
               <label for="cbFileUpload">
                 {{ t("app_setting.enable_files_except_image") }}
                 {{ t("app_setting.enable_files_except_image") }}
               </label>
               </label>
@@ -107,6 +117,57 @@
       </fieldset>
       </fieldset>
       </form>
       </form>
 
 
+      <form action="/_api/admin/settings/siteUrl" method="post" class="form-horizontal" id="siteUrlSettingForm" role="form">
+        <fieldset>
+          <legend>{{ t('Site URL settings') }}</legend>
+          <p class="well">{{ t('app_setting.Site URL desc') }}</p>
+          {% if !getConfig('crowi', 'app:siteUrl') %}
+            <p class="alert alert-danger"><i class="icon-exclamation"></i> {{ t('app_setting.Site URL warn') }}</p>
+          {% endif %}
+
+          <div class="col-xs-offset-3">
+            <table class="table settings-table">
+              <colgroup>
+                <col class="from-db">
+                <col class="from-env-vars">
+              </colgroup>
+              <thead>
+              <tr><th>Database</th><th>Environment variables</th></tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <td>
+                    <input class="form-control"
+                           type="text"
+                           name="settingForm[app:siteUrl]"
+                           value="{{ getConfigFromDB('crowi', 'app:siteUrl') | default('') }}"
+                           placeholder="e.g. https://my.growi.org">
+                    <p class="help-block">{{ t("app_setting.siteurl_help") }}</p>
+                  </td>
+                  <td>
+                    <input class="form-control"
+                           type="text"
+                           value="{{ getConfigFromEnvVars('crowi', 'app:siteUrl') | default('') }}"
+                           readonly>
+                    <p class="help-block">
+                      {{ t("app_setting.Use env var if empty", "APP_SITE_URL") }}
+                    </p>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+
+          <div class="form-group">
+            <div class="col-xs-offset-3 col-xs-6">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="btn btn-primary">{{ t('app_setting.Update') }}</button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+
+
       <form action="/_api/admin/settings/mail" method="post" class="form-horizontal" id="mailSettingForm" role="form">
       <form action="/_api/admin/settings/mail" method="post" class="form-horizontal" id="mailSettingForm" role="form">
       <fieldset>
       <fieldset>
       <legend>{{ t('app_setting.Mail settings') }}</legend>
       <legend>{{ t('app_setting.Mail settings') }}</legend>
@@ -115,30 +176,47 @@
         <div class="form-group">
         <div class="form-group">
           <label for="settingForm[mail.from]" class="col-xs-3 control-label">{{ t('app_setting.From e-mail address') }}</label>
           <label for="settingForm[mail.from]" class="col-xs-3 control-label">{{ t('app_setting.From e-mail address') }}</label>
           <div class="col-xs-6">
           <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[mail:from]" placeholder="例: mail@growi.org" value="{{ settingForm['mail:from'] }}">
+            <input class="form-control"
+                   id="settingForm[mail.from]"
+                   type="text"
+                   name="settingForm[mail:from]"
+                   placeholder="{{ t('eg') }} mail@growi.org"
+                   value="{{ settingForm['mail:from'] }}">
           </div>
           </div>
         </div>
         </div>
 
 
         <div class="form-group">
         <div class="form-group">
           <label class="col-xs-3 control-label">{{ t('app_setting.SMTP settings') }}</label>
           <label class="col-xs-3 control-label">{{ t('app_setting.SMTP settings') }}</label>
           <div class="col-xs-4">
           <div class="col-xs-4">
-            <label for="">{{ t('app_setting.Host') }}</label>
-            <input class="form-control" type="text" name="settingForm[mail:smtpHost]"   value="{{ settingForm['mail:smtpHost']|default('') }}">
+            <label>{{ t('app_setting.Host') }}</label>
+            <input class="form-control"
+                   type="text"
+                   name="settingForm[mail:smtpHost]"
+                   value="{{ settingForm['mail:smtpHost']|default('') }}">
           </div>
           </div>
           <div class="col-xs-2">
           <div class="col-xs-2">
-            <label for="">{{ t('app_setting.Port') }}</label>
-            <input class="form-control" type="text" name="settingForm[mail:smtpPort]" value="{{ settingForm['mail:smtpPort']|default('') }}">
+            <label>{{ t('app_setting.Port') }}</label>
+            <input class="form-control"
+                   type="text"
+                   name="settingForm[mail:smtpPort]"
+                   value="{{ settingForm['mail:smtpPort']|default('') }}">
           </div>
           </div>
         </div>
         </div>
 
 
         <div class="form-group">
         <div class="form-group">
           <div class="col-xs-3 col-xs-offset-3">
           <div class="col-xs-3 col-xs-offset-3">
-            <label for="">{{ t('app_setting.User') }}</label>
-            <input class="form-control" type="text" name="settingForm[mail:smtpUser]"   value="{{ settingForm['mail:smtpUser']|default('') }}">
+            <label>{{ t('app_setting.User') }}</label>
+            <input class="form-control"
+                   type="text"
+                   name="settingForm[mail:smtpUser]"
+                   value="{{ settingForm['mail:smtpUser']|default('') }}">
           </div>
           </div>
           <div class="col-xs-3">
           <div class="col-xs-3">
-            <label for="">{{ t('Password') }}</label>
-            <input class="form-control" type="password" name="settingForm[mail:smtpPassword]" value="{{ settingForm['mail:smtpPassword']|default('') }}">
+            <label>{{ t('Password') }}</label>
+            <input class="form-control"
+                   type="password"
+                   name="settingForm[mail:smtpPassword]"
+                   value="{{ settingForm['mail:smtpPassword']|default('') }}">
           </div>
           </div>
         </div>
         </div>
 
 
@@ -163,23 +241,37 @@
         </p>
         </p>
 
 
         <div class="form-group">
         <div class="form-group">
-          <label for="settingForm[app.region]" class="col-xs-3 control-label">{{ t('app_setting.region') }}</label>
+          <label for="settingForm[app:region]" class="col-xs-3 control-label">{{ t('app_setting.region') }}</label>
           <div class="col-xs-6">
           <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[aws:region]" placeholder="例: ap-northeast-1" value="{{ settingForm['aws:region'] }}">
+            <input class="form-control"
+                   id="settingForm[app:region]"
+                   type="text"
+                   name="settingForm[aws:region]"
+                   placeholder="例: ap-northeast-1"
+                   value="{{ settingForm['aws:region'] }}">
           </div>
           </div>
         </div>
         </div>
 
 
         <div class="form-group">
         <div class="form-group">
           <label for="settingForm[aws:bucket]" class="col-xs-3 control-label">{{ t('app_setting.bucket name') }}</label>
           <label for="settingForm[aws:bucket]" class="col-xs-3 control-label">{{ t('app_setting.bucket name') }}</label>
           <div class="col-xs-6">
           <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[aws:bucket]" placeholder="例: crowi"  value="{{ settingForm['aws:bucket'] }}">
+            <input class="form-control"
+                   id="settingForm[aws:bucket]"
+                   type="text"
+                   name="settingForm[aws:bucket]"
+                   placeholder="例: crowi"
+                   value="{{ settingForm['aws:bucket'] }}">
           </div>
           </div>
         </div>
         </div>
 
 
         <div class="form-group">
         <div class="form-group">
           <label for="settingForm[aws:accessKeyId]" class="col-xs-3 control-label">Access Key ID</label>
           <label for="settingForm[aws:accessKeyId]" class="col-xs-3 control-label">Access Key ID</label>
           <div class="col-xs-6">
           <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[aws:accessKeyId]" value="{{ settingForm['aws:accessKeyId'] }}">
+            <input class="form-control"
+                   id="settingForm[aws:accessKeyId]"
+                   type="text"
+                   name="settingForm[aws:accessKeyId]"
+                   value="{{ settingForm['aws:accessKeyId'] }}">
           </div>
           </div>
 
 
         </div>
         </div>
@@ -187,7 +279,11 @@
         <div class="form-group">
         <div class="form-group">
           <label for="settingForm[aws:secretAccessKey]" class="col-xs-3 control-label">Secret Access Key</label>
           <label for="settingForm[aws:secretAccessKey]" class="col-xs-3 control-label">Secret Access Key</label>
           <div class="col-xs-6">
           <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[aws:secretAccessKey]" value="{{ settingForm['aws:secretAccessKey'] }}">
+            <input class="form-control"
+                   id="settingForm[aws:secretAccessKey]"
+                   type="text"
+                   name="settingForm[aws:secretAccessKey]"
+                   value="{{ settingForm['aws:secretAccessKey'] }}">
           </div>
           </div>
         </div>
         </div>
 
 
@@ -207,17 +303,23 @@
         <p class="well">{{ t('app_setting.Enable plugin loading') }}</p>
         <p class="well">{{ t('app_setting.Enable plugin loading') }}</p>
 
 
         <div class="form-group">
         <div class="form-group">
-          <label for="settingForm[plugin:isEnabledPlugins]" class="col-xs-3 control-label">{{ t('app_setting.Load plugins') }}</label>
+          <label class="col-xs-3 control-label">{{ t('app_setting.Load plugins') }}</label>
           <div class="col-xs-6">
           <div class="col-xs-6">
 
 
             <div class="btn-group btn-toggle" data-toggle="buttons">
             <div class="btn-group btn-toggle" data-toggle="buttons">
               <label class="btn btn-default btn-rounded btn-outline {% if settingForm['plugin:isEnabledPlugins'] %}active{% endif %}" data-active-class="primary">
               <label class="btn btn-default btn-rounded btn-outline {% if settingForm['plugin:isEnabledPlugins'] %}active{% endif %}" data-active-class="primary">
-                <input name="settingForm[plugin:isEnabledPlugins]" value="true" type="radio"
-                    {% if true === settingForm['plugin:isEnabledPlugins'] %}checked{% endif %}> ON
+                <input name="settingForm[plugin:isEnabledPlugins]"
+                       value="true"
+                       type="radio"
+                       {% if true === settingForm['plugin:isEnabledPlugins'] %}checked{% endif %}>
+                ON
               </label>
               </label>
               <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['plugin:isEnabledPlugins'] %}active{% endif %}" data-active-class="default">
               <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['plugin:isEnabledPlugins'] %}active{% endif %}" data-active-class="default">
-                <input name="settingForm[plugin:isEnabledPlugins]" value="false" type="radio"
-                    {% if !settingForm['plugin:isEnabledPlugins'] %}checked{% endif %}> OFF
+                <input name="settingForm[plugin:isEnabledPlugins]"
+                       value="false"
+                       type="radio"
+                       {% if !settingForm['plugin:isEnabledPlugins'] %}checked{% endif %}>
+                OFF
               </label>
               </label>
             </div>
             </div>
           </div>
           </div>
@@ -237,7 +339,7 @@
   </div>
   </div>
 
 
   <script>
   <script>
-    $('#appSettingForm, #mailSettingForm, #awsSettingForm, #pluginSettingForm').each(function() {
+    $('#appSettingForm, #siteUrlSettingForm, #mailSettingForm, #awsSettingForm, #pluginSettingForm').each(function() {
       $(this).submit(function()
       $(this).submit(function()
       {
       {
         function showMessage(formId, msg, status) {
         function showMessage(formId, msg, status) {
@@ -285,6 +387,32 @@
       });
       });
     });
     });
 
 
+    /**
+     * The following script sets the class name 'unused' to the cell in from-env-vars column
+     * when the value of the corresponding cell from the database is not empty.
+     * It is used to indicate that the system does not use a value from the environment variables by setting a css style.
+     *
+     * TODO The following script is duplicated from saml.html. It is desirable to integrate those in the future.
+     */
+    $('.settings-table tbody tr').each(function(_, element) {
+      const inputElemFromDB      = $('td:nth-of-type(1) input[type="text"], td:nth-of-type(1) textarea', element);
+      const inputElemFromEnvVars = $('td:nth-of-type(2) input[type="text"], td:nth-of-type(2) textarea', element);
+
+      // initialize
+      addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars);
+
+      // set keyup event handler
+      inputElemFromDB.keyup(function () { addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars) });
+    });
+
+    function addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars) {
+      if (inputElemFromDB.val() === '') {
+        inputElemFromEnvVars.parent().removeClass('unused');
+      }
+      else {
+        inputElemFromEnvVars.parent().addClass('unused');
+      }
+    };
   </script>
   </script>
 
 
 </div>
 </div>

+ 7 - 9
src/server/views/admin/customize.html

@@ -377,11 +377,10 @@ export  $initHighlight;</code></pre>
 
 
       <form action="/_api/admin/customize/title" method="post" class="form-horizontal" id="customtitleSettingForm" role="form">
       <form action="/_api/admin/customize/title" method="post" class="form-horizontal" id="customtitleSettingForm" role="form">
         <fieldset>
         <fieldset>
-          <legend>カスタム Title</legend>
+          <legend>{{ t('customize_page.custom_title') }}</legend>
 
 
           <p class="well">
           <p class="well">
-            <code>&lt;title&gt;</code>タグのコンテンツをカスタマイズできます。<br>
-            <code>&#123;&#123;sitename&#125;&#125;</code>がサイト名、<code>&#123;&#123;page&#125;&#125;</code>がページ名またはページパスに置換されます
+            {{ t('customize_page.custom_title_detail', '&lt;title&gt;', '&#123;&#123;sitename&#125;&#125;', '&#123;&#123;page&#125;&#125;') }}
           </p>
           </p>
 
 
           <p class="help-block">
           <p class="help-block">
@@ -408,16 +407,15 @@ export  $initHighlight;</code></pre>
 
 
       <form action="/_api/admin/customize/header" method="post" class="form-horizontal" id="customheaderSettingForm" role="form">
       <form action="/_api/admin/customize/header" method="post" class="form-horizontal" id="customheaderSettingForm" role="form">
       <fieldset>
       <fieldset>
-        <legend>カスタムヘッダーHTML</legend>
+        <legend>{{ t('customize_page.custom_header') }}</legend>
 
 
         <p class="well">
         <p class="well">
-          システム全体に適用される HTML を記述できます。<code>&lt;header&gt;</code> タグ内の他の <code>&lt;script&gt;</code> タグ読み込み前に展開されます。<br>
-          変更の反映はページの更新が必要です。
+          {{ t('customize_page.custom_header_detail', '&lt;header&gt;', '&lt;script&gt;') }}
         </p>
         </p>
 
 
         <p class="help-block">
         <p class="help-block">
-          Examples:
-          <pre class="hljs"><code>&lt;script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js" defer&gt;&lt;/script&gt;</code></pre>
+          {{ t('Example') }}:
+          <pre class="hljs"><code>&lt;script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js" defer&gt;&lt;/script&gt;</code></pre>
         </p>
         </p>
 
 
         <div class="form-group">
         <div class="form-group">
@@ -428,7 +426,7 @@ export  $initHighlight;</code></pre>
           <div class="col-xs-12">
           <div class="col-xs-12">
             <p class="help-block text-right">
             <p class="help-block text-right">
               <i class="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
               <i class="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
-              Ctrl+Space でコード補完
+              {{ t("customize_page.ctrl_space") }}
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>

+ 10 - 9
src/server/views/admin/external-accounts.html

@@ -42,25 +42,26 @@
       <p>
       <p>
         <a class="btn btn-default" href="/admin/users">
         <a class="btn btn-default" href="/admin/users">
           <i class="icon-fw ti-arrow-left" aria-hidden="true"></i>
           <i class="icon-fw ti-arrow-left" aria-hidden="true"></i>
-          ユーザー管理に戻る
+          {{ t('user_management.back_to_user_management') }}
         </a>
         </a>
       </p>
       </p>
 
 
-      <h2>外部アカウント一覧</h2>
+      <h2>{{ t('user_management.external_account_list') }}</h2>
 
 
       <table class="table table-bordered table-user-list">
       <table class="table table-bordered table-user-list">
         <thead>
         <thead>
           <tr>
           <tr>
-            <th width="120px">Authentication Provider</th>
+            <th width="120px">{{ t('user_management.authentication_provider') }}</th>
             <th><code>accountId</code></th>
             <th><code>accountId</code></th>
-            <th>関連付けられているユーザーの <code>username</code></th>
+            <th>{{ t('user_management.related_username', 'username') }}</th>
+
             <th>
             <th>
-              パスワード設定
+              {{ t('user_management.password_setting') }}
               <a class="text-muted"
               <a class="text-muted"
                   data-toggle="popover" data-placement="top"
                   data-toggle="popover" data-placement="top"
                   data-trigger="hover focus" tabindex="0" role="button" {# dismiss settings #}
                   data-trigger="hover focus" tabindex="0" role="button" {# dismiss settings #}
                   data-animation="false" data-html="true"
                   data-animation="false" data-html="true"
-                  data-content="<small>関連付けられているユーザーがパスワードを設定しているかどうかを表示します</small>">
+                  data-content="<small>{{ t('user_management.password_setting_help') }}</small>">
                 <small>
                 <small>
                   <i class="icon-question" aria-hidden="true"></i>
                   <i class="icon-question" aria-hidden="true"></i>
                 </small>
                 </small>
@@ -83,11 +84,11 @@
             <td>
             <td>
               {% if account.user.password != null %}
               {% if account.user.password != null %}
               <span class="label label-info">
               <span class="label label-info">
-                設定済み
+                {{ t('user_management.set') }}
               </span>
               </span>
               {% else %}
               {% else %}
               <span class="label label-warning">
               <span class="label label-warning">
-                未設定
+                {{ t('user_management.unset') }}
               </span>
               </span>
               {% endif %}
               {% endif %}
             </td>
             </td>
@@ -106,7 +107,7 @@
                   <li>
                   <li>
                     <a href="javascript:form_remove_{{ loop.index }}.submit()">
                     <a href="javascript:form_remove_{{ loop.index }}.submit()">
                       <i class="icon-fw icon-fire text-danger"></i>
                       <i class="icon-fw icon-fire text-danger"></i>
-                      削除する
+                      {{ t('Delete') }}
                     </a>
                     </a>
                   </li>
                   </li>
                 </ul>{# end of .dropdown-menu #}
                 </ul>{# end of .dropdown-menu #}

+ 4 - 2
src/server/views/admin/user-group-detail.html

@@ -234,8 +234,10 @@
 
 
       <legend class="m-t-20">ページ一覧</legend>
       <legend class="m-t-20">ページ一覧</legend>
 
 
-      {% if pageGroupRelations.length == 0 %}<p>グループが閲覧権限を保有するページはありません</p>{% endif %}
-      {% include '../widget/page_list.html' with { pages: pageGroupRelations, pagePropertyName: 'targetPage' } %}
+      <div class="page-list">
+        {% if relatedPages.length == 0 %}<p>グループが閲覧権限を保有するページはありません</p>{% endif %}
+        {% include '../widget/page_list.html' with { pages: relatedPages } %}
+      </div>
 
 
     </div>
     </div>
   </div>
   </div>

+ 12 - 12
src/server/views/admin/users.html

@@ -44,14 +44,14 @@
       <form role="form" action="/admin/user/invite" method="post">
       <form role="form" action="/admin/user/invite" method="post">
         <div id="inviteUserForm" class="collapse">
         <div id="inviteUserForm" class="collapse">
           <div class="form-group">
           <div class="form-group">
-            <label for="inviteForm[emailList]">メールアドレス (複数行入力で複数人招待可能)</label>
-            <textarea class="form-control" name="inviteForm[emailList]" placeholder="例: user@growi.org"></textarea>
+            <label for="inviteForm[emailList]">{{ t('user_management.emails') }}</label>
+            <textarea class="form-control" name="inviteForm[emailList]" placeholder="{{ t('eg') }} user@growi.org"></textarea>
           </div>
           </div>
           <div class="checkbox checkbox-info">
           <div class="checkbox checkbox-info">
             <input type="checkbox" id="inviteWithEmail" name="inviteForm[sendEmail]" checked>
             <input type="checkbox" id="inviteWithEmail" name="inviteForm[sendEmail]" checked>
-            <label for="inviteWithEmail">招待をメールで送信</label>
+            <label for="inviteWithEmail">{{ t('user_management.invite_thru_email') }}</label>
           </div>
           </div>
-          <button type="submit" class="btn btn-primary">招待する</button>
+          <button type="submit" class="btn btn-primary">{{ t('user_management.invite') }}</button>
         </div>
         </div>
         <input type="hidden" name="_csrf" value="{{ csrf() }}">
         <input type="hidden" name="_csrf" value="{{ csrf() }}">
       </form>
       </form>
@@ -150,7 +150,7 @@
         <thead>
         <thead>
           <tr>
           <tr>
             <th width="100px">#</th>
             <th width="100px">#</th>
-            <th>Status</th>
+            <th>{{ t('user_management.Status') }}</th>
             <th><code>username</code></th>
             <th><code>username</code></th>
             <th>{{ t('Name') }}</th>
             <th>{{ t('Name') }}</th>
             <th>{{ t('Email') }}</th>
             <th>{{ t('Email') }}</th>
@@ -213,7 +213,7 @@
                   </form>
                   </form>
                   <li>
                   <li>
                     <a href="javascript:form_activate_{{ sUserId }}.submit()">
                     <a href="javascript:form_activate_{{ sUserId }}.submit()">
-                      <i class="icon-fw icon-user-following"></i> 承認する
+                      <i class="icon-fw icon-user-following"></i> {{ t('user_management.accept') }}
                     </a>
                     </a>
                   </li>
                   </li>
                   {% endif  %}
                   {% endif  %}
@@ -247,13 +247,13 @@
                   </form>
                   </form>
                   <li>
                   <li>
                     <a href="javascript:form_activate_{{ sUserId }}.submit()">
                     <a href="javascript:form_activate_{{ sUserId }}.submit()">
-                      <i class="icon-fw icon-action-redo"></i> 元に戻す
+                      <i class="icon-fw icon-action-redo"></i> {{ t('Undo') }}
                     </a>
                     </a>
                   </li>
                   </li>
                   <li>
                   <li>
                     {# label は同じだけど、こっちは論理削除 #}
                     {# label は同じだけど、こっちは論理削除 #}
                     <a href="javascript:form_remove_{{ sUserId }}.submit()">
                     <a href="javascript:form_remove_{{ sUserId }}.submit()">
-                      <i class="icon-fw icon-fire text-danger"></i> 削除する
+                      <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
                     </a>
                     </a>
                   </li>
                   </li>
                   {% endif  %}
                   {% endif  %}
@@ -265,7 +265,7 @@
                   <li class="dropdown-button">
                   <li class="dropdown-button">
                     {# label は同じだけど、こっちは物理削除 #}
                     {# label は同じだけど、こっちは物理削除 #}
                     <a href="javascript:form_removeCompletely_{{ sUserId }}.submit()">
                     <a href="javascript:form_removeCompletely_{{ sUserId }}.submit()">
-                      <i class="icon-fw icon-fire text-danger"></i> 削除する
+                      <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
                     </a>
                     </a>
                   </li>
                   </li>
                   {% endif  %}
                   {% endif  %}
@@ -281,11 +281,11 @@
                   <li>
                   <li>
                     {% if sUser.username != user.username %}
                     {% if sUser.username != user.username %}
                       <a href="javascript:form_removeFromAdmin_{{ sUserId }}.submit()">
                       <a href="javascript:form_removeFromAdmin_{{ sUserId }}.submit()">
-                        <i class="icon-fw icon-user-unfollow"></i> 管理者からはずす
+                        <i class="icon-fw icon-user-unfollow"></i> {{ t("user_management.remove_admin_access") }}
                       </a>
                       </a>
                     {% else %}
                     {% else %}
                       <a disabled>
                       <a disabled>
-                        <i class="icon-fw icon-user-unfollow"></i> 管理者からはずす
+                        <i class="icon-fw icon-user-unfollow"></i> {{ t("user_management.remove_admin_access") }}
                       </a>
                       </a>
                       <p class="alert alert-danger m-l-10 m-r-10 p-10">{{ t("user_management.cannot_remove") }}</p>
                       <p class="alert alert-danger m-l-10 m-r-10 p-10">{{ t("user_management.cannot_remove") }}</p>
                     {% endif %}
                     {% endif %}
@@ -296,7 +296,7 @@
                   </form>
                   </form>
                   <li>
                   <li>
                     <a href="javascript:form_makeAdmin_{{ sUserId }}.submit()">
                     <a href="javascript:form_makeAdmin_{{ sUserId }}.submit()">
-                      <i class="icon-fw icon-magic-wand"></i> 管理者にする
+                      <i class="icon-fw icon-magic-wand"></i> {{ t("user_management.give_admin_access") }}
                     </a>
                     </a>
                   </li>
                   </li>
                   {% endif %}
                   {% endif %}

+ 3 - 2
src/server/views/admin/widget/passport/github.html

@@ -4,7 +4,8 @@
 
 
   {% set nameForIsGitHubEnabled = "settingForm[security:passport-github:isEnabled]" %}
   {% set nameForIsGitHubEnabled = "settingForm[security:passport-github:isEnabled]" %}
   {% set isGitHubEnabled = settingForm['security:passport-github:isEnabled'] %}
   {% set isGitHubEnabled = settingForm['security:passport-github:isEnabled'] %}
-  {% set callbackUrl = settingForm['app:siteUrl'] || '[INVALID]' + '/passport/github/callback' %}
+  {% set siteUrl = getConfig('crowi', 'app:siteUrl') || '[INVALID]' %}
+  {% set callbackUrl = siteUrl + '/passport/github/callback' %}
 
 
   <div class="form-group">
   <div class="form-group">
     <label for="{{nameForIsGitHubEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.GitHub.name") }}</label>
     <label for="{{nameForIsGitHubEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.GitHub.name") }}</label>
@@ -52,7 +53,7 @@
       <div class="col-xs-6">
       <div class="col-xs-6">
           <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
           <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
         <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
         <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
-        {% if !settingForm['app:siteUrl'] %}
+        {% if !getConfig('crowi', 'app:siteUrl') %}
         <div class="alert alert-danger">
         <div class="alert alert-danger">
           <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
           <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
         </div>
         </div>

+ 3 - 2
src/server/views/admin/widget/passport/google-oauth.html

@@ -4,7 +4,8 @@
 
 
   {% set nameForIsGoogleEnabled = "settingForm[security:passport-google:isEnabled]" %}
   {% set nameForIsGoogleEnabled = "settingForm[security:passport-google:isEnabled]" %}
   {% set isGoogleEnabled = settingForm['security:passport-google:isEnabled'] %}
   {% set isGoogleEnabled = settingForm['security:passport-google:isEnabled'] %}
-  {% set callbackUrl = settingForm['app:siteUrl'] || '[INVALID]' + '/passport/google/callback' %}
+  {% set siteUrl = getConfig('crowi', 'app:siteUrl') || '[INVALID]' %}
+  {% set callbackUrl = siteUrl + '/passport/google/callback' %}
 
 
   <div class="form-group">
   <div class="form-group">
     <label for="{{nameForIsGoogleEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Google.name") }}</label>
     <label for="{{nameForIsGoogleEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Google.name") }}</label>
@@ -52,7 +53,7 @@
       <div class="col-xs-6">
       <div class="col-xs-6">
           <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
           <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
         <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
         <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
-        {% if !settingForm['app:siteUrl'] %}
+        {% if !getConfig('crowi', 'app:siteUrl') %}
         <div class="alert alert-danger">
         <div class="alert alert-danger">
           <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
           <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
         </div>
         </div>

+ 329 - 135
src/server/views/admin/widget/passport/saml.html

@@ -3,95 +3,321 @@
   <legend class="alert-anchor">{{ t("security_setting.SAML.name") }} {{ t("security_setting.configuration") }}</legend>
   <legend class="alert-anchor">{{ t("security_setting.SAML.name") }} {{ t("security_setting.configuration") }}</legend>
 
 
   {% set nameForIsSamlEnabled = "settingForm[security:passport-saml:isEnabled]" %}
   {% set nameForIsSamlEnabled = "settingForm[security:passport-saml:isEnabled]" %}
-  {% set isSamlEnabled = settingForm['security:passport-saml:isEnabled'] %}
+  {% set isSamlEnabled  = getConfig('crowi', 'security:passport-saml:isEnabled') %}
+  {% set useOnlyEnvVars = getConfig('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions') %}
+  {% set siteUrl = getConfig('crowi', 'app:siteUrl') || '[INVALID]' %}
+  {% set callbackUrl = siteUrl + '/passport/saml/callback' %}
+
+  {% if useOnlyEnvVars %}
+    <p class="alert alert-info">
+      {{ t("security_setting.SAML.note for the only env option", "SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS") }}
+    </p>
+  {% endif %}
 
 
   <div class="form-group">
   <div class="form-group">
-    <label for="{{nameForIsSamlEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.SAML.name") }}</label>
+    <label class="col-xs-3 control-label">{{ t("security_setting.SAML.name") }}</label>
     <div class="col-xs-6">
     <div class="col-xs-6">
-      <div class="btn-group btn-toggle" data-toggle="buttons">
+      <div class="btn-group btn-toggle {% if useOnlyEnvVars %}btn-group-disabled{% endif %}" data-toggle="buttons">
         <label class="btn btn-default btn-rounded btn-outline {% if isSamlEnabled %}active{% endif %}" data-active-class="primary">
         <label class="btn btn-default btn-rounded btn-outline {% if isSamlEnabled %}active{% endif %}" data-active-class="primary">
-          <input name="{{nameForIsSamlEnabled}}" value="true" type="radio"
-              {% if true === isSamlEnabled %}checked{% endif %}> ON
+          <input name="{{nameForIsSamlEnabled}}"
+                 value="true"
+                 type="radio"
+                 {% if true === isSamlEnabled %}checked{% endif %}
+                 {% if useOnlyEnvVars %}readonly{% endif %}> ON
         </label>
         </label>
         <label class="btn btn-default btn-rounded btn-outline {% if !isSamlEnabled %}active{% endif %}" data-active-class="default">
         <label class="btn btn-default btn-rounded btn-outline {% if !isSamlEnabled %}active{% endif %}" data-active-class="default">
-          <input name="{{nameForIsSamlEnabled}}" value="false" type="radio"
-              {% if !isSamlEnabled %}checked{% endif %}> OFF
+          <input name="{{nameForIsSamlEnabled}}"
+                 value="false"
+                 type="radio"
+                 {% if !isSamlEnabled %}checked{% endif %}
+                 {% if useOnlyEnvVars %}readonly{% endif %}> OFF
         </label>
         </label>
       </div>
       </div>
     </div>
     </div>
   </div>
   </div>
-  <fieldset id="passport-saml-hide-when-disabled" {%if !isSamlEnabled %}style="display: none;"{% endif %}>
 
 
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:entryPoint]" class="col-xs-3 control-label">{{ t("security_setting.SAML.entry_point") }}</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-saml:entryPoint]" value="{{ settingForm['security:passport-saml:entryPoint'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.Use env var if empty", "SAML_ENTRY_POINT") }}
-          </small>
-        </p>
+  <div class="form-group">
+    <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+    <div class="col-xs-6">
+      <input class="form-control"
+             type="text"
+             value="{{ callbackUrl }}"
+             readonly>
+      <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'SAML Identity') }}</p>
+      {% if !getConfig('crowi', 'app:siteUrl') %}
+      <div class="alert alert-danger">
+        <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
       </div>
       </div>
+      {% endif %}
     </div>
     </div>
+  </div>
 
 
-    <div class="form-group">
-      <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" value="{% if settingForm['app:siteUrl'] %}{{ settingForm['app:siteUrl'] }}{% else %}[INVALID] {% endif %}/passport/saml/callback" readonly>
-        <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'SAML Identity') }}</p>
-        {% if !settingForm['app:siteUrl'] %}
-        <div class="alert alert-danger">
-          <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
-        </div>
-        {% endif %}
-      </div>
-    </div>
+  <fieldset id="passport-saml-hide-when-disabled" {%if !isSamlEnabled %}style="display: none;"{% endif %}>
 
 
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:issuer]" class="col-xs-3 control-label">{{ t("security_setting.SAML.issuer") }}</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-saml:issuer]" value="{{ settingForm['security:passport-saml:issuer'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.Use env var if empty", "SAML_ISSUER") }}
-          </small>
-        </p>
-      </div>
+    {% set missingMandatoryConfigKeys = getSamlMissingMandatoryConfigKeys() %}
+    {% if missingMandatoryConfigKeys.length !== 0 %}
+    <div class="alert alert-danger">
+      {{ t("security_setting.missing mandatory configs") }}
+      <ul>
+        {% for missingMandatoryConfigKey in missingMandatoryConfigKeys %}
+        <li>{{ t("security_setting.form_item_name." + missingMandatoryConfigKey) }}</li>
+        {% endfor %}
+      </ul>
     </div>
     </div>
+    {% endif %}
+
+    <h4>Basic Settings</h4>
+    <table class="table settings-table {% if useOnlyEnvVars %}use-only-env-vars{% endif %}">
+      <colgroup>
+        <col class="item-name">
+        <col class="from-db">
+        <col class="from-env-vars">
+      </colgroup>
+      <thead>
+        <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+      </thead>
+      <tbody>
+        <tr>
+          <th>{{ t("security_setting.form_item_name.security:passport-saml:entryPoint") }}</th>
+          <td>
+            <input class="form-control"
+                   type="text"
+                   name="settingForm[security:passport-saml:entryPoint]"
+                   value="{{ getConfigFromDB('crowi', 'security:passport-saml:entryPoint') || '' }}"
+                   {% if useOnlyEnvVars %}readonly{% endif %}>
+          </td>
+          <td>
+            <input class="form-control"
+                   type="text"
+                   value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:entryPoint') || '' }}"
+                   readonly>
+            <p class="help-block">
+              <small>
+                {{ t("security_setting.SAML.Use env var if empty", "SAML_ENTRY_POINT") }}
+              </small>
+            </p>
+          </td>
+        </tr>
+        <tr>
+          <th>{{ t("security_setting.form_item_name.security:passport-saml:issuer") }}</th>
+          <td>
+            <input class="form-control"
+                   type="text"
+                   name="settingForm[security:passport-saml:issuer]"
+                   value="{{ getConfigFromDB('crowi', 'security:passport-saml:issuer') || '' }}"
+                   {% if useOnlyEnvVars %}readonly{% endif %}>
+          </td>
+          <td>
+            <input class="form-control"
+                   type="text"
+                   value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:issuer') || '' }}"
+                   readonly>
+            <p class="help-block">
+              <small>
+                {{ t("security_setting.SAML.Use env var if empty", "SAML_ISSUER") }}
+              </small>
+            </p>
+          </td>
+        </tr>
+        <tr>
+          <th>{{ t("security_setting.form_item_name.security:passport-saml:cert") }}</th>
+          <td>
+            <textarea class="form-control input-sm"
+                      type="text"
+                      rows="5"
+                      name="settingForm[security:passport-saml:cert]"
+                      {% if useOnlyEnvVars %}readonly{% endif %}
+            >{{ getConfigFromDB('crowi', 'security:passport-saml:cert') || '' }}</textarea>
+            <p class="help-block">
+              <small>
+                {{ t("security_setting.SAML.cert_detail") }}
+              </small>
+            </p>
+            <p>
+              <small>
+                e.g.
+                <pre>-----BEGIN CERTIFICATE-----
+MIICBzCCAXACCQD4US7+0A/b/zANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJK
+UDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAoMDFdFU0VFSywgSW5jLjESMBAGA1UE
+...
+crmVwBzbloUO2l6k1ibwD2WVwpdxMKIF5z58HfKAvxZAzCHE7kMEZr1ge30WRXQA
+pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
+-----END CERTIFICATE-----</pre>
+              </small>
+            </p>
+          </td>
+          <td>
+            <textarea class="form-control input-sm"
+                      type="text"
+                      rows="5"
+                      readonly
+            >{{ getConfigFromEnvVars('crowi', 'security:passport-saml:cert') || '' }}</textarea>
+            <p class="help-block">
+              <small>
+                {{ t("security_setting.SAML.Use env var if empty", "SAML_CERT") }}
+              </small>
+            </p>
+          </td>
+        </tr>
+      </tbody>
+    </table>
 
 
     <h4>Attribute Mapping</h4>
     <h4>Attribute Mapping</h4>
 
 
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapId]" class="col-xs-3 control-label">Identifier</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text"
-            name="settingForm[security:passport-saml:attrMapId]" value="{{ settingForm['security:passport-saml:attrMapId'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.id_detail") }}
-          </small>
-        </p>
-      </div>
-    </div>
+    <table class="table settings-table {% if useOnlyEnvVars %}use-only-env-vars{% endif %}">
+      <colgroup>
+        <col class="item-name">
+        <col class="from-db">
+        <col class="from-env-vars">
+      </colgroup>
+      <thead>
+        <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+      </thead>
+      <tbody>
+      <tr>
+        <th>{{ t("security_setting.form_item_name.security:passport-saml:attrMapId") }}</th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:attrMapId]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:attrMapId') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.id_detail") }}
+            </small>
+          </p>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapId') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ATTR_MAPPING_ID") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      <tr>
+        <th>{{ t("security_setting.form_item_name.security:passport-saml:attrMapUsername") }}</th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:attrMapUsername]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:attrMapUsername') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.username_detail") }}
+            </small>
+          </p>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapUsername') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ATTR_MAPPING_USERNAME") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      <tr>
+        <th>{{ t("security_setting.form_item_name.security:passport-saml:attrMapMail") }}</th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:attrMapMail]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:attrMapMail') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.mapping_detail", t("Email")) }}
+            </small>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapMail') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ATTR_MAPPING_MAIL") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      <tr>
+        <th>{{ t("security_setting.form_item_name.security:passport-saml:attrMapFirstName") }}</th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:attrMapFirstName]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:attrMapFirstName') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.mapping_detail", t("security_setting.form_item_name.security:passport-saml:attrMapFirstName")) }}
+            </small>
+          </p>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapFirstName') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ATTR_MAPPING_FIRST_NAME") }}<br>
+              {{ t("security_setting.Use default if both are empty", "firstName") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      <tr>
+        <th>{{ t("security_setting.form_item_name.security:passport-saml:attrMapLastName") }}</th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:attrMapLastName]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:attrMapLastName') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.mapping_detail", t("security_setting.form_item_name.security:passport-saml:attrMapLastName")) }}
+            </small>
+          </p>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapLastName') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ATTR_MAPPING_LAST_NAME") }}<br>
+              {{ t("security_setting.Use default if both are empty", "lastName") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      </tbody>
+    </table>
 
 
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapUsername]" class="col-xs-3 control-label">Username</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text"
-            name="settingForm[security:passport-saml:attrMapUsername]" value="{{ settingForm['security:passport-saml:attrMapUsername'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.username_detail") }}
-          </small>
-        </p>
-      </div>
-    </div>
+    <h4>Attribute Mapping Options</h4>
 
 
     <div class="form-group">
     <div class="form-group">
-      <div class="col-xs-6 col-xs-offset-3">
+      <div class="col-xs-offset-1">
         <div class="checkbox checkbox-info">
         <div class="checkbox checkbox-info">
-          <input type="checkbox" id="bindByUserName-SAML" name="settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]" value="1"
-              {% if settingForm['security:passport-saml:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <input id="bindByUserName-SAML"
+                 type="checkbox"
+                 name="settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]"
+                 value="1"
+                 {% if getConfig('crowi', 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser') %}checked{% endif %} />
           <label for="bindByUserName-SAML">
           <label for="bindByUserName-SAML">
             {{ t("security_setting.Treat username matching as identical", "username") }}
             {{ t("security_setting.Treat username matching as identical", "username") }}
           </label>
           </label>
@@ -105,23 +331,13 @@
     </div>
     </div>
 
 
     <div class="form-group">
     <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapMail]" class="col-xs-3 control-label">Mail</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text"
-            name="settingForm[security:passport-saml:attrMapMail]" value="{{ settingForm['security:passport-saml:attrMapMail'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.mapping_detail", t("Email")) }}
-          </small>
-        </p>
-      </div>
-    </div>
-
-    <div class="form-group">
-      <div class="col-xs-6 col-xs-offset-3">
+      <div class="col-xs-offset-1">
         <div class="checkbox checkbox-info">
         <div class="checkbox checkbox-info">
-          <input type="checkbox" id="bindByEmail-SAML" name="settingForm[security:passport-saml:isSameEmailTreatedAsIdenticalUser]" value="1"
-              {% if settingForm['security:passport-saml:isSameEmailTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <input id="bindByEmail-SAML"
+                 type="checkbox"
+                 name="settingForm[security:passport-saml:isSameEmailTreatedAsIdenticalUser]"
+                 value="1"
+                 {% if getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser') %}checked{% endif %} />
           <label for="bindByEmail-SAML">
           <label for="bindByEmail-SAML">
             {{ t("security_setting.Treat email matching as identical", "email") }}
             {{ t("security_setting.Treat email matching as identical", "email") }}
           </label>
           </label>
@@ -134,59 +350,6 @@
       </div>
       </div>
     </div>
     </div>
 
 
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapFirstName]" class="col-xs-3 control-label">{{ t("security_setting.SAML.First Name") }}</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" placeholder="Default: firstName"
-            name="settingForm[security:passport-saml:attrMapFirstName]" value="{{ settingForm['security:passport-saml:attrMapFirstName'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.mapping_detail", t("security_setting.SAML.First Name")) }}
-          </small>
-        </p>
-      </div>
-    </div>
-
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapLastName]" class="col-xs-3 control-label">{{ t("security_setting.SAML.Last Name") }}</label>
-      <div class="col-xs-6">
-        <input class="form-control" type="text" placeholder="Default: lastName"
-            name="settingForm[security:passport-saml:attrMapLastName]" value="{{ settingForm['security:passport-saml:attrMapLastName'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.mapping_detail", t("security_setting.SAML.Last Name")) }}
-          </small>
-        </p>
-      </div>
-    </div>
-
-    <h4>Options</h4>
-
-    <div class="form-group">
-      <label for="settingForm[security:passport-saml:cert]" class="col-xs-3 control-label">Certificate</label>
-      <div class="col-xs-6">
-        <textarea class="form-control input-sm" type="text" rows="5" name="settingForm[security:passport-saml:cert]">{{ settingForm['security:passport-saml:cert'] || '' }}</textarea>
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.SAML.cert_detail1") }}<br>
-            {{ t("security_setting.SAML.cert_detail2") }}
-          </small>
-        </p>
-        <p>
-          <small>
-            e.g.
-            <pre>-----BEGIN CERTIFICATE-----
-MIICBzCCAXACCQD4US7+0A/b/zANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJK
-UDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAoMDFdFU0VFSywgSW5jLjESMBAGA1UE
-...
-crmVwBzbloUO2l6k1ibwD2WVwpdxMKIF5z58HfKAvxZAzCHE7kMEZr1ge30WRXQA
-pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
------END CERTIFICATE-----</pre>
-          </small>
-        </p>
-      </div>
-    </div>
-
   </fieldset>
   </fieldset>
 
 
   <div class="form-group" id="btn-update">
   <div class="form-group" id="btn-update">
@@ -199,6 +362,10 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
 </form>
 </form>
 
 
 <script>
 <script>
+  $('.btn-group-disabled').on('click', '.btn', function() {
+    return false;
+  });
+
   $('input[name="settingForm[security:passport-saml:isEnabled]"]').change(function() {
   $('input[name="settingForm[security:passport-saml:isEnabled]"]').change(function() {
     const isEnabled = ($(this).val() === "true");
     const isEnabled = ($(this).val() === "true");
 
 
@@ -209,5 +376,32 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
       $('#passport-saml-hide-when-disabled').hide(400);
       $('#passport-saml-hide-when-disabled').hide(400);
     }
     }
   });
   });
+
+
+  /**
+   * The following script sets the class name 'unused' to the cell in from-env-vars column
+   * when the value of the corresponding cell from the database is not empty.
+   * It is used to indicate that the system does not use a value from the environment variables by setting a css style.
+   * This behavior is disabled when the system is in the use-only-env-vars mode.
+   */
+  $('.settings-table:not(.use-only-env-vars) tbody tr').each(function(_, element) {
+    const inputElemFromDB      = $('td:nth-of-type(1) input[type="text"], td:nth-of-type(1) textarea', element);
+    const inputElemFromEnvVars = $('td:nth-of-type(2) input[type="text"], td:nth-of-type(2) textarea', element);
+
+    // initialize
+    addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars);
+
+    // set keyup event handler
+    inputElemFromDB.keyup(function () { addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars) });
+  });
+
+  function addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars) {
+    if (inputElemFromDB.val() === '') {
+      inputElemFromEnvVars.parent().removeClass('unused');
+    }
+    else {
+      inputElemFromEnvVars.parent().addClass('unused');
+    }
+  };
 </script>
 </script>
 
 

+ 3 - 2
src/server/views/admin/widget/passport/twitter.html

@@ -4,7 +4,8 @@
 
 
   {% set nameForIsTwitterEnabled = "settingForm[security:passport-twitter:isEnabled]" %}
   {% set nameForIsTwitterEnabled = "settingForm[security:passport-twitter:isEnabled]" %}
   {% set isTwitterEnabled = settingForm['security:passport-twitter:isEnabled'] %}
   {% set isTwitterEnabled = settingForm['security:passport-twitter:isEnabled'] %}
-  {% set callbackUrl = settingForm['app:siteUrl'] || '[INVALID]' + '/passport/twitter/callback' %}
+  {% set siteUrl = getConfig('crowi', 'app:siteUrl') || '[INVALID]' %}
+  {% set callbackUrl = siteUrl + '/passport/twitter/callback' %}
 
 
   <div class="form-group">
   <div class="form-group">
     <label for="{{nameForIsTwitterEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Twitter.name") }}</label>
     <label for="{{nameForIsTwitterEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Twitter.name") }}</label>
@@ -54,7 +55,7 @@
       <div class="col-xs-6">
       <div class="col-xs-6">
           <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
           <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
         <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
         <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</p>
-        {% if !settingForm['app:siteUrl'] %}
+        {% if !getConfig('crowi', 'app:siteUrl') %}
         <div class="alert alert-danger">
         <div class="alert alert-danger">
           <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
           <i class="icon-exclamation"></i> {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
         </div>
         </div>

+ 4 - 0
src/server/views/layout-crowi/base/layout.html

@@ -7,6 +7,10 @@
   {{ cdnScriptTag('highlight-addons') }}
   {{ cdnScriptTag('highlight-addons') }}
 {% endblock %}
 {% endblock %}
 
 
+{% block head_warn %}
+  {% include '../../widget/alert_siteurl_undefined.html' %}
+{% endblock %}
+
 {% block layout_main %}
 {% block layout_main %}
 <div class="container-fluid">
 <div class="container-fluid">
 
 

+ 4 - 0
src/server/views/layout-growi/base/layout.html

@@ -5,6 +5,10 @@
   {{ cdnScriptTag('highlight-addons') }}
   {{ cdnScriptTag('highlight-addons') }}
 {% endblock %}
 {% endblock %}
 
 
+{% block head_warn %}
+  {% include '../../widget/alert_siteurl_undefined.html' %}
+{% endblock %}
+
 {% block layout_main %}
 {% block layout_main %}
 <div class="container-fluid">
 <div class="container-fluid">
 
 

+ 4 - 0
src/server/views/layout-kibela/base/layout.html

@@ -5,6 +5,10 @@
   {{ cdnScriptTag('highlight-addons') }}
   {{ cdnScriptTag('highlight-addons') }}
 {% endblock %}
 {% endblock %}
 
 
+{% block head_warn %}
+  {% include '../../widget/alert_siteurl_undefined.html' %}
+{% endblock %}
+
 {% block layout_main %}
 {% block layout_main %}
 <div class="container-fluid">
 <div class="container-fluid">
 
 

+ 3 - 1
src/server/views/layout/layout.html

@@ -117,7 +117,7 @@
 <div id="wrapper">
 <div id="wrapper">
   <!-- Navigation -->
   <!-- Navigation -->
   {% block layout_head_nav %}
   {% block layout_head_nav %}
-  <nav class="navbar navbar-default navbar-static-top m-b-0">
+  <nav class="navbar navbar-default navbar-static-top mb-0">
     <div class="navbar-header">
     <div class="navbar-header">
       <a class="navbar-toggle hidden-sm hidden-md hidden-lg " href="javascript:void(0)" data-toggle="collapse" data-target=".navbar-collapse">
       <a class="navbar-toggle hidden-sm hidden-md hidden-lg " href="javascript:void(0)" data-toggle="collapse" data-target=".navbar-collapse">
         <i class="ti-menu"></i>
         <i class="ti-menu"></i>
@@ -195,6 +195,8 @@
   {% include '../modal/create_page.html' %}
   {% include '../modal/create_page.html' %}
   {% endblock  %} {# layout_head_nav #}
   {% endblock  %} {# layout_head_nav #}
 
 
+  {% block head_warn %}{% endblock %}
+
   {% block sidebar %}
   {% block sidebar %}
   <!-- Left navbar-header -->
   <!-- Left navbar-header -->
   <div class="navbar-default sidebar hidden-print" role="navigation">
   <div class="navbar-default sidebar hidden-print" role="navigation">

+ 1 - 1
src/server/views/modal/duplicate.html

@@ -16,7 +16,7 @@
             <div class="form-group">
             <div class="form-group">
               <label for="duplicatePageName">{{ t('modal_duplicate.label.New page name') }}</label><br>
               <label for="duplicatePageName">{{ t('modal_duplicate.label.New page name') }}</label><br>
               <div class="input-group">
               <div class="input-group">
-                <span class="input-group-addon">{{ config.crowi['app:siteUrl:fixed'] }}</span>
+                <span class="input-group-addon">{{ baseUrl }}</span>
                 <input type="text" class="form-control" name="new_path" id="duplicatePageName" value="{{ page.path }}">
                 <input type="text" class="form-control" name="new_path" id="duplicatePageName" value="{{ page.path }}">
               </div>
               </div>
             </div>
             </div>

+ 1 - 1
src/server/views/modal/rename.html

@@ -16,7 +16,7 @@
           <div class="form-group">
           <div class="form-group">
             <label for="newPageName">{{ t('modal_rename.label.New page name') }}</label><br>
             <label for="newPageName">{{ t('modal_rename.label.New page name') }}</label><br>
             <div class="input-group">
             <div class="input-group">
-              <span class="input-group-addon">{{ config.crowi['app:siteUrl:fixed'] }}</span>
+              <span class="input-group-addon">{{ baseUrl }}</span>
               <input type="text" class="form-control" name="new_path" id="newPageName" value="{{ page.path }}">
               <input type="text" class="form-control" name="new_path" id="newPageName" value="{{ page.path }}">
             </div>
             </div>
           </div>
           </div>

+ 3 - 0
src/server/views/search.html

@@ -10,6 +10,9 @@
   data-target="#search-result-list"
   data-target="#search-result-list"
 {% endblock %}
 {% endblock %}
 
 
+{% block head_warn %}
+  {% include './widget/alert_siteurl_undefined.html' %}
+{% endblock %}
 
 
 {% block layout_main %}
 {% block layout_main %}
 <div class="container-fluid">
 <div class="container-fluid">

+ 6 - 0
src/server/views/widget/alert_siteurl_undefined.html

@@ -0,0 +1,6 @@
+{% if !getConfig('crowi', 'app:siteUrl') %}
+<div class="alert alert-danger mb-0">
+  <i class="icon-exclamation"></i>
+  {{ t("security_setting.alert_siteUrl_is_not_set", '<a href="/admin/app">' + t('App settings') + '<i class="icon-login"></i></a>') }}
+</div>
+{% endif %}

+ 25 - 7
src/test/models/page.test.js

@@ -76,7 +76,7 @@ describe('Page', () => {
         path: '/grant/specified',
         path: '/grant/specified',
         grant: Page.GRANT_SPECIFIED,
         grant: Page.GRANT_SPECIFIED,
         grantedUsers: [testUser0],
         grantedUsers: [testUser0],
-        creator: testUser0
+        creator: testUser0,
       },
       },
       {
       {
         path: '/grant/owner',
         path: '/grant/owner',
@@ -92,7 +92,7 @@ describe('Page', () => {
       },
       },
       {
       {
         path: '/grant/groupacl',
         path: '/grant/groupacl',
-        grant: 5,
+        grant: Page.GRANT_USER_GROUP,
         grantedUsers: [],
         grantedUsers: [],
         grantedGroup: testGroup0,
         grantedGroup: testGroup0,
         creator: testUser1,
         creator: testUser1,
@@ -233,7 +233,7 @@ describe('Page', () => {
     context('with a restricted page and an user who has no grant', () => {
     context('with a restricted page and an user who has no grant', () => {
       it('should return false', async() => {
       it('should return false', async() => {
         const user = await User.findOne({email: 'anonymous1@example.com'});
         const user = await User.findOne({email: 'anonymous1@example.com'});
-        const page = await Page.findOne({path: '/grant/restricted'});
+        const page = await Page.findOne({path: '/grant/owner'});
 
 
         const bool = await Page.isAccessiblePageByViewer(page.id, user);
         const bool = await Page.isAccessiblePageByViewer(page.id, user);
         expect(bool).to.be.equal(false);
         expect(bool).to.be.equal(false);
@@ -283,8 +283,8 @@ describe('Page', () => {
 
 
   describe('.findPage', () => {
   describe('.findPage', () => {
     context('findByIdAndViewer', () => {
     context('findByIdAndViewer', () => {
-      it('should find page', async() => {
-        const pageToFind = createdPages[0];
+      it('should find page (public)', async() => {
+        const pageToFind = createdPages[1];
         const grantedUser = createdUsers[0];
         const grantedUser = createdUsers[0];
 
 
         const page = await Page.findByIdAndViewer(pageToFind._id, grantedUser);
         const page = await Page.findByIdAndViewer(pageToFind._id, grantedUser);
@@ -292,8 +292,26 @@ describe('Page', () => {
         expect(page.path).to.equal(pageToFind.path);
         expect(page.path).to.equal(pageToFind.path);
       });
       });
 
 
-      it('should not be found by grant', async() => {
-        const pageToFind = createdPages[0];
+      it('should find page (anyone knows link)', async() => {
+        const pageToFind = createdPages[2];
+        const grantedUser = createdUsers[1];
+
+        const page = await Page.findByIdAndViewer(pageToFind._id, grantedUser);
+        expect(page).to.be.not.null;
+        expect(page.path).to.equal(pageToFind.path);
+      });
+
+      it('should find page (just me)', async() => {
+        const pageToFind = createdPages[4];
+        const grantedUser = createdUsers[0];
+
+        const page = await Page.findByIdAndViewer(pageToFind._id, grantedUser);
+        expect(page).to.be.not.null;
+        expect(page.path).to.equal(pageToFind.path);
+      });
+
+      it('should not be found by grant (just me)', async() => {
+        const pageToFind = createdPages[4];
         const grantedUser = createdUsers[1];
         const grantedUser = createdUsers[1];
 
 
         const page = await Page.findByIdAndViewer(pageToFind._id, grantedUser);
         const page = await Page.findByIdAndViewer(pageToFind._id, grantedUser);

+ 72 - 86
yarn.lock

@@ -31,33 +31,34 @@
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/@handsontable/react/-/react-2.0.0.tgz#30d9c2bd05421588a6ed1b3050b1f7dc476b35d3"
   resolved "https://registry.yarnpkg.com/@handsontable/react/-/react-2.0.0.tgz#30d9c2bd05421588a6ed1b3050b1f7dc476b35d3"
 
 
-"@lykmapipo/gridfs-stream@^1.2.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@lykmapipo/gridfs-stream/-/gridfs-stream-1.2.0.tgz#0f74826816b4f7414ae36862d67ce4849a224d91"
-  dependencies:
-    flushwritable "^1.0.0"
-
 "@sinonjs/commons@^1.0.2":
 "@sinonjs/commons@^1.0.2":
   version "1.0.2"
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.0.2.tgz#3e0ac737781627b8844257fadc3d803997d0526e"
   resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.0.2.tgz#3e0ac737781627b8844257fadc3d803997d0526e"
   dependencies:
   dependencies:
     type-detect "4.0.8"
     type-detect "4.0.8"
 
 
-"@sinonjs/formatio@3.0.0", "@sinonjs/formatio@^3.0.0":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-3.0.0.tgz#9d282d81030a03a03fa0c5ce31fd8786a4da311a"
+"@sinonjs/commons@^1.2.0":
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.3.0.tgz#50a2754016b6f30a994ceda6d9a0a8c36adda849"
+  integrity sha512-j4ZwhaHmwsCb4DlDOIWnI5YyKDNMoNThsmwEpfHx6a1EpsGZ9qYLxP++LMlmBRjtGptGHFsGItJ768snllFWpA==
   dependencies:
   dependencies:
-    "@sinonjs/samsam" "2.1.0"
+    type-detect "4.0.8"
 
 
-"@sinonjs/samsam@2.1.0":
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-2.1.0.tgz#b8b8f5b819605bd63601a6ede459156880f38ea3"
+"@sinonjs/formatio@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-3.1.0.tgz#6ac9d1eb1821984d84c4996726e45d1646d8cce5"
+  integrity sha512-ZAR2bPHOl4Xg6eklUGpsdiIJ4+J1SNag1DHHrG/73Uz/nVwXqjgUtRPLoS+aVyieN9cSbc0E4LsU984tWcDyNg==
   dependencies:
   dependencies:
-    array-from "^2.1.1"
+    "@sinonjs/samsam" "^2 || ^3"
 
 
-"@sinonjs/samsam@^2.1.2":
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-2.1.2.tgz#16947fce5f57258d01f1688fdc32723093c55d3f"
+"@sinonjs/samsam@^2 || ^3", "@sinonjs/samsam@^3.0.2":
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-3.0.2.tgz#304fb33bd5585a0b2df8a4c801fcb47fa84d8e43"
+  integrity sha512-m08g4CS3J6lwRQk1pj1EO+KEVWbrbXsmi9Pw0ySmrIbcVxVaedoFgLvFsV8wHLwh01EpROVz3KvVcD1Jmks9FQ==
+  dependencies:
+    "@sinonjs/commons" "^1.0.2"
+    array-from "^2.1.1"
+    lodash.get "^4.4.2"
 
 
 "@types/body-parser@*":
 "@types/body-parser@*":
   version "1.16.8"
   version "1.16.8"
@@ -1570,7 +1571,7 @@ bs-recipes@1.3.4:
   version "1.3.4"
   version "1.3.4"
   resolved "https://registry.yarnpkg.com/bs-recipes/-/bs-recipes-1.3.4.tgz#0d2d4d48a718c8c044769fdc4f89592dc8b69585"
   resolved "https://registry.yarnpkg.com/bs-recipes/-/bs-recipes-1.3.4.tgz#0d2d4d48a718c8c044769fdc4f89592dc8b69585"
 
 
-bson@^1.1.0:
+bson@^1.1.0, bson@~1.1.0:
   version "1.1.0"
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.0.tgz#bee57d1fb6a87713471af4e32bcae36de814b5b0"
   resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.0.tgz#bee57d1fb6a87713471af4e32bcae36de814b5b0"
 
 
@@ -1578,10 +1579,6 @@ bson@~1.0.4:
   version "1.0.4"
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/bson/-/bson-1.0.4.tgz#93c10d39eaa5b58415cbc4052f3e53e562b0b72c"
   resolved "https://registry.yarnpkg.com/bson/-/bson-1.0.4.tgz#93c10d39eaa5b58415cbc4052f3e53e562b0b72c"
 
 
-bson@~1.0.5:
-  version "1.0.9"
-  resolved "https://registry.yarnpkg.com/bson/-/bson-1.0.9.tgz#12319f8323b1254739b7c6bef8d3e89ae05a2f57"
-
 buffer-equal-constant-time@1.0.1:
 buffer-equal-constant-time@1.0.1:
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
   resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
@@ -2762,6 +2759,11 @@ diff@^3.3.1:
   version "3.4.0"
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/diff/-/diff-3.4.0.tgz#b1d85507daf3964828de54b37d0d73ba67dda56c"
   resolved "https://registry.yarnpkg.com/diff/-/diff-3.4.0.tgz#b1d85507daf3964828de54b37d0d73ba67dda56c"
 
 
+diff@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.1.tgz#0c667cb467ebbb5cea7f14f135cc2dba7780a8ff"
+  integrity sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q==
+
 diffie-hellman@^5.0.0:
 diffie-hellman@^5.0.0:
   version "5.0.2"
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"
   resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"
@@ -3661,10 +3663,6 @@ flush-write-stream@^1.0.0:
     inherits "^2.0.1"
     inherits "^2.0.1"
     readable-stream "^2.0.4"
     readable-stream "^2.0.4"
 
 
-flushwritable@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/flushwritable/-/flushwritable-1.0.0.tgz#3e328d8fde412ad47e738e3be750b4d290043498"
-
 fn-args@3.0.0:
 fn-args@3.0.0:
   version "3.0.0"
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/fn-args/-/fn-args-3.0.0.tgz#df5c3805ed41ec3b38a72aabe390cf9493ec084c"
   resolved "https://registry.yarnpkg.com/fn-args/-/fn-args-3.0.0.tgz#df5c3805ed41ec3b38a72aabe390cf9493ec084c"
@@ -5068,9 +5066,10 @@ jsx-ast-utils@^2.0.1:
   dependencies:
   dependencies:
     array-includes "^3.0.3"
     array-includes "^3.0.3"
 
 
-just-extend@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-3.0.0.tgz#cee004031eaabf6406da03a7b84e4fe9d78ef288"
+just-extend@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc"
+  integrity sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==
 
 
 jwa@^1.1.4:
 jwa@^1.1.4:
   version "1.1.5"
   version "1.1.5"
@@ -5335,7 +5334,7 @@ lodash.foreach@^4.1.0:
   version "4.5.0"
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53"
   resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53"
 
 
-lodash.get@4.4.2, lodash.get@^4.0, lodash.get@^4.0.2, lodash.get@^4.4.2:
+lodash.get@^4.0, lodash.get@^4.0.2, lodash.get@^4.4.2:
   version "4.4.2"
   version "4.4.2"
   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
 
 
@@ -5395,7 +5394,7 @@ lodash.uniq@^4.5.0:
   version "4.5.0"
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
 
 
-lodash@4.17.11, lodash@^4.17.11:
+lodash@4.17.11, lodash@>=4.17.11, lodash@^4.17.11:
   version "4.17.11"
   version "4.17.11"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
 
 
@@ -5522,9 +5521,10 @@ markdown-it-named-headers@^0.0.4:
   dependencies:
   dependencies:
     string "^3.0.1"
     string "^3.0.1"
 
 
-markdown-it-plantuml@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/markdown-it-plantuml/-/markdown-it-plantuml-1.0.0.tgz#7b6a351a1d9275705c09626b02d873301e5899c2"
+markdown-it-plantuml@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-plantuml/-/markdown-it-plantuml-1.3.0.tgz#a4eb7bcdc718f6b3b889840d974e5f9184a90102"
+  integrity sha512-BW4/dzaIFX3D3dMnifmYko2o2+gajkkt1VQpEuicC6E7sCtKgjOAI4qpYmJKH70guGH0qoK0sNMHXJfi3noTbQ==
 
 
 markdown-it-task-checkbox@^1.0.6:
 markdown-it-task-checkbox@^1.0.6:
   version "1.0.6"
   version "1.0.6"
@@ -5877,16 +5877,6 @@ mongodb-core@2.1.19:
     bson "~1.0.4"
     bson "~1.0.4"
     require_optional "~1.0.0"
     require_optional "~1.0.0"
 
 
-mongodb-core@3.1.5:
-  version "3.1.5"
-  resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-3.1.5.tgz#59ca67d7f6cea570d5437624a7afec8d752d477a"
-  dependencies:
-    bson "^1.1.0"
-    require_optional "^1.0.1"
-    safe-buffer "^5.1.2"
-  optionalDependencies:
-    saslprep "^1.0.0"
-
 mongodb-core@3.1.9:
 mongodb-core@3.1.9:
   version "3.1.9"
   version "3.1.9"
   resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-3.1.9.tgz#c31ee407bf932b0149eaed775c17ee09974e4ca3"
   resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-3.1.9.tgz#c31ee407bf932b0149eaed775c17ee09974e4ca3"
@@ -5904,13 +5894,6 @@ mongodb@3.1.10:
     mongodb-core "3.1.9"
     mongodb-core "3.1.9"
     safe-buffer "^5.1.2"
     safe-buffer "^5.1.2"
 
 
-mongodb@3.1.6:
-  version "3.1.6"
-  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.1.6.tgz#6054641973b5bf5b5ae1c67dcbcf8fa88280273d"
-  dependencies:
-    mongodb-core "3.1.5"
-    safe-buffer "^5.1.2"
-
 mongodb@^2.0.36:
 mongodb@^2.0.36:
   version "2.2.35"
   version "2.2.35"
   resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-2.2.35.tgz#cd1b5af8a9463e3f9a787fa5b3d05565579730f9"
   resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-2.2.35.tgz#cd1b5af8a9463e3f9a787fa5b3d05565579730f9"
@@ -5919,13 +5902,13 @@ mongodb@^2.0.36:
     mongodb-core "2.1.19"
     mongodb-core "2.1.19"
     readable-stream "2.2.7"
     readable-stream "2.2.7"
 
 
-mongoose-gridfs@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/mongoose-gridfs/-/mongoose-gridfs-0.5.0.tgz#626e12ab605c2ed2a205a5953cd5aa8615f44feb"
+mongoose-gridfs@^0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/mongoose-gridfs/-/mongoose-gridfs-0.6.2.tgz#b144219af01c51b14c58e5cdf7f58293d2ce1d67"
+  integrity sha512-fW6+D1Pn+qtpuYK3lYl1OMd3wenNIJ0/KhpOAoiRZWdzHjABiUQrtZSCyLEjmVgsxuTqIsUqvS1KeMUvUmtO2A==
   dependencies:
   dependencies:
-    "@lykmapipo/gridfs-stream" "^1.2.0"
-    lodash "^4.17.10"
-    stream-read "^1.1.2"
+    lodash ">=4.17.11"
+    stream-read ">=1.1.2"
 
 
 mongoose-legacy-pluralize@1.0.2:
 mongoose-legacy-pluralize@1.0.2:
   version "1.0.2"
   version "1.0.2"
@@ -5944,16 +5927,16 @@ mongoose-unique-validator@^2.0.2:
     lodash.foreach "^4.1.0"
     lodash.foreach "^4.1.0"
     lodash.get "^4.0.2"
     lodash.get "^4.0.2"
 
 
-mongoose@^5.3.1:
-  version "5.3.1"
-  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.3.1.tgz#52d5bfb67788a2194e5f7a2a5c0d597e4b86fd7a"
+mongoose@^5.4.4:
+  version "5.4.4"
+  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.4.4.tgz#923923cd39a03b970c269f828322990ea163f9a8"
+  integrity sha512-KoYUFtgrXQ9Sxuf5IEE0Y2LswWGhHiN27bxtObANxMgXbuNLTtLB3xaep8jITUDitG4kkI+FKElUJH4uY8G2Bw==
   dependencies:
   dependencies:
     async "2.6.1"
     async "2.6.1"
-    bson "~1.0.5"
+    bson "~1.1.0"
     kareem "2.3.0"
     kareem "2.3.0"
-    lodash.get "4.4.2"
-    mongodb "3.1.6"
-    mongodb-core "3.1.5"
+    mongodb "3.1.10"
+    mongodb-core "3.1.9"
     mongoose-legacy-pluralize "1.0.2"
     mongoose-legacy-pluralize "1.0.2"
     mpath "0.5.1"
     mpath "0.5.1"
     mquery "3.2.0"
     mquery "3.2.0"
@@ -6095,12 +6078,13 @@ nice-try@^1.0.4:
   version "1.0.4"
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.4.tgz#d93962f6c52f2c1558c0fbda6d512819f1efe1c4"
   resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.4.tgz#d93962f6c52f2c1558c0fbda6d512819f1efe1c4"
 
 
-nise@^1.4.5:
-  version "1.4.6"
-  resolved "https://registry.yarnpkg.com/nise/-/nise-1.4.6.tgz#76cc3915925056ae6c405dd8ad5d12bde570c19f"
+nise@^1.4.7:
+  version "1.4.8"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-1.4.8.tgz#ce91c31e86cf9b2c4cac49d7fcd7f56779bfd6b0"
+  integrity sha512-kGASVhuL4tlAV0tvA34yJYZIVihrUt/5bDwpp4tTluigxUr2bBlJeDXmivb6NuEdFkqvdv/Ybb9dm16PSKUhtw==
   dependencies:
   dependencies:
-    "@sinonjs/formatio" "3.0.0"
-    just-extend "^3.0.0"
+    "@sinonjs/formatio" "^3.1.0"
+    just-extend "^4.0.2"
     lolex "^2.3.2"
     lolex "^2.3.2"
     path-to-regexp "^1.7.0"
     path-to-regexp "^1.7.0"
     text-encoding "^0.6.4"
     text-encoding "^0.6.4"
@@ -6270,9 +6254,10 @@ nodemailer-ses-transport@~1.5.0:
   dependencies:
   dependencies:
     aws-sdk "^2.2.36"
     aws-sdk "^2.2.36"
 
 
-nodemailer@^4.0.1:
-  version "4.4.1"
-  resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.4.1.tgz#ce480eb3db7b949b3366e301b8f0af1c1248025e"
+nodemailer@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-5.1.1.tgz#0c48d1ecab02e86d9ff6c620ee75ed944b763505"
+  integrity sha512-hKGCoeNdFL2W7S76J/Oucbw0/qRlfG815tENdhzcqTpSjKgAN91mFOqU2lQUflRRxFM7iZvCyaFcAR9noc/CqQ==
 
 
 nopt@1.0.10:
 nopt@1.0.10:
   version "1.0.10"
   version "1.0.10"
@@ -8419,23 +8404,23 @@ simple-swizzle@^0.2.2:
   dependencies:
   dependencies:
     is-arrayish "^0.3.1"
     is-arrayish "^0.3.1"
 
 
-sinon-chai@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.2.0.tgz#ed995e13a8a3cfccec18f218d9b767edc47e0715"
+sinon-chai@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.3.0.tgz#8084ff99451064910fbe2c2cb8ab540c00b740ea"
+  integrity sha512-r2JhDY7gbbmh5z3Q62pNbrjxZdOAjpsqW/8yxAZRSqLZqowmfGZPGUZPFf3UX36NLis0cv8VEM5IJh9HgkSOAA==
 
 
-sinon@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-7.0.0.tgz#99f2e5198d90a01ccbcebd4dc181a24827cb90dd"
+sinon@^7.2.2:
+  version "7.2.2"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-7.2.2.tgz#388ecabd42fa93c592bfc71d35a70894d5a0ca07"
+  integrity sha512-WLagdMHiEsrRmee3jr6IIDntOF4kbI6N2pfbi8wkv50qaUQcBglkzkjtoOEbeJ2vf1EsrHhLI+5Ny8//WHdMoA==
   dependencies:
   dependencies:
-    "@sinonjs/commons" "^1.0.2"
-    "@sinonjs/formatio" "^3.0.0"
-    "@sinonjs/samsam" "^2.1.2"
+    "@sinonjs/commons" "^1.2.0"
+    "@sinonjs/formatio" "^3.1.0"
+    "@sinonjs/samsam" "^3.0.2"
     diff "^3.5.0"
     diff "^3.5.0"
-    lodash.get "^4.4.2"
     lolex "^3.0.0"
     lolex "^3.0.0"
-    nise "^1.4.5"
+    nise "^1.4.7"
     supports-color "^5.5.0"
     supports-color "^5.5.0"
-    type-detect "^4.0.8"
 
 
 slack-node@^0.1.8:
 slack-node@^0.1.8:
   version "0.1.8"
   version "0.1.8"
@@ -8696,9 +8681,10 @@ stream-http@^2.7.2:
     to-arraybuffer "^1.0.0"
     to-arraybuffer "^1.0.0"
     xtend "^4.0.0"
     xtend "^4.0.0"
 
 
-stream-read@^1.1.2:
+stream-read@>=1.1.2:
   version "1.1.2"
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/stream-read/-/stream-read-1.1.2.tgz#3137110d7aa80ba54e4b829c4cd33ca106b9564d"
   resolved "https://registry.yarnpkg.com/stream-read/-/stream-read-1.1.2.tgz#3137110d7aa80ba54e4b829c4cd33ca106b9564d"
+  integrity sha1-MTcRDXqoC6VOS4KcTNM8oQa5Vk0=
   dependencies:
   dependencies:
     dezalgo "^1.0.1"
     dezalgo "^1.0.1"
 
 
@@ -9135,7 +9121,7 @@ type-check@~0.3.2:
   dependencies:
   dependencies:
     prelude-ls "~1.1.2"
     prelude-ls "~1.1.2"
 
 
-type-detect@4.0.8, type-detect@^4.0.8:
+type-detect@4.0.8:
   version "4.0.8"
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"