Quellcode durchsuchen

Merge branch 'master' into feat/GC-1097-user-privateWiki-restriction

# Conflicts:
#	config/env.dev.js
Seiya Tashiro vor 7 Jahren
Ursprung
Commit
c173a7c2ff
66 geänderte Dateien mit 1615 neuen und 571 gelöschten Zeilen
  1. 26 2
      CHANGES.md
  2. 7 2
      README.md
  3. 1 0
      config/env.dev.js
  4. 29 0
      config/migrate.js
  5. 21 14
      package.json
  6. 1 0
      public/images/icons/editor/table.svg
  7. 13 0
      resource/certs/localhost/cert.pem
  8. 11 0
      resource/certs/localhost/csr.pem
  9. 15 0
      resource/certs/localhost/key.pem
  10. 14 2
      resource/locales/en-US/translation.json
  11. 17 5
      resource/locales/ja/translation.json
  12. 68 53
      src/client/js/app.js
  13. 16 26
      src/client/js/components/Page.js
  14. 2 2
      src/client/js/components/PageEditor/CodeMirrorEditor.js
  15. 79 21
      src/client/js/components/PageEditor/HandsontableModal.jsx
  16. 54 0
      src/client/js/components/PageEditor/HandsontableUtil.js
  17. 30 6
      src/client/js/components/PageEditor/MarkdownTableUtil.js
  18. 191 0
      src/client/js/components/RecentCreated/RecentCreated.jsx
  19. 0 19
      src/client/js/legacy/crowi.js
  20. 2 0
      src/client/js/util/GrowiRenderer.js
  21. 16 0
      src/client/js/util/markdown-it/header-with-edit-link.js
  22. 1 1
      src/client/js/util/markdown-it/table-with-handsontable-button.js
  23. 10 0
      src/client/styles/agile-admin/inverse/colors/_apply-colors.scss
  24. 13 0
      src/client/styles/scss/_handsontable.scss
  25. 12 0
      src/client/styles/scss/_mixins.scss
  26. 9 2
      src/client/styles/scss/_on-edit.scss
  27. 29 0
      src/client/styles/scss/_page.scss
  28. 1 0
      src/client/styles/scss/style.scss
  29. 47 0
      src/migrations/20180926134048-make-email-unique.js
  30. 89 0
      src/migrations/20180927102719-init-serverurl.js
  31. 3 1
      src/server/.node-dev.json
  32. 3 3
      src/server/app.js
  33. 47 11
      src/server/crowi/dev.js
  34. 4 3
      src/server/crowi/express-init.js
  35. 33 52
      src/server/crowi/index.js
  36. 2 1
      src/server/form/admin/app.js
  37. 2 1
      src/server/form/admin/customfeatures.js
  38. 0 1
      src/server/form/admin/securityPassportGitHub.js
  39. 0 1
      src/server/form/admin/securityPassportGoogle.js
  40. 8 8
      src/server/form/admin/securityPassportSaml.js
  41. 0 1
      src/server/form/admin/securityPassportTwitter.js
  42. 41 65
      src/server/models/config.js
  43. 37 26
      src/server/models/page.js
  44. 21 4
      src/server/models/user.js
  45. 3 3
      src/server/routes/attachment.js
  46. 3 3
      src/server/routes/hackmd.js
  47. 1 0
      src/server/routes/index.js
  48. 28 11
      src/server/routes/login-passport.js
  49. 1 1
      src/server/routes/login.js
  50. 33 1
      src/server/routes/page.js
  51. 13 4
      src/server/service/passport.js
  52. 2 2
      src/server/util/googleAuth.js
  53. 6 6
      src/server/util/slack.js
  54. 9 1
      src/server/views/admin/app.html
  55. 15 0
      src/server/views/admin/customize.html
  56. 10 10
      src/server/views/admin/widget/passport/github.html
  57. 10 10
      src/server/views/admin/widget/passport/google-oauth.html
  58. 57 18
      src/server/views/admin/widget/passport/saml.html
  59. 10 8
      src/server/views/admin/widget/passport/twitter.html
  60. 1 1
      src/server/views/modal/create_page.html
  61. 1 1
      src/server/views/modal/duplicate.html
  62. 1 1
      src/server/views/modal/rename.html
  63. 0 5
      src/server/views/widget/user_page_content.html
  64. 5 5
      src/test/crowi/crowi.test.js
  65. 2 2
      src/test/utils.js
  66. 379 145
      yarn.lock

+ 26 - 2
CHANGES.md

@@ -1,9 +1,33 @@
 CHANGES
 CHANGES
 ========
 ========
 
 
-## 3.2.4-RC
+## 3.2.5-RC
 
 
-* 
+* Improvement: Prevent XSS of New Page modal
+* Fix: Recent Created tab of user home shows wrong page list
+    * Introduced by 3.2.4
+* Support: Upgrade libs
+    * metismenu
+    * sinon
+
+## 3.2.4
+
+* Feature: Edit table with Spreadsheet like GUI (Handsontable)
+* Feature: Paging recent created in users home
+* Improvement: Specify certificate for SAML Authentication
+* Fix: SAML Authentication didn't work
+    * Introduced by 3.2.2
+* Fix: Failed to create new page with title which includes RegEx special characters
+* Fix: Preventing XSS Settings are not applied in default
+    * Introduced by 3.1.12
+* Support: Mongoose migration mechanism
+* Support: Upgrade libs
+    * googleapis
+    * mocha
+    * mongoose
+    * mongoose-paginate
+    * mongoose-unique-validator
+    * multer
 
 
 ## 3.2.3
 ## 3.2.3
 
 

+ 7 - 2
README.md

@@ -173,10 +173,15 @@ Environment Variables
 * **Option (Overwritable in admin page)**
 * **Option (Overwritable in admin page)**
     * 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_GOOGLE_CALLBACK_URI: Google API callback URI for OAuth login (Set `https://${growi.host}/passport/google/callback`).
     * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
     * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
     * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
     * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
-    * OAUTH_GITHUB_CALLBACK_URI: GitHub API callback URI for OAuth login (Set `https://${growi.host}/passport/github/callback`).
+    * OAUTH_TWITTER_CLIENT_ID: Twitter API client id for OAuth login.
+    * OAUTH_TWITTER_CLIENT_SECRET: Twitter API client secret for OAuth login.
+    * OAUTH_TWITTER_CLIENT_ID: Twitter API client id for OAuth login.
+    * OAUTH_TWITTER_CLIENT_SECRET: Twitter API client secret for OAuth login.
+    * SAML_ENTRY_POINT: IdP entry point
+    * SAML_ISSUER: Issuer string to supply to IdP
+    * SAML_CERT: PEM-encoded X.509 signing certificate string to validate the response from IdP
 
 
 
 
 Documentation
 Documentation

+ 1 - 0
config/env.dev.js

@@ -10,4 +10,5 @@ module.exports = {
   ],
   ],
   USER_UPPER_LIMIT: 0,
   USER_UPPER_LIMIT: 0,
   IS_PRIVATE_WIKI_ENABLED: true,
   IS_PRIVATE_WIKI_ENABLED: true,
+  // DEV_HTTPS: true,
 };
 };

+ 29 - 0
config/migrate.js

@@ -0,0 +1,29 @@
+/**
+ * Configuration file for migrate-mongo
+ * @see https://github.com/seppevs/migrate-mongo
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ */
+
+function getMongoUri(env) {
+  return env.MONGOLAB_URI || // for B.C.
+    env.MONGODB_URI || // MONGOLAB changes their env name
+    env.MONGOHQ_URL ||
+    env.MONGO_URI ||
+    ((env.NODE_ENV === 'test') ? 'mongodb://localhost/growi_test' : 'mongodb://localhost/growi');
+}
+
+const mongoUri = getMongoUri(process.env);
+const match = mongoUri.match(/^(.+)\/([^/]+)$/);
+module.exports = {
+  mongoUri,
+  mongodb: {
+    url: match[1],
+    databaseName: match[2],
+    options: {
+      useNewUrlParser: true, // removes a deprecation warning when connecting
+    },
+  },
+  migrationsDir: 'src/migrations/',
+  changelogCollectionName: 'migrations'
+};

+ 21 - 14
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "3.2.4-RC",
+  "version": "3.2.5-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -37,10 +37,16 @@
     "heroku-postbuild": "sh bin/heroku/install-plugins.sh && npm run build:prod",
     "heroku-postbuild": "sh bin/heroku/install-plugins.sh && npm run build:prod",
     "lint:fix": "eslint . --fix",
     "lint:fix": "eslint . --fix",
     "lint": "eslint .",
     "lint": "eslint .",
+    "migrate": "npm run migrate:up",
+    "migrate:create": "migrate-mongo create -f config/migrate.js -- ",
+    "migrate:status": "migrate-mongo status -f config/migrate.js",
+    "migrate:up": "migrate-mongo up -f config/migrate.js",
+    "migrate:down": "migrate-mongo down -f config/migrate.js",
     "mkdirp": "mkdirp",
     "mkdirp": "mkdirp",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
     "prebuild:dev:app": "env-cmd config/env.dev.js npm run plugin:def",
     "prebuild:dev:app": "env-cmd config/env.dev.js npm run plugin:def",
     "prebuild:prod": "npm run plugin:def",
     "prebuild:prod": "npm run plugin:def",
+    "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
     "prestart": "npm run build:prod",
     "server:debug": "env-cmd config/env.dev.js node-dev --inspect src/server/app.js",
     "server:debug": "env-cmd config/env.dev.js node-dev --inspect src/server/app.js",
     "server:dev": "env-cmd config/env.dev.js node-dev --respawn src/server/app.js",
     "server:dev": "env-cmd config/env.dev.js node-dev --respawn src/server/app.js",
@@ -48,7 +54,7 @@
     "server:prod": "env-cmd config/env.prod.js node src/server/app.js",
     "server:prod": "env-cmd config/env.prod.js node src/server/app.js",
     "server": "npm run server:dev",
     "server": "npm run server:dev",
     "start": "npm run server:prod",
     "start": "npm run server:prod",
-    "test": "mocha --timeout 10000 -r src/test/bootstrap.js src/test/**/*.js",
+    "test": "mocha --timeout 10000 --exit -r src/test/bootstrap.js src/test/**/*.js",
     "version": "node -p \"require('./package.json').version\"",
     "version": "node -p \"require('./package.json').version\"",
     "webpack": "webpack"
     "webpack": "webpack"
   },
   },
@@ -79,7 +85,7 @@
     "express-sanitizer": "^1.0.4",
     "express-sanitizer": "^1.0.4",
     "express-session": "~1.15.0",
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^33.0.0",
+    "googleapis": "^34.0.0",
     "graceful-fs": "^4.1.11",
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "growi-pluginkit": "^1.1.0",
     "helmet": "^3.13.0",
     "helmet": "^3.13.0",
@@ -90,12 +96,13 @@
     "markdown-it-blockdiag": "^1.0.2",
     "markdown-it-blockdiag": "^1.0.2",
     "md5": "^2.2.1",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
     "method-override": "^3.0.0",
+    "migrate-mongo": "^4.0.0",
     "mkdirp": "~0.5.1",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
     "module-alias": "^2.0.6",
-    "mongoose": "^5.0.0",
-    "mongoose-paginate": "^5.0.0",
-    "mongoose-unique-validator": "^2.0.0",
-    "multer": "~1.3.0",
+    "mongoose": "^5.2.0",
+    "mongoose-paginate": "^5.0.3",
+    "mongoose-unique-validator": "^2.0.2",
+    "multer": "~1.4.0",
     "nodemailer": "^4.0.1",
     "nodemailer": "^4.0.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",
@@ -163,8 +170,8 @@
     "markdown-it-task-lists": "^2.1.0",
     "markdown-it-task-lists": "^2.1.0",
     "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",
-    "metismenu": "^2.7.4",
-    "mocha": "^5.0.0",
+    "metismenu": "^3.0.3",
+    "mocha": "^5.2.0",
     "morgan": "^1.9.0",
     "morgan": "^1.9.0",
     "node-dev": "^3.1.3",
     "node-dev": "^3.1.3",
     "node-sass": "^4.5.0",
     "node-sass": "^4.5.0",
@@ -182,23 +189,23 @@
     "react-clipboard.js": "^2.0.0",
     "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^5.0.4",
     "react-codemirror2": "^5.0.4",
     "react-dom": "^16.4.1",
     "react-dom": "^16.4.1",
-    "react-dropzone": "^5.0.1",
+    "react-dropzone": "^6.0.2",
     "react-frame-component": "^4.0.0",
     "react-frame-component": "^4.0.0",
-    "react-i18next": "^7.6.1",
+    "react-i18next": "=7.13.0",
     "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": "^6.0.0",
+    "sinon": "^7.0.0",
     "sinon-chai": "^3.2.0",
     "sinon-chai": "^3.2.0",
     "socket.io-client": "^2.0.3",
     "socket.io-client": "^2.0.3",
     "style-loader": "^0.23.0",
     "style-loader": "^0.23.0",
     "throttle-debounce": "^2.0.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
     "toastr": "^2.1.2",
-    "uglifyjs-webpack-plugin": "^1.2.5",
+    "uglifyjs-webpack-plugin": "^2.0.1",
     "url-join": "^4.0.0",
     "url-join": "^4.0.0",
     "webpack": "^4.12.0",
     "webpack": "^4.12.0",
     "webpack-assets-manifest": "^3.0.1",
     "webpack-assets-manifest": "^3.0.1",
-    "webpack-bundle-analyzer": "^2.9.0",
+    "webpack-bundle-analyzer": "^3.0.2",
     "webpack-cli": "^3.0.8",
     "webpack-cli": "^3.0.8",
     "webpack-merge": "~4.1.0"
     "webpack-merge": "~4.1.0"
   },
   },

+ 1 - 0
public/images/icons/editor/table.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M0 19.7v216.6h256V19.7H0zm78.8 196.9H19.7v-39.4h59.1v39.4zm0-59.1H19.7v-39.3h59.1v39.3zm0-59H19.7V59.1h59.1v39.4zm78.7 118.1h-59v-39.4h59v39.4zm0-59.1h-59v-39.3h59v39.3zm0-59h-59V59.1h59v39.4zm78.8 118.1h-59.1v-39.4h59.1v39.4zm0-59.1h-59.1v-39.3h59.1v39.3zm0-59h-59.1V59.1h59.1v39.4z"/></svg>

+ 13 - 0
resource/certs/localhost/cert.pem

@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIICBzCCAXACCQD4US7+0A/b/zANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJK
+UDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAoMDFdFU0VFSywgSW5jLjESMBAGA1UE
+AwwJbG9jYWxob3N0MB4XDTE4MDkxMjEwMjIzNFoXDTE4MTAxMjEwMjIzNFowSDEL
+MAkGA1UEBhMCSlAxDjAMBgNVBAgMBVRva3lvMRUwEwYDVQQKDAxXRVNFRUssIElu
+Yy4xEjAQBgNVBAMMCWxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkC
+gYEAy0FKOlCQynbo5wYyMCL5LW27Re9dB14wXPDT+zd7wDdYrCbAKKZ1r+7Sj1e7
+638lnn7n4WkhkgsQi/mTxF7W9PHYF00Dh2X0qGf9t+LocNeLVQBHMGNi7HXh8X3j
+iM7w9FffdlfBvuYxPIdDXP12x9JmRhr59Tpv1aaMcRxAY1cCAwEAATANBgkqhkiG
+9w0BAQsFAAOBgQBa/PwnEeFCQ5G4SS6IcL6QVh3KLfeVMCfYVk1o0iJVmJTvfdrq
+crmVwBzbloUO2l6k1ibwD2WVwpdxMKIF5z58HfKAvxZAzCHE7kMEZr1ge30WRXQA
+pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
+-----END CERTIFICATE-----

+ 11 - 0
resource/certs/localhost/csr.pem

@@ -0,0 +1,11 @@
+-----BEGIN CERTIFICATE REQUEST-----
+MIIBhzCB8QIBADBIMQswCQYDVQQGEwJKUDEOMAwGA1UECAwFVG9reW8xFTATBgNV
+BAoMDFdFU0VFSywgSW5jLjESMBAGA1UEAwwJbG9jYWxob3N0MIGfMA0GCSqGSIb3
+DQEBAQUAA4GNADCBiQKBgQDLQUo6UJDKdujnBjIwIvktbbtF710HXjBc8NP7N3vA
+N1isJsAopnWv7tKPV7vrfyWefufhaSGSCxCL+ZPEXtb08dgXTQOHZfSoZ/234uhw
+14tVAEcwY2LsdeHxfeOIzvD0V992V8G+5jE8h0Nc/XbH0mZGGvn1Om/VpoxxHEBj
+VwIDAQABoAAwDQYJKoZIhvcNAQELBQADgYEAd49hz4IoQO55tr62OFlZr254ZPBX
+SXxCtSWawBWLFij8QLl1B8JkHARrMKdM1jBCy5UXcH05DrxGwIOXIcRW7mfrIQDH
+pGs+BQCHMHuYnssg/z2aDhafkmPaLBwh0KWPypVIStxUwLcKxA1xk5VBoP/q+Lgk
+h/mCVJ7JY40BlLA=
+-----END CERTIFICATE REQUEST-----

+ 15 - 0
resource/certs/localhost/key.pem

@@ -0,0 +1,15 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIICXAIBAAKBgQDLQUo6UJDKdujnBjIwIvktbbtF710HXjBc8NP7N3vAN1isJsAo
+pnWv7tKPV7vrfyWefufhaSGSCxCL+ZPEXtb08dgXTQOHZfSoZ/234uhw14tVAEcw
+Y2LsdeHxfeOIzvD0V992V8G+5jE8h0Nc/XbH0mZGGvn1Om/VpoxxHEBjVwIDAQAB
+AoGALsf8OafJa5Aq0uGOM54ZE+eprtME6mk3YGzdnXiLtxYGBrl0iOanN7MUK4HZ
+8r30/qHe5Pa5j0+Uo2LyK8RYXOcT77CeSSQiSGlBgGj7US7ZmyTqsOwaUKVnbFcy
+Bf/bTJl4EjZREy7kdfCWVO1yY98tV4XrZ2CBDSEDyI8UiiECQQDw47YBMy2duuS6
+z5Ui0xRr8MYwRlCNOt6xKQxlKEhnnRA0vdNB6VMiSzvZ9Bt5nPWI/B3ugUupo6Hn
+FXi1tOgTAkEA2AE7RTICcGPpogOtq/5g7pPNofH524hN26qtUT2kKjUy316JYjrU
+t+N6Ck867w4juVbDcVOTB2Nbj+2+t2EILQJAfo1CyvKWHm1XSQVRNlBqRCLkG+x0
+2R16bNxB1MsK7tRG9U5ctB3ePQAFW4WxAX0CSYsaNnjaxS5gGkTfe6ak3QJAWVlh
+EAVYtu7NRKQq4btOk0F2TOfQB7xBIH1gRfuufXsV+Qmc4JIfTZV99OfDJAGAS3kV
+TTpZ1jOGO2oHeslbXQJBAM4xX8hUueQMIllpBNjlAx1xTqptOHa4elaaPZi7HcDj
+olRU0OP/wPOoEJRvHGP8+LAerx5CEYbadnukQAnNPLA=
+-----END RSA PRIVATE KEY-----

+ 14 - 2
resource/locales/en-US/translation.json

@@ -260,6 +260,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",
+    "siteurl_help": "Site full URL beginning from <code>http://</code> or <code>https://</code>.",
     "Confidential name": "Confidential name",
     "Confidential name": "Confidential name",
     "ex): internal use only":"ex): internal use only",
     "ex): internal use only":"ex): internal use only",
     "enable_files_except_image": "Enable file upload other than image files.",
     "enable_files_except_image": "Enable file upload other than image files.",
@@ -308,6 +310,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",
     "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",
@@ -331,6 +334,7 @@
     "xss_prevent_setting":"Prevent XSS(Cross Site Scripting)",
     "xss_prevent_setting":"Prevent XSS(Cross Site Scripting)",
     "xss_prevent_setting_link":"Go to Markdown settings",
     "xss_prevent_setting_link":"Go to Markdown settings",
     "callback_URL": "Callback URL",
     "callback_URL": "Callback URL",
+    "desc_of_callback_URL": "Use it in the setting of the %s provider",
     "guest_mode": {
     "guest_mode": {
       "deny": "Deny Unregistered Users",
       "deny": "Deny Unregistered Users",
       "readonly": "View Only"
       "readonly": "View Only"
@@ -379,7 +383,13 @@
       "name": "SAML",
       "name": "SAML",
       "entry_point": "Entry Point",
       "entry_point": "Entry Point",
       "issuer": "Issuer",
       "issuer": "Issuer",
-      "mapping_detail": "Specification of mappings for %s when creating new users"
+      "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",
+      "username_detail": "Specification of mappings for <code>username</code> 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"
     },
     },
     "OAuth": {
     "OAuth": {
       "register": "Register for %s",
       "register": "Register for %s",
@@ -487,7 +497,9 @@
     "Custom script": "Custom script",
     "Custom script": "Custom script",
     "write_java": "You can write Javascript that is applied to whole system.",
     "write_java": "You can write Javascript that is applied to whole system.",
     "attach_title_header": "Add h1 section when create new page automatically",
     "attach_title_header": "Add h1 section when create new page automatically",
-    "attach_title_header_desc": "Add page path to the first line as h1 section when create new page"
+    "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
+    "show_document_number": "Manage the number of documents to be displayed",
+    "show_document_number_desc": "Set the number of items to display on one page in Recent Created on the home screen"
   },
   },
 
 
   "user_management": {
   "user_management": {

+ 17 - 5
resource/locales/ja/translation.json

@@ -278,6 +278,8 @@
     "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。",
     "Confidential name": "コンフィデンシャル表示",
     "Confidential name": "コンフィデンシャル表示",
     "ex): internal use only": "例: 社外秘",
     "ex): internal use only": "例: 社外秘",
     "enable_files_except_image": "画像以外のファイルアップロードを許可",
     "enable_files_except_image": "画像以外のファイルアップロードを許可",
@@ -327,6 +329,7 @@
     "auth_mechanism": "認証機構",
     "auth_mechanism": "認証機構",
     "recommended": "推奨",
     "recommended": "推奨",
     "username_email_password": "ユーザー名、Eメール、パスワードでの認証",
     "username_email_password": "ユーザー名、Eメール、パスワードでの認証",
+    "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。%s から設定してください。",
     "ldap_auth": "LDAP 認証",
     "ldap_auth": "LDAP 認証",
     "saml_auth": "SAML 認証",
     "saml_auth": "SAML 認証",
     "google_auth2": "Google OAuth 認証",
     "google_auth2": "Google OAuth 認証",
@@ -349,6 +352,7 @@
     "xss_prevent_setting":"XSS(Cross Site Scripting)対策設定",
     "xss_prevent_setting":"XSS(Cross Site Scripting)対策設定",
     "xss_prevent_setting_link":"マークダウン設定ページに移動",
     "xss_prevent_setting_link":"マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
     "callback_URL": "コールバックURL",
+    "desc_of_callback_URL": "%s プロバイダ側の設定で利用してください。",
     "guest_mode": {
     "guest_mode": {
       "deny": "アカウントを持たないユーザーはアクセス不可",
       "deny": "アカウントを持たないユーザーはアクセス不可",
       "readonly": "閲覧のみ許可"
       "readonly": "閲覧のみ許可"
@@ -397,17 +401,23 @@
       "name": "SAML",
       "name": "SAML",
       "entry_point": "エントリーポイント",
       "entry_point": "エントリーポイント",
       "issuer": "発行者",
       "issuer": "発行者",
-      "mapping_detail": "新規ユーザーの%sに関連付ける属性"
+      "First Name": "姓",
+      "Last Name": "名",
+      "id_detail": "SAML Identity プロバイダ内で一意に識別可能な値を格納している属性",
+      "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
+      "mapping_detail": "新規ユーザーの%sに関連付ける属性",
+      "cert_detail1": "IdP からのレスポンスの validation を行うための、PEMエンコードされた X.509 証明書",
+      "cert_detail2": "空の場合は環境変数 <code>SAML_CERT</code> を利用し、そちらも存在しない場合は validation 自体を行いません"
     },
     },
     "OAuth": {
     "OAuth": {
       "register": "%sに登録",
       "register": "%sに登録",
-      "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力<br>(<code>%s</code>は環境に合わせて変更してください)",
+      "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力",
       "Google": {
       "Google": {
         "name": "Google OAuth",
         "name": "Google OAuth",
         "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
         "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
         "register_2": "プロジェクトがない場合はプロジェクトを作成",
         "register_2": "プロジェクトがない場合はプロジェクトを作成",
         "register_3": "認証情報を作成 &rightarrow; OAuthクライアントID &rightarrow; ウェブアプリケーションを選択",
         "register_3": "認証情報を作成 &rightarrow; OAuthクライアントID &rightarrow; ウェブアプリケーションを選択",
-        "register_4": "承認済みのリダイレクトURIを<code>%s</code>としてGrowiを登録 (<code>%s</code>は環境に合わせて変更してください)",
+        "register_4": "承認済みのリダイレクトURIを<code>%s</code>としてGrowiを登録",
         "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力"
         "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力"
       },
       },
       "Facebook": {
       "Facebook": {
@@ -424,7 +434,7 @@
       "GitHub": {
       "GitHub": {
         "name": "GitHub OAuth",
         "name": "GitHub OAuth",
         "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
         "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
-        "register_2": "\"Authorization callback URL\"を<code>%s</code>としてGrowiを登録 (<code>%s</code>は環境に合わせて変更してください)",
+        "register_2": "\"Authorization callback URL\"を<code>%s</code>としてGrowiを登録",
         "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力"
         "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力"
       },
       },
       "how_to": {
       "how_to": {
@@ -504,7 +514,9 @@
     "Custom script": "カスタムスクリプト",
     "Custom script": "カスタムスクリプト",
     "write_java": "システム全体に適用されるJavaScriptを記述できます。",
     "write_java": "システム全体に適用されるJavaScriptを記述できます。",
     "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
     "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
-    "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。"
+    "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
+    "show_document_number": "表示ドキュメント数管理",
+    "show_document_number_desc": "ホーム画面の Recent Created で、1ページに表示する件数を設定します。"
   },
   },
 
 
   "user_management": {
   "user_management": {

+ 68 - 53
src/client/js/app.js

@@ -33,6 +33,7 @@ import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import BookmarkButton   from './components/BookmarkButton';
 import BookmarkButton   from './components/BookmarkButton';
 import NewPageNameInput from './components/NewPageNameInput';
 import NewPageNameInput from './components/NewPageNameInput';
+import RecentCreated from './components/RecentCreated/RecentCreated';
 
 
 import CustomCssEditor  from './components/Admin/CustomCssEditor';
 import CustomCssEditor  from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
@@ -110,59 +111,11 @@ if (isEnabledPlugins) {
   crowiPlugin.installAll(crowi, crowiRenderer);
   crowiPlugin.installAll(crowi, crowiRenderer);
 }
 }
 
 
-// setup renderer after plugins are installed
-crowiRenderer.setup();
-
-// restore draft when the first time to edit
-const draft = crowi.findDraft(pagePath);
-if (!pageRevisionId && draft != null) {
-  markdown = draft;
-}
-
 /**
 /**
- * define components
- *  key: id of element
- *  value: React Element
+ * component store
  */
  */
-const componentMappings = {
-  'search-top': <HeaderSearchBox crowi={crowi} />,
-  'search-sidebar': <HeaderSearchBox crowi={crowi} />,
-  'search-page': <SearchPage crowi={crowi} crowiRenderer={crowiRenderer} />,
-  'page-list-search': <PageListSearch crowi={crowi} />,
-
-  //'revision-history': <PageHistory pageId={pageId} />,
-  'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,
-  'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
-  'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
-
-  'page-name-input': <NewPageNameInput crowi={crowi} parentPageName={pagePath} />,
-
-};
-// additional definitions if data exists
-if (pageId) {
-  componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} crowiOriginRenderer={crowiRenderer} />;
-  componentMappings['page-attachment'] = <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />;
-}
-if (pagePath) {
-  componentMappings['page'] = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} showHeadEditButton={true} />;
-  componentMappings['revision-path'] = <RevisionPath pagePath={pagePath} crowi={crowi} />;
-  componentMappings['revision-url'] = <RevisionUrl pageId={pageId} pagePath={pagePath} />;
-}
-
 let componentInstances = {};
 let componentInstances = {};
 
 
-Object.keys(componentMappings).forEach((key) => {
-  const elem = document.getElementById(key);
-  if (elem) {
-    componentInstances[key] = ReactDOM.render(componentMappings[key], elem);
-  }
-});
-
-// set page if exists
-if (componentInstances['page'] != null) {
-  crowi.setPage(componentInstances['page']);
-}
-
 /**
 /**
  * save success handler when reloading is not needed
  * save success handler when reloading is not needed
  * @param {object} page Page instance
  * @param {object} page Page instance
@@ -231,10 +184,6 @@ const errorHandler = function(error) {
 
 
 const saveWithShortcut = function(markdown) {
 const saveWithShortcut = function(markdown) {
   const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
   const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
-  if (editorMode == null) {
-    // do nothing
-    return;
-  }
 
 
   let revisionId = pageRevisionId;
   let revisionId = pageRevisionId;
   // get options
   // get options
@@ -308,6 +257,57 @@ const saveWithSubmitButton = function() {
     .catch(errorHandler);
     .catch(errorHandler);
 };
 };
 
 
+// setup renderer after plugins are installed
+crowiRenderer.setup();
+
+// restore draft when the first time to edit
+const draft = crowi.findDraft(pagePath);
+if (!pageRevisionId && draft != null) {
+  markdown = draft;
+}
+
+/**
+ * define components
+ *  key: id of element
+ *  value: React Element
+ */
+const componentMappings = {
+  'search-top': <HeaderSearchBox crowi={crowi} />,
+  'search-sidebar': <HeaderSearchBox crowi={crowi} />,
+  'search-page': <SearchPage crowi={crowi} crowiRenderer={crowiRenderer} />,
+  'page-list-search': <PageListSearch crowi={crowi} />,
+
+  //'revision-history': <PageHistory pageId={pageId} />,
+  'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,
+  'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
+  'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
+
+  'page-name-input': <NewPageNameInput crowi={crowi} parentPageName={pagePath} />,
+
+};
+// additional definitions if data exists
+if (pageId) {
+  componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} crowiOriginRenderer={crowiRenderer} />;
+  componentMappings['page-attachment'] = <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />;
+}
+if (pagePath) {
+  componentMappings['page'] = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} showHeadEditButton={true} onSaveWithShortcut={saveWithShortcut} />;
+  componentMappings['revision-path'] = <RevisionPath pagePath={pagePath} crowi={crowi} />;
+  componentMappings['revision-url'] = <RevisionUrl pageId={pageId} pagePath={pagePath} />;
+}
+
+Object.keys(componentMappings).forEach((key) => {
+  const elem = document.getElementById(key);
+  if (elem) {
+    componentInstances[key] = ReactDOM.render(componentMappings[key], elem);
+  }
+});
+
+// set page if exists
+if (componentInstances['page'] != null) {
+  crowi.setPage(componentInstances['page']);
+}
+
 // render SavePageControls
 // render SavePageControls
 let savePageControls = null;
 let savePageControls = null;
 const savePageControlsElem = document.getElementById('save-page-controls');
 const savePageControlsElem = document.getElementById('save-page-controls');
@@ -331,6 +331,21 @@ if (savePageControlsElem) {
   componentInstances.savePageControls = savePageControls;
   componentInstances.savePageControls = savePageControls;
 }
 }
 
 
+// RecentCreated dev GC-939 start
+const recentCreatedControlsElem = document.getElementById('user-created-list');
+if (recentCreatedControlsElem) {
+  let limit = crowi.getConfig().recentCreatedLimit;
+  if (null == limit) {
+    limit = 10;
+  }
+  ReactDOM.render(
+    <RecentCreated  crowi={crowi} pageId={pageId} limit={limit} >
+
+    </RecentCreated>, document.getElementById('user-created-list')
+  );
+}
+// RecentCreated dev GC-939 end
+
 /*
 /*
  * HackMD Editor
  * HackMD Editor
  */
  */

+ 16 - 26
src/client/js/components/Page.js

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
 import RevisionBody from './Page/RevisionBody';
 import RevisionBody from './Page/RevisionBody';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import MarkdownTable from '../models/MarkdownTable';
 import MarkdownTable from '../models/MarkdownTable';
+import mtu from './PageEditor/MarkdownTableUtil';
 
 
 export default class Page extends React.Component {
 export default class Page extends React.Component {
 
 
@@ -11,43 +12,24 @@ export default class Page extends React.Component {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-      html: ''
+      html: '',
+      markdown: '',
+      currentTargetTableArea: null
     };
     };
 
 
-    this.appendEditSectionButtons = this.appendEditSectionButtons.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
     this.getHighlightedBody = this.getHighlightedBody.bind(this);
     this.getHighlightedBody = this.getHighlightedBody.bind(this);
+    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
   }
   }
 
 
   componentWillMount() {
   componentWillMount() {
     this.renderHtml(this.props.markdown, this.props.highlightKeywords);
     this.renderHtml(this.props.markdown, this.props.highlightKeywords);
   }
   }
 
 
-  componentDidUpdate() {
-    this.appendEditSectionButtons();
-  }
-
-  componentWillReceiveProps(nextProps) {
-    this.renderHtml(nextProps.markdown, nextProps.highlightKeywords);
-  }
-
   setMarkdown(markdown) {
   setMarkdown(markdown) {
     this.renderHtml(markdown, this.props.highlightKeywords);
     this.renderHtml(markdown, this.props.highlightKeywords);
   }
   }
 
 
-  /**
-   * Add edit section buttons to headers
-   * This invoke `appendEditSectionButtons` method of `legacy/crowi.js`
-   *
-   * TODO: transplant `appendEditSectionButtons` to this class in the future
-   */
-  appendEditSectionButtons(parentElement) {
-    if (this.props.showHeadEditButton) {
-      const crowiForJquery = this.props.crowi.getCrowiForJquery();
-      crowiForJquery.appendEditSectionButtons(this.revisionBodyElement);
-    }
-  }
-
   /**
   /**
    * transplanted from legacy code -- Yuki Takei
    * transplanted from legacy code -- Yuki Takei
    * @param {string} body html strings
    * @param {string} body html strings
@@ -76,10 +58,17 @@ export default class Page extends React.Component {
    * @param endLineNumber
    * @param endLineNumber
    */
    */
   launchHandsontableModal(beginLineNumber, endLineNumber) {
   launchHandsontableModal(beginLineNumber, endLineNumber) {
-    const tableLines = this.props.markdown.split('\n').slice(beginLineNumber - 1, endLineNumber).join('\n');
+    const tableLines = this.state.markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
+    this.setState({currentTargetTableArea: {beginLineNumber, endLineNumber}});
     this.refs.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
     this.refs.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
   }
   }
 
 
+  saveHandlerForHandsontableModal(markdownTable) {
+    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(markdownTable, this.state.markdown, this.state.currentTargetTableArea.beginLineNumber, this.state.currentTargetTableArea.endLineNumber);
+    this.props.onSaveWithShortcut(newMarkdown);
+    this.setState({currentTargetTableArea: null});
+  }
+
   renderHtml(markdown, highlightKeywords) {
   renderHtml(markdown, highlightKeywords) {
     let context = {
     let context = {
       markdown,
       markdown,
@@ -110,7 +99,7 @@ export default class Page extends React.Component {
       .then(() => interceptorManager.process('postPostProcess', context))
       .then(() => interceptorManager.process('postPostProcess', context))
       .then(() => interceptorManager.process('preRenderHtml', context))
       .then(() => interceptorManager.process('preRenderHtml', context))
       .then(() => {
       .then(() => {
-        this.setState({ html: context.parsedHTML });
+        this.setState({ html: context.parsedHTML, markdown });
       })
       })
       // process interceptors for post rendering
       // process interceptors for post rendering
       .then(() => interceptorManager.process('postRenderHtml', context));
       .then(() => interceptorManager.process('postRenderHtml', context));
@@ -129,7 +118,7 @@ export default class Page extends React.Component {
           isMathJaxEnabled={isMathJaxEnabled}
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={true}
           renderMathJaxOnInit={true}
       />
       />
-      <HandsontableModal ref='handsontableModal' />
+      <HandsontableModal ref='handsontableModal' onSave={this.saveHandlerForHandsontableModal} />
     </div>;
     </div>;
   }
   }
 }
 }
@@ -137,6 +126,7 @@ export default class Page extends React.Component {
 Page.propTypes = {
 Page.propTypes = {
   crowi: PropTypes.object.isRequired,
   crowi: PropTypes.object.isRequired,
   crowiRenderer: PropTypes.object.isRequired,
   crowiRenderer: PropTypes.object.isRequired,
+  onSaveWithShortcut: PropTypes.func.isRequired,
   markdown: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   showHeadEditButton: PropTypes.bool,
   showHeadEditButton: PropTypes.bool,

+ 2 - 2
src/client/js/components/PageEditor/CodeMirrorEditor.js

@@ -650,7 +650,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
   }
 
 
   getNavbarItems() {
   getNavbarItems() {
-    return <Button bsSize="small" onClick={ this.showHandsonTableHandler }><i className="icon-grid"></i></Button>;
+    return <Button bsSize="small" onClick={ this.showHandsonTableHandler }><img src="/images/icons/editor/table.svg" width="14" /></Button>;
   }
   }
 
 
   render() {
   render() {
@@ -730,7 +730,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         { this.state.isCheatsheetModalButtonShown && this.renderCheatsheetModalButton() }
         { this.state.isCheatsheetModalButtonShown && this.renderCheatsheetModalButton() }
       </div>
       </div>
 
 
-      <HandsontableModal ref='handsontableModal' onSave={ table => mtu.replaceMarkdownTable(this.getCodeMirror(), table) }/>
+      <HandsontableModal ref='handsontableModal' onSave={ table => mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }/>
     </React.Fragment>;
     </React.Fragment>;
   }
   }
 
 

+ 79 - 21
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -4,9 +4,11 @@ import PropTypes from 'prop-types';
 import Modal from 'react-bootstrap/es/Modal';
 import Modal from 'react-bootstrap/es/Modal';
 import Button from 'react-bootstrap/es/Button';
 import Button from 'react-bootstrap/es/Button';
 
 
+import Handsontable from 'handsontable';
 import { HotTable } from '@handsontable/react';
 import { HotTable } from '@handsontable/react';
 
 
 import MarkdownTable from '../../models/MarkdownTable';
 import MarkdownTable from '../../models/MarkdownTable';
+import HandsontableUtil from './HandsontableUtil';
 
 
 export default class HandsontableModal extends React.Component {
 export default class HandsontableModal extends React.Component {
   constructor(props) {
   constructor(props) {
@@ -15,17 +17,8 @@ export default class HandsontableModal extends React.Component {
     this.state = {
     this.state = {
       show: false,
       show: false,
       markdownTableOnInit: HandsontableModal.getDefaultMarkdownTable(),
       markdownTableOnInit: HandsontableModal.getDefaultMarkdownTable(),
-      markdownTable: HandsontableModal.getDefaultMarkdownTable()
-    };
-
-    this.settings = {
-      height: 300,
-      rowHeaders: true,
-      colHeaders: true,
-      fixedRowsTop: [0, 1],
-      contextMenu: ['row_above', 'row_below', 'col_left', 'col_right', '---------', 'remove_row', 'remove_col', '---------', 'alignment'],
-      stretchH: 'all',
-      selectionMode: 'multiple',
+      markdownTable: HandsontableModal.getDefaultMarkdownTable(),
+      handsontableSetting: HandsontableModal.getDefaultHandsotableSetting()
     };
     };
 
 
     this.init = this.init.bind(this);
     this.init = this.init.bind(this);
@@ -36,8 +29,16 @@ export default class HandsontableModal extends React.Component {
 
 
   init(markdownTable) {
   init(markdownTable) {
     const initMarkdownTable = markdownTable || HandsontableModal.getDefaultMarkdownTable();
     const initMarkdownTable = markdownTable || HandsontableModal.getDefaultMarkdownTable();
-    this.setState({ markdownTableOnInit: initMarkdownTable });
-    this.setState({ markdownTable: initMarkdownTable.clone() });
+    this.setState(
+      {
+        markdownTableOnInit: initMarkdownTable,
+        markdownTable: initMarkdownTable.clone(),
+        handsontableSetting: Object.assign({}, this.state.handsontableSetting, {
+          afterUpdateSettings: HandsontableUtil.createHandlerToSynchronizeHandontableAlignWith(initMarkdownTable.options.align),
+          loadData: HandsontableUtil.createHandlerToSynchronizeHandontableAlignWith(initMarkdownTable.options.align)
+        })
+      }
+    );
   }
   }
 
 
   show(markdownTable) {
   show(markdownTable) {
@@ -54,21 +55,25 @@ export default class HandsontableModal extends React.Component {
   }
   }
 
 
   save() {
   save() {
+    let newMarkdownTable = this.state.markdownTable.clone();
+    newMarkdownTable.options.align = HandsontableUtil.getMarkdownTableAlignmentFrom(this.refs.hotTable.hotInstance);
+
     if (this.props.onSave != null) {
     if (this.props.onSave != null) {
-      this.props.onSave(this.state.markdownTable);
+      this.props.onSave(newMarkdownTable);
     }
     }
+
     this.setState({ show: false });
     this.setState({ show: false });
   }
   }
 
 
   render() {
   render() {
     return (
     return (
-      <Modal show={this.state.show} onHide={this.cancel} bsSize="large">
+      <Modal show={this.state.show} onHide={this.cancel} bsSize="large" dialogClassName="handsontable-modal">
         <Modal.Header closeButton>
         <Modal.Header closeButton>
           <Modal.Title>Edit Table</Modal.Title>
           <Modal.Title>Edit Table</Modal.Title>
         </Modal.Header>
         </Modal.Header>
         <Modal.Body className="p-0">
         <Modal.Body className="p-0">
           <div className="p-4">
           <div className="p-4">
-            <HotTable data={this.state.markdownTable.table} settings={this.settings} />
+            <HotTable ref='hotTable' data={this.state.markdownTable.table} settings={this.state.handsontableSetting} />
           </div>
           </div>
         </Modal.Body>
         </Modal.Body>
         <Modal.Footer>
         <Modal.Footer>
@@ -85,11 +90,64 @@ export default class HandsontableModal extends React.Component {
   }
   }
 
 
   static getDefaultMarkdownTable() {
   static getDefaultMarkdownTable() {
-    return new MarkdownTable([
-      ['col1', 'col2', 'col3'],
-      ['', '', ''],
-      ['', '', ''],
-    ]);
+    return new MarkdownTable(
+      [
+        ['col1', 'col2', 'col3'],
+        ['', '', ''],
+        ['', '', ''],
+      ],
+      {
+        align: ['', '', '']
+      }
+    );
+  }
+
+  static getDefaultHandsotableSetting() {
+    return {
+      height: 300,
+      rowHeaders: true,
+      colHeaders: true,
+      contextMenu: {
+        items: {
+          'row_above': {}, 'row_below': {}, 'col_left': {}, 'col_right': {},
+          'separator1': Handsontable.plugins.ContextMenu.SEPARATOR,
+          'remove_row': {}, 'remove_col': {},
+          'separator2': Handsontable.plugins.ContextMenu.SEPARATOR,
+          'custom_alignment': {
+            name: 'Align columns',
+            key: 'align_columns',
+            submenu: {
+              items: [{
+                name: 'Left',
+                key: 'align_columns:1',
+                callback: function(key, selection) {
+                  HandsontableUtil.setClassNameToColumns(this, selection[0].start.col, selection[0].end.col, 'htLeft');
+                }}, {
+                name: 'Center',
+                key: 'align_columns:2',
+                callback: function(key, selection) {
+                  HandsontableUtil.setClassNameToColumns(this, selection[0].start.col, selection[0].end.col, 'htCenter');
+                }}, {
+                name: 'Right',
+                key: 'align_columns:3',
+                callback: function(key, selection) {
+                  HandsontableUtil.setClassNameToColumns(this, selection[0].start.col, selection[0].end.col, 'htRight');
+                }}
+              ]
+            }
+          }
+        }
+      },
+      selectionMode: 'multiple',
+      modifyColWidth: function(width) {
+        if (width < 100) {
+          return 100;
+        }
+        if (width > 300) {
+          return 300;
+        }
+      }
+    };
   }
   }
 }
 }
 
 

+ 54 - 0
src/client/js/components/PageEditor/HandsontableUtil.js

@@ -0,0 +1,54 @@
+/**
+ * Utility for Handsontable (and cooperation with MarkdownTable)
+ */
+export default class HandsontableUtil {
+
+  static setClassNameToColumns(core, startCol, endCol, className) {
+    for (let i = startCol; i <= endCol; i++) {
+      for (let j = 0; j < core.countRows(); j++) {
+        core.setCellMeta(j, i, 'className', className);
+      }
+    }
+    core.render();
+  }
+
+  /**
+   * return a function(handsontable event handler) to adjust the handsontable alignment to the markdown table
+   */
+  static createHandlerToSynchronizeHandontableAlignWith(markdownTableAlign) {
+    const mapping = {
+      'r': 'htRight',
+      'c': 'htCenter',
+      'l': 'htLeft',
+      '': ''
+    };
+
+    return function() {
+      const align = markdownTableAlign;
+      for (let i = 0; i < align.length; i++) {
+        HandsontableUtil.setClassNameToColumns(this, i, i, mapping[align[i]]);
+      }
+    };
+  }
+
+  /**
+   * return MarkdownTable alignment retrieved from Handsontable instance
+   */
+  static getMarkdownTableAlignmentFrom(handsontable) {
+    const cellMetasAtFirstRow = handsontable.getCellMetaAtRow(0);
+    const mapping = {
+      'htRight': 'r',
+      'htCenter': 'c',
+      'htLeft': 'l',
+      '': ''
+    };
+
+    let align = [];
+    for (let i = 0; i < cellMetasAtFirstRow.length; i++) {
+      align.push(mapping[cellMetasAtFirstRow[i].className]);
+    }
+
+    return align;
+  }
+}
+

+ 30 - 6
src/client/js/components/PageEditor/MarkdownTableUtil.js

@@ -18,8 +18,8 @@ class MarkdownTableUtil {
     this.getStrFromBot = this.getStrFromBot.bind(this);
     this.getStrFromBot = this.getStrFromBot.bind(this);
     this.getStrToEot = this.getStrToEot.bind(this);
     this.getStrToEot = this.getStrToEot.bind(this);
     this.isInTable = this.isInTable.bind(this);
     this.isInTable = this.isInTable.bind(this);
-    this.replaceMarkdownTable = this.replaceMarkdownTable.bind(this);
-    this.replaceMarkdownTableWithReformed = this.replaceMarkdownTable; // alias
+    this.replaceFocusedMarkdownTableWithEditor = this.replaceFocusedMarkdownTableWithEditor.bind(this);
+    this.replaceMarkdownTableWithReformed = this.replaceFocusedMarkdownTableWithEditor; // alias
   }
   }
 
 
   /**
   /**
@@ -133,7 +133,7 @@ class MarkdownTableUtil {
   }
   }
 
 
   /**
   /**
-   * returns markdown table that is merged all of markdown table in array
+   * return markdown table that is merged all of markdown table in array
    * (The merged markdown table options are used for the first markdown table.)
    * (The merged markdown table options are used for the first markdown table.)
    * @param {Array} array of markdown table
    * @param {Array} array of markdown table
    */
    */
@@ -152,15 +152,39 @@ class MarkdownTableUtil {
   }
   }
 
 
   /**
   /**
-   * replace markdown table
+   * replace focused markdown table with editor
    * (A replaced table is reformed by markdown-table.)
    * (A replaced table is reformed by markdown-table.)
-   * @param {MarkdownTable} markdown table
+   * @param {MarkdownTable} table
    */
    */
-  replaceMarkdownTable(editor, table) {
+  replaceFocusedMarkdownTableWithEditor(editor, table) {
     const curPos = editor.getCursor();
     const curPos = editor.getCursor();
     editor.getDoc().replaceRange(table.toString(), this.getBot(editor), this.getEot(editor));
     editor.getDoc().replaceRange(table.toString(), this.getBot(editor), this.getEot(editor));
     editor.getDoc().setCursor(curPos.line + 1, 2);
     editor.getDoc().setCursor(curPos.line + 1, 2);
   }
   }
+
+  /**
+   * return markdown where the markdown table specified by line number params is replaced to the markdown table specified by table param
+   * @param {string} markdown
+   * @param {MarkdownTable} table
+   * @param beginLineNumber
+   * @param endLineNumber
+   */
+  replaceMarkdownTableInMarkdown(table, markdown, beginLineNumber, endLineNumber) {
+    const splitMarkdown = markdown.split(/\r\n|\r|\n/);
+    const markdownBeforeTable = splitMarkdown.slice(0, beginLineNumber - 1);
+    const markdownAfterTable = splitMarkdown.slice(endLineNumber);
+
+    let newMarkdown = '';
+    if (markdownBeforeTable.length > 0) {
+      newMarkdown += markdownBeforeTable.join('\n') + '\n';
+    }
+    newMarkdown += table;
+    if (markdownAfterTable.length > 0) {
+      newMarkdown += '\n' + markdownAfterTable.join('\n');
+    }
+
+    return newMarkdown;
+  }
 }
 }
 
 
 // singleton pattern
 // singleton pattern

+ 191 - 0
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -0,0 +1,191 @@
+import React from 'react';
+import Page from '../PageList/Page';
+
+import PropTypes from 'prop-types';
+import Pagination from 'react-bootstrap/lib/Pagination';
+export default class RecentCreated extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      pages: [],
+      activePage: 1,
+      paginationNumbers: {},
+    };
+    this.calculatePagination = this.calculatePagination.bind(this);
+  }
+
+
+  componentWillMount() {
+    this.getRecentCreatedList(1);
+  }
+
+  getRecentCreatedList(selectPageNumber) {
+    const pageId = this.props.pageId;
+    const userId = this.props.crowi.me;
+    const limit = this.props.limit;
+    const offset = (selectPageNumber - 1) * limit;
+
+    // pagesList get and pagination calculate
+    this.props.crowi.apiGet('/pages.recentCreated', { page_id: pageId, user: userId, limit, offset })
+      .then(res => {
+        const totalCount = res.pages[0].totalCount;
+        const activePage = selectPageNumber;
+        const pages = res.pages[1];
+        // pagiNation calculate function call
+        const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
+        this.setState({
+          pages,
+          activePage,
+          paginationNumbers,
+        });
+      });
+  }
+
+  calculatePagination(limit, totalCount, activePage) {
+    // calc totalPageNumber
+    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0  : 1);
+
+    let paginationStart = activePage - 2;
+    let maxViewPageNum =  activePage + 2;
+    // pagiNation Number area size = 5 , pageNuber calculate in here
+    // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
+    if ( paginationStart < 1 ) {
+      const diff = 1 - paginationStart;
+      paginationStart += diff;
+      maxViewPageNum = Math.min(totalPage, maxViewPageNum + diff);
+    }
+    if ( maxViewPageNum > totalPage ) {
+      const diff = maxViewPageNum - totalPage;
+      maxViewPageNum -= diff;
+      paginationStart = Math.max(1, paginationStart - diff);
+    }
+
+    return {
+      totalPage,
+      paginationStart,
+      maxViewPageNum,
+    };
+  }
+  /**
+   * generate Elements of Page
+   *
+   * @param {any} pages Array of pages Model Obj
+   *
+   */
+  generatePageList(pages) {
+    return pages.map(page => {
+      return <Page page={page} key={'recent-created:list-view:' + page._id} />;
+    });
+
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set << & <
+   */
+  generateFirstPrev(activePage) {
+    let paginationItems = [];
+    if (1 != activePage) {
+      paginationItems.push(
+        <Pagination.First key="first" onClick={() => this.getRecentCreatedList(1)} />
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" onClick={() => this.getRecentCreatedList(this.state.activePage - 1)} />
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.First key="first" disabled />
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" disabled />
+      );
+
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
+   * this function set  numbers
+   */
+  generatePaginations(activePage, paginationStart, maxViewPageNum) {
+    let paginationItems = [];
+    for (let number = paginationStart; number <= maxViewPageNum; number++) {
+      paginationItems.push(
+        <Pagination.Item key={number} active={number === activePage} onClick={ () => this.getRecentCreatedList(number)}>{number}</Pagination.Item>
+      );
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set > & >>
+   */
+  generateNextLast(activePage, totalPage) {
+    let paginationItems = [];
+    if (totalPage != activePage) {
+      paginationItems.push(
+        <Pagination.Next key="next" onClick={() => this.getRecentCreatedList(this.state.activePage + 1)} />
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" onClick={() => this.getRecentCreatedList(totalPage)} />
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.Next key="next" disabled />
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" disabled />
+      );
+
+    }
+    return paginationItems;
+
+  }
+
+  render() {
+    const pageList = this.generatePageList(this.state.pages);
+
+    let paginationItems = [];
+
+    const activePage = this.state.activePage;
+    const totalPage = this.state.paginationNumbers.totalPage;
+    const paginationStart = this.state.paginationNumbers.paginationStart;
+    const maxViewPageNum =  this.state.paginationNumbers.maxViewPageNum;
+    const firstPrevItems = this.generateFirstPrev(activePage);
+    paginationItems.push(firstPrevItems);
+    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
+    paginationItems.push(paginations);
+    const nextLastItems = this.generateNextLast(activePage, totalPage);
+    paginationItems.push(nextLastItems);
+
+    return (
+      <div className="page-list-container-create">
+        <ul className="page-list-ul page-list-ul-flat">
+          {pageList}
+        </ul>
+        <Pagination bsSize="small">{paginationItems}</Pagination>
+      </div>
+    );
+  }
+}
+
+
+
+RecentCreated.propTypes = {
+  pageId: PropTypes.string.isRequired,
+  crowi: PropTypes.object.isRequired,
+  limit: PropTypes.number,
+};
+
+RecentCreated.defaultProps = {
+};
+

+ 0 - 19
src/client/js/legacy/crowi.js

@@ -36,25 +36,6 @@ Crowi.renderTocContent = (tocHtml) => {
   $('#revision-toc-content').html(tocHtml);
   $('#revision-toc-content').html(tocHtml);
 };
 };
 
 
-/**
- * append buttons to section headers
- */
-Crowi.appendEditSectionButtons = function(parentElement) {
-  $('h1,h2,h3,h4,h5,h6', parentElement).each(function(idx, elm) {
-    const line = +elm.getAttribute('data-line');
-
-    // add button
-    $(this).append(`
-      <span class="revision-head-edit-button">
-        <a href="#edit" onClick="Crowi.setCaretLineData(${line})">
-          <i class="icon-note"></i>
-        </a>
-      </span>
-      `
-    );
-  });
-};
-
 /**
 /**
  * set 'data-caret-line' attribute that will be processed when 'shown.bs.tab' event fired
  * set 'data-caret-line' attribute that will be processed when 'shown.bs.tab' event fired
  * @param {number} line
  * @param {number} line

+ 2 - 0
src/client/js/util/GrowiRenderer.js

@@ -17,6 +17,7 @@ import TaskListsConfigurer from './markdown-it/task-lists';
 import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
 import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
 import BlockdiagConfigurer from './markdown-it/blockdiag';
 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';
 
 
 export default class GrowiRenderer {
 export default class GrowiRenderer {
 
 
@@ -87,6 +88,7 @@ export default class GrowiRenderer {
           new FooternoteConfigurer(crowi),
           new FooternoteConfigurer(crowi),
           new TocAndAnchorConfigurer(crowi, options.renderToc),
           new TocAndAnchorConfigurer(crowi, options.renderToc),
           new HeaderLineNumberConfigurer(crowi),
           new HeaderLineNumberConfigurer(crowi),
+          new HeaderWithEditLinkConfigurer(crowi),
           new TableWithHandsontableButtonConfigurer(crowi)
           new TableWithHandsontableButtonConfigurer(crowi)
         ]);
         ]);
         break;
         break;

+ 16 - 0
src/client/js/util/markdown-it/header-with-edit-link.js

@@ -0,0 +1,16 @@
+export default class HeaderWithEditLinkConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  configure(md) {
+    md.renderer.rules.heading_close = (tokens, idx) => {
+      return `<span class="revision-head-edit-button">
+                <a href="#edit" onClick="Crowi.setCaretLineData(parseInt(this.parentNode.parentNode.dataset.line, 10))">
+                  <i class="icon-note"></i>
+                </a>
+              </span></${tokens[idx].tag}>`;
+    };
+  }
+}

+ 1 - 1
src/client/js/util/markdown-it/table-with-handsontable-button.js

@@ -8,7 +8,7 @@ export default class TableWithHandsontableButtonConfigurer {
     md.renderer.rules.table_open = (tokens, idx) => {
     md.renderer.rules.table_open = (tokens, idx) => {
       const beginLine = tokens[idx].map[0] + 1;
       const beginLine = tokens[idx].map[0] + 1;
       const endLine  = tokens[idx].map[1];
       const endLine  = tokens[idx].map[1];
-      return `<div><button onClick="crowi.launchHandsontableModal('page', ${beginLine}, ${endLine})"><i class="fa fa-table"></i></button><table class="table table-bordered">`;
+      return `<div class="editable-with-handsontable"><button class="handsontable-modal-trigger" onClick="crowi.launchHandsontableModal('page', ${beginLine}, ${endLine})"><i class="icon-note"></i></button><table class="table table-bordered">`;
     };
     };
 
 
     md.renderer.rules.table_close = (tokens, idx) => {
     md.renderer.rules.table_close = (tokens, idx) => {

+ 10 - 0
src/client/styles/agile-admin/inverse/colors/_apply-colors.scss

@@ -279,6 +279,16 @@ $active-nav-tabs-bgcolor: $bodycolor !default;
       color: $wikilinktext-hover;
       color: $wikilinktext-hover;
     }
     }
   }
   }
+
+  // table with handsontable modal button
+  .editable-with-handsontable {
+    button {
+      color: $wikilinktext;
+    }
+    button:hover {
+      color: $wikilinktext-hover;
+    }
+  }
 }
 }
 
 
 
 

+ 13 - 0
src/client/styles/scss/_handsontable.scss

@@ -0,0 +1,13 @@
+.handsontable {
+  .handsontableInput {
+    max-width: 290px !important;
+  }
+
+  td {
+    word-break: break-all;
+  }
+}
+
+.handsontable-modal.modal-lg {
+  width: 90%;
+}

+ 12 - 0
src/client/styles/scss/_mixins.scss

@@ -49,6 +49,18 @@
           min-height: calc(100vh - #{$header-plus-footer});   // for IE11
           min-height: calc(100vh - #{$header-plus-footer});   // for IE11
           height: calc(100vh - #{$header-plus-footer});
           height: calc(100vh - #{$header-plus-footer});
         }
         }
+
+        .navbar-editor {
+          button {
+            padding: 7px 8px;
+            line-height: 1;
+          }
+
+          img {
+            vertical-align: bottom;
+          }
+        }
+
         // left(editor)
         // left(editor)
         .page-editor-editor-container {
         .page-editor-editor-container {
           min-height: calc(100vh - #{$header-plus-footer});   // for IE11
           min-height: calc(100vh - #{$header-plus-footer});   // for IE11

+ 9 - 2
src/client/styles/scss/_on-edit.scss

@@ -169,8 +169,15 @@ body.on-edit {
       // add icon on cursor
       // add icon on cursor
       .autoformat-markdown-table-activated .CodeMirror-cursor {
       .autoformat-markdown-table-activated .CodeMirror-cursor {
         &:after {
         &:after {
-          font-family: 'FontAwesome';
-          content: '\f0ce';
+          display: block;
+          content: ' ';
+          background-image: url(/images/icons/editor/table.svg);
+
+          position: relative;
+          width: 14px;
+          height: 14px;
+          top: -16px;
+          left: 5px;
         }
         }
       }
       }
 
 

+ 29 - 0
src/client/styles/scss/_page.scss

@@ -128,6 +128,35 @@
 
 
 } // }}}
 } // }}}
 
 
+
+/**
+ * for table with handsontable modal button
+ */
+.editable-with-handsontable {
+  position: relative;
+
+  .handsontable-modal-trigger {
+    opacity: 0;
+    position: absolute;
+    top: 11px;
+    right: 10px;
+    padding: 0;
+    border: none;
+    background-color: transparent;
+    font-size: 16px;
+    line-height: 1;
+    vertical-align: bottom;
+  }
+
+  .page-mobile & .handsontable-modal-trigger {
+    opacity: 0.3;
+  }
+
+  &:hover .handsontable-modal-trigger {
+    opacity: 1;
+  }
+}
+
 /*
 /*
  * for Presentation
  * for Presentation
  */
  */

+ 1 - 0
src/client/styles/scss/style.scss

@@ -42,6 +42,7 @@
 @import 'shortcuts';
 @import 'shortcuts';
 @import 'user';
 @import 'user';
 @import 'user_growi';
 @import 'user_growi';
+@import 'handsontable';
 @import 'wiki';
 @import 'wiki';
 
 
 /*
 /*

+ 47 - 0
src/migrations/20180926134048-make-email-unique.js

@@ -0,0 +1,47 @@
+'use strict';
+
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:make-email-unique');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+module.exports = {
+
+  async up(db, next) {
+    logger.info('Start migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const User = require('@server/models/user')();
+
+    // get all users who has 'deleted@deleted' email
+    const users = await User.find({email: 'deleted@deleted'});
+    if (users.length > 0) {
+      logger.info(`${users.length} users found. Replace email...`, users);
+    }
+
+    // make email unique
+    const promises = users.map(user => {
+      const now = new Date();
+      const deletedLabel = `deleted_at_${now.getTime()}`;
+      user.email = `${deletedLabel}@deleted`;
+      return user.save();
+    });
+    await Promise.all(promises);
+
+    // sync index
+    logger.info('Invoking syncIndexes');
+    await User.syncIndexes();
+
+    await mongoose.disconnect();
+
+    logger.info('Migration has successfully terminated');
+    next();
+  },
+
+  down(db, next) {
+    // do not rollback
+    next();
+  }
+
+};

+ 89 - 0
src/migrations/20180927102719-init-serverurl.js

@@ -0,0 +1,89 @@
+'use strict';
+
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:init-serverurl');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+/**
+ * check all values of the array are equal
+ * @see https://stackoverflow.com/a/35568895
+ */
+function isAllValuesSame(array) {
+  return !!array.reduce((a, b) => {
+    return (a === b) ? a : NaN;
+  });
+}
+
+module.exports = {
+
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = require('@server/models/config')();
+
+    // find 'app:siteUrl'
+    const siteUrlConfig = await Config.findOne({
+      ns: 'crowi',
+      key: 'app:siteUrl',
+    });
+    // exit if exists
+    if (siteUrlConfig != null) {
+      logger.info('\'app:siteUrl\' is already exists. This migration terminates without any changes.');
+      return;
+    }
+
+    // find all callbackUrls
+    const configs = await Config.find({
+      ns: 'crowi',
+      $or: [
+        { key: 'security:passport-github:callbackUrl' },
+        { key: 'security:passport-google:callbackUrl' },
+        { key: 'security:passport-twitter:callbackUrl' },
+        { key: 'security:passport-saml:callbackUrl' },
+      ]
+    });
+
+    // determine serverUrl
+    let siteUrl;
+    if (configs.length > 0) {
+      logger.info(`${configs.length} configs which has callbackUrl found: `);
+      logger.info(configs);
+
+      // extract domain
+      const siteUrls = configs.map(config => {
+        // see https://regex101.com/r/Q0Isjo/2
+        const match = config.value.match(/^"(https?:\/\/[^/]+).*"$/);
+        return (match != null) ? match[1] : null;
+      }).filter(value => value != null);
+
+      // determine serverUrl if all values are same
+      if (siteUrls.length > 0 && isAllValuesSame(siteUrls)) {
+        siteUrl = siteUrls[0];
+      }
+    }
+
+    if (siteUrl != null) {
+      await Config.findOneAndUpdateByNsAndKey('crowi', 'app:siteUrl', siteUrl);
+      logger.info('Migration has successfully applied');
+    }
+  },
+
+  async down(db) {
+    logger.info('Undo migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = require('@server/models/config')();
+
+    // remote 'app:siteUrl'
+    await Config.findOneAndDelete({
+      ns: 'crowi',
+      key: 'app:siteUrl',
+    });
+
+    logger.info('Migration has successfully undoed');
+  }
+
+};

+ 3 - 1
src/server/.node-dev.json

@@ -1,6 +1,8 @@
 {
 {
   "ignore": [
   "ignore": [
     "package.json",
     "package.json",
-    "public/manifest.json"
+    "public/manifest.json",
+    "config/env.",
+    "config/webpack."
   ]
   ]
 }
 }

+ 3 - 3
src/server/app.js

@@ -9,7 +9,7 @@ require('module-alias/register');
 
 
 const logger = require('@alias/logger')('growi');
 const logger = require('@alias/logger')('growi');
 const helpers = require('@commons/util/helpers');
 const helpers = require('@commons/util/helpers');
-const growi = new (require('./crowi'))(helpers.root(), process.env);
+const growi = new (require('./crowi'))(helpers.root());
 
 
 
 
 /************************************
 /************************************
@@ -24,10 +24,10 @@ process.on('unhandledRejection', (reason, p) => {
 });
 });
 
 
 growi.start()
 growi.start()
-  .then(express => {
+  .then(server => {
     if (helpers.hasProcessFlag('ci')) {
     if (helpers.hasProcessFlag('ci')) {
       logger.info('"--ci" flag is detected. Exit process.');
       logger.info('"--ci" flag is detected. Exit process.');
-      express.close(() => {
+      server.close(() => {
         process.exit();
         process.exit();
       });
       });
     }
     }

+ 47 - 11
src/server/crowi/dev.js

@@ -1,4 +1,4 @@
-const debug = require('debug')('growi:crowi:dev');
+const logger = require('@alias/logger')('growi:crowi:dev');
 const fs = require('fs');
 const fs = require('fs');
 const path = require('path');
 const path = require('path');
 
 
@@ -50,30 +50,66 @@ class CrowiDev {
 
 
   /**
   /**
    *
    *
-   *
-   * @param {any} server http server
    * @param {any} app express
    * @param {any} app express
+   */
+  setupServer(app) {
+    const port = this.crowi.port;
+    let server = app;
+
+    // for log
+    let serverUrl = `http://localhost:${port}}`;
+
+    if (this.crowi.env.DEV_HTTPS) {
+      logger.info(`[${this.crowi.node_env}] Express server will start with HTTPS Self-Signed Certification`);
+
+      serverUrl = `https://localhost:${port}}`;
+
+      const fs = require('graceful-fs');
+      const https = require('https');
+
+      const options = {
+        key: fs.readFileSync(path.join(this.crowi.rootDir, './resource/certs/localhost/key.pem')),
+        cert: fs.readFileSync(path.join(this.crowi.rootDir, './resource/certs/localhost/cert.pem')),
+      };
+
+      server = https.createServer(options, app);
+    }
+
+    const eazyLogger = require('eazy-logger').Logger({
+      prefix: '[{green:GROWI}] ',
+      useLevelPrefixes: false,
+    });
+
+    eazyLogger.info('{bold:Server URLs:}');
+    eazyLogger.unprefixed('info', '{grey:=======================================}');
+    eazyLogger.unprefixed('info', `         APP: {magenta:${serverUrl}}`);
+    eazyLogger.unprefixed('info', '{grey:=======================================}');
+
+    return server;
+  }
+
+  /**
    *
    *
-   * @memberOf CrowiDev
+   * @param {any} app express
    */
    */
-  setup(server, app) {
+  setupExpressAfterListening(app) {
     this.setupHeaderDebugger(app);
     this.setupHeaderDebugger(app);
     this.setupBrowserSync(app);
     this.setupBrowserSync(app);
   }
   }
 
 
   setupHeaderDebugger(app) {
   setupHeaderDebugger(app) {
-    debug('setupHeaderDebugger');
+    logger.debug('setupHeaderDebugger');
 
 
     app.use((req, res, next) => {
     app.use((req, res, next) => {
       onHeaders(res, () => {
       onHeaders(res, () => {
-        debug('HEADERS GOING TO BE WRITTEN');
+        logger.debug('HEADERS GOING TO BE WRITTEN');
       });
       });
       next();
       next();
     });
     });
   }
   }
 
 
   setupBrowserSync(app) {
   setupBrowserSync(app) {
-    debug('setupBrowserSync');
+    logger.debug('setupBrowserSync');
 
 
     const browserSync = require('browser-sync');
     const browserSync = require('browser-sync');
     const bs = browserSync.create().init({
     const bs = browserSync.create().init({
@@ -93,12 +129,12 @@ class CrowiDev {
         && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
         && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
 
 
       const pluginNames = process.env.PLUGIN_NAMES_TOBE_LOADED.split(',');
       const pluginNames = process.env.PLUGIN_NAMES_TOBE_LOADED.split(',');
-      debug('loading Plugins for development', pluginNames);
+      logger.debug('[development] loading Plugins', pluginNames);
 
 
       // merge and remove duplicates
       // merge and remove duplicates
       if (pluginNames.length > 0) {
       if (pluginNames.length > 0) {
-        var PluginService = require('../plugins/plugin.service');
-        var pluginService = new PluginService(this.crowi, app);
+        const PluginService = require('../plugins/plugin.service');
+        const pluginService = new PluginService(this.crowi, app);
         pluginService.loadPlugins(pluginNames);
         pluginService.loadPlugins(pluginNames);
       }
       }
     }
     }

+ 4 - 3
src/server/crowi/express-init.js

@@ -57,17 +57,18 @@ module.exports = function(crowi, app) {
       , User = crowi.model('User')
       , User = crowi.model('User')
       , Config = crowi.model('Config')
       , Config = crowi.model('Config')
       ;
       ;
-    let baseUrl;
 
 
     app.set('tzoffset', tzoffset);
     app.set('tzoffset', tzoffset);
 
 
     req.config = config;
     req.config = config;
     req.csrfToken = null;
     req.csrfToken = null;
 
 
-    config.crowi['app:url'] = baseUrl = (req.headers['x-forwarded-proto'] == 'https' ? 'https' : req.protocol) + '://' + req.get('host');
+    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  = baseUrl;
+    res.locals.baseUrl  = config.crowi['app:siteUrl:fixed'];
     res.locals.config   = config;
     res.locals.config   = config;
     res.locals.env      = env;
     res.locals.env      = env;
     res.locals.now      = now;
     res.locals.now      = now;

+ 33 - 52
src/server/crowi/index.js

@@ -15,7 +15,7 @@ const debug = require('debug')('growi:crowi')
 
 
   ;
   ;
 
 
-function Crowi(rootdir, env) {
+function Crowi(rootdir) {
   const self = this;
   const self = this;
 
 
   this.version = pkg.version;
   this.version = pkg.version;
@@ -45,7 +45,7 @@ function Crowi(rootdir, env) {
 
 
   this.models = {};
   this.models = {};
 
 
-  this.env = env;
+  this.env = process.env;
   this.node_env = this.env.NODE_ENV || 'development';
   this.node_env = this.env.NODE_ENV || 'development';
 
 
   this.port = this.env.PORT || 3000;
   this.port = this.env.PORT || 3000;
@@ -124,8 +124,8 @@ Crowi.prototype.getEnv = function() {
 // getter/setter of model instance
 // getter/setter of model instance
 //
 //
 Crowi.prototype.model = function(name, model) {
 Crowi.prototype.model = function(name, model) {
-  if (model) {
-    return this.models[name] = model;
+  if (model != null) {
+    this.models[name] = model;
   }
   }
 
 
   return this.models[name];
   return this.models[name];
@@ -146,17 +146,17 @@ Crowi.prototype.setupDatabase = function() {
 
 
   const mongoUri = getMongoUrl(this.env);
   const mongoUri = getMongoUrl(this.env);
 
 
-  return mongoose.connect(mongoUri);
+  return mongoose.connect(mongoUri, { useNewUrlParser: true });
 };
 };
 
 
 Crowi.prototype.setupSessionConfig = function() {
 Crowi.prototype.setupSessionConfig = function() {
-  var self = this
+  const self = this
     , session  = require('express-session')
     , session  = require('express-session')
-    , sessionConfig
     , sessionAge = (1000*3600*24*30)
     , sessionAge = (1000*3600*24*30)
     , redisUrl = this.env.REDISTOGO_URL || this.env.REDIS_URI || this.env.REDIS_URL || null
     , redisUrl = this.env.REDISTOGO_URL || this.env.REDIS_URI || this.env.REDIS_URL || null
     , mongoUrl = getMongoUrl(this.env)
     , mongoUrl = getMongoUrl(this.env)
     ;
     ;
+  let sessionConfig;
 
 
   return new Promise(function(resolve, reject) {
   return new Promise(function(resolve, reject) {
     sessionConfig = {
     sessionConfig = {
@@ -347,56 +347,37 @@ Crowi.prototype.getTokens = function() {
   return this.tokens;
   return this.tokens;
 };
 };
 
 
-Crowi.prototype.start = function() {
-  var self = this
-    , server
-    , io;
-
+Crowi.prototype.start = async function() {
   // init CrowiDev
   // init CrowiDev
-  if (self.node_env === 'development') {
+  if (this.node_env === 'development') {
     const CrowiDev = require('./dev');
     const CrowiDev = require('./dev');
-    this.crowiDev = new CrowiDev(self);
+    this.crowiDev = new CrowiDev(this);
     this.crowiDev.init();
     this.crowiDev.init();
   }
   }
 
 
-  return Promise.resolve()
-    .then(function() {
-      return self.init();
-    })
-    .then(function() {
-      return self.buildServer();
-    })
-    .then(function(express) {
-      return new Promise((resolve) => {
-        server = express.listen(self.port, function() {
-          logger.info(`[${self.node_env}] Express server listening on port ${self.port}`);
-
-          // setup for dev
-          if (self.node_env === 'development') {
-            const eazyLogger = require('eazy-logger').Logger({
-              prefix: '[{green:GROWI}] ',
-              useLevelPrefixes: false,
-            });
-
-            eazyLogger.info('{bold:Server URLs:}');
-            eazyLogger.unprefixed('info', '{grey:=======================================}');
-            eazyLogger.unprefixed('info', `         APP: {magenta:http://localhost:${self.port}}`);
-            eazyLogger.unprefixed('info', '{grey:=======================================}');
-
-            self.crowiDev.setup(server, express);
-          }
-          resolve(server);
-        });
-
-        io = require('socket.io')(server);
-        io.sockets.on('connection', function(socket) {
-        });
-        self.io = io;
-
-        // setup Express Routes
-        self.setupRoutesAtLast(express);
-      });
-    });
+  await this.init();
+  const express = await this.buildServer();
+
+  const server = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
+
+  // listen
+  const serverListening = server.listen(this.port, () => {
+    logger.info(`[${this.node_env}] Express server is listening on port ${this.port}`);
+    if (this.node_env === 'development') {
+      this.crowiDev.setupExpressAfterListening(express);
+    }
+  });
+
+  // setup WebSocket
+  const io = require('socket.io')(serverListening);
+  io.sockets.on('connection', function(socket) {
+  });
+  this.io = io;
+
+  // setup Express Routes
+  this.setupRoutesAtLast(express);
+
+  return serverListening;
 };
 };
 
 
 Crowi.prototype.buildServer = function() {
 Crowi.prototype.buildServer = function() {

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

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

+ 2 - 1
src/server/form/admin/customfeatures.js

@@ -6,6 +6,7 @@ var form = require('express-form')
 module.exports = form(
 module.exports = form(
   field('settingForm[customize:isEnabledTimeline]').trim().toBooleanStrict(),
   field('settingForm[customize:isEnabledTimeline]').trim().toBooleanStrict(),
   field('settingForm[customize:isSavedStatesOfTabChanges]').trim().toBooleanStrict(),
   field('settingForm[customize:isSavedStatesOfTabChanges]').trim().toBooleanStrict(),
-  field('settingForm[customize:isEnabledAttachTitleHeader]').trim().toBooleanStrict()
+  field('settingForm[customize:isEnabledAttachTitleHeader]').trim().toBooleanStrict(),
+  field('settingForm[customize:showRecentCreatedNumber]').trim().toInt()
 );
 );
 
 

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

@@ -8,6 +8,5 @@ module.exports = form(
   field('settingForm[security:passport-github:isEnabled]').trim().toBooleanStrict().required(),
   field('settingForm[security:passport-github:isEnabled]').trim().toBooleanStrict().required(),
   field('settingForm[security:passport-github:clientId]').trim(),
   field('settingForm[security:passport-github:clientId]').trim(),
   field('settingForm[security:passport-github:clientSecret]').trim(),
   field('settingForm[security:passport-github:clientSecret]').trim(),
-  field('settingForm[security:passport-github:callbackUrl]').trim(),
   field('settingForm[security:passport-github:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
   field('settingForm[security:passport-github:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
 );
 );

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

@@ -8,6 +8,5 @@ module.exports = form(
   field('settingForm[security:passport-google:isEnabled]').trim().toBooleanStrict().required(),
   field('settingForm[security:passport-google:isEnabled]').trim().toBooleanStrict().required(),
   field('settingForm[security:passport-google:clientId]').trim(),
   field('settingForm[security:passport-google:clientId]').trim(),
   field('settingForm[security:passport-google:clientSecret]').trim(),
   field('settingForm[security:passport-google:clientSecret]').trim(),
-  field('settingForm[security:passport-google:callbackUrl]').trim(),
   field('settingForm[security:passport-google:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
   field('settingForm[security:passport-google:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
 );
 );

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

@@ -5,13 +5,13 @@ const field = form.field;
 
 
 module.exports = form(
 module.exports = form(
   field('settingForm[security:passport-saml:isEnabled]').trim().toBooleanStrict().required(),
   field('settingForm[security:passport-saml:isEnabled]').trim().toBooleanStrict().required(),
-  field('settingForm[security:passport-saml:entryPoint]').trim(),
-  field('settingForm[security:passport-saml:callbackUrl]').trim(),
-  field('settingForm[security:passport-saml:issuer]').trim(),
-  field('settingForm[security:passport-saml:attrMapId]'),
-  field('settingForm[security:passport-saml:attrMapUsername]'),
-  field('settingForm[security:passport-saml:attrMapMail]'),
-  field('settingForm[security:passport-saml:attrMapFirstName]'),
-  field('settingForm[security:passport-saml:attrMapLastName]'),
+  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:attrMapFirstName]').trim(),
+  field('settingForm[security:passport-saml:attrMapLastName]').trim(),
+  field('settingForm[security:passport-saml:cert]').trim(),
   field('settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
   field('settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
 );
 );

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

@@ -8,6 +8,5 @@ module.exports = form(
   field('settingForm[security:passport-twitter:isEnabled]').trim().toBooleanStrict().required(),
   field('settingForm[security:passport-twitter:isEnabled]').trim().toBooleanStrict().required(),
   field('settingForm[security:passport-twitter:consumerKey]').trim(),
   field('settingForm[security:passport-twitter:consumerKey]').trim(),
   field('settingForm[security:passport-twitter:consumerSecret]').trim(),
   field('settingForm[security:passport-twitter:consumerSecret]').trim(),
-  field('settingForm[security:passport-twitter:callbackUrl]').trim(),
   field('settingForm[security:passport-twitter:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
   field('settingForm[security:passport-twitter:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
 );
 );

+ 41 - 65
src/server/models/config.js

@@ -21,6 +21,12 @@ module.exports = function(crowi) {
     value: { type: String, required: true }
     value: { type: String, required: true }
   });
   });
 
 
+  function validateCrowi() {
+    if (crowi == null) {
+      throw new Error('"crowi" is null. Init Config model with "crowi" argument first.');
+    }
+  }
+
   /**
   /**
    * default values when GROWI is cleanly installed
    * default values when GROWI is cleanly installed
    */
    */
@@ -67,6 +73,7 @@ module.exports = function(crowi) {
       'security:passport-ldap:groupSearchFilter' : undefined,
       'security:passport-ldap:groupSearchFilter' : undefined,
       'security:passport-ldap:groupDnProperty' : undefined,
       'security:passport-ldap:groupDnProperty' : undefined,
       'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': false,
       'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': false,
+      'security:passport-saml:isEnabled' : false,
       'security:passport-google:isEnabled' : false,
       'security:passport-google:isEnabled' : false,
       'security:passport-github:isEnabled' : false,
       'security:passport-github:isEnabled' : false,
       'security:passport-twitter:isEnabled' : false,
       'security:passport-twitter:isEnabled' : false,
@@ -99,6 +106,7 @@ module.exports = function(crowi) {
       'customize:isEnabledTimeline' : true,
       'customize:isEnabledTimeline' : true,
       'customize:isSavedStatesOfTabChanges' : true,
       'customize:isSavedStatesOfTabChanges' : true,
       'customize:isEnabledAttachTitleHeader' : false,
       'customize:isEnabledAttachTitleHeader' : false,
+      'customize:showRecentCreatedNumber' : 10,
 
 
       'importer:esa:team_name': '',
       'importer:esa:team_name': '',
       'importer:esa:access_token': '',
       'importer:esa:access_token': '',
@@ -130,6 +138,15 @@ module.exports = function(crowi) {
     return config.crowi[key];
     return config.crowi[key];
   }
   }
 
 
+  function getValueForMarkdownNS(config, key) {
+    // return the default value if undefined
+    if (undefined === config.markdown || undefined === config.markdown[key]) {
+      return getDefaultMarkdownConfigs()[key];
+    }
+
+    return config.markdown[key];
+  }
+
   configSchema.statics.getRestrictGuestModeLabels = function() {
   configSchema.statics.getRestrictGuestModeLabels = function() {
     var labels = {};
     var labels = {};
     labels[SECURITY_RESTRICT_GUEST_MODE_DENY]     = 'security_setting.guest_mode.deny';
     labels[SECURITY_RESTRICT_GUEST_MODE_DENY]     = 'security_setting.guest_mode.deny';
@@ -148,8 +165,10 @@ module.exports = function(crowi) {
   };
   };
 
 
   configSchema.statics.updateConfigCache = function(ns, config) {
   configSchema.statics.updateConfigCache = function(ns, config) {
-    var originalConfig = crowi.getConfig();
-    var newNSConfig = originalConfig[ns] || {};
+    validateCrowi();
+
+    const originalConfig = crowi.getConfig();
+    const newNSConfig = originalConfig[ns] || {};
     Object.keys(config).forEach(function(key) {
     Object.keys(config).forEach(function(key) {
       if (config[key] || config[key] === '' || config[key] === false) {
       if (config[key] || config[key] === '' || config[key] === false) {
         newNSConfig[key] = config[key];
         newNSConfig[key] = config[key];
@@ -223,16 +242,11 @@ module.exports = function(crowi) {
     return callback(null, configs);
     return callback(null, configs);
   };
   };
 
 
-  configSchema.statics.findAndUpdate = function(ns, key, value, callback) {
-    var Config = this;
-    Config.findOneAndUpdate(
+  configSchema.statics.findOneAndUpdateByNsAndKey = async function(ns, key, value) {
+    return this.findOneAndUpdate(
       { ns: ns, key: key },
       { ns: ns, key: key },
       { ns: ns, key: key, value: JSON.stringify(value) },
       { ns: ns, key: key, value: JSON.stringify(value) },
-      { upsert: true, },
-      function(err, config) {
-        debug('Config.findAndUpdate', err, config);
-        callback(err, config);
-      });
+      { upsert: true, });
   };
   };
 
 
   configSchema.statics.getConfig = function(callback) {
   configSchema.statics.getConfig = function(callback) {
@@ -310,7 +324,7 @@ module.exports = function(crowi) {
   };
   };
 
 
   configSchema.statics.isUploadable = function(config) {
   configSchema.statics.isUploadable = function(config) {
-    var method = crowi.env.FILE_UPLOAD || 'aws';
+    const method = process.env.FILE_UPLOAD || 'aws';
 
 
     if (method == 'aws' && (
     if (method == 'aws' && (
       !config.crowi['aws:accessKeyId'] ||
       !config.crowi['aws:accessKeyId'] ||
@@ -339,78 +353,37 @@ module.exports = function(crowi) {
 
 
   configSchema.statics.isEnabledLinebreaks = function(config) {
   configSchema.statics.isEnabledLinebreaks = function(config) {
     const key = 'markdown:isEnabledLinebreaks';
     const key = 'markdown:isEnabledLinebreaks';
-
-    // return default value if undefined
-    if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs()[key];
-    }
-
-    return config.markdown[key];
+    return getValueForMarkdownNS(config, key);
   };
   };
 
 
   configSchema.statics.isEnabledLinebreaksInComments = function(config) {
   configSchema.statics.isEnabledLinebreaksInComments = function(config) {
     const key = 'markdown:isEnabledLinebreaksInComments';
     const key = 'markdown:isEnabledLinebreaksInComments';
-
-    // return default value if undefined
-    if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs()[key];
-    }
-
-    return config.markdown[key];
+    return getValueForMarkdownNS(config, key);
   };
   };
 
 
   configSchema.statics.pageBreakSeparator = function(config) {
   configSchema.statics.pageBreakSeparator = function(config) {
     const key = 'markdown:presentation:pageBreakSeparator';
     const key = 'markdown:presentation:pageBreakSeparator';
-
-    // return default value if undefined
-    if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs[key];
-    }
-
-    return config.markdown[key];
+    return getValueForMarkdownNS(config, key);
   };
   };
 
 
   configSchema.statics.pageBreakCustomSeparator = function(config) {
   configSchema.statics.pageBreakCustomSeparator = function(config) {
     const key = 'markdown:presentation:pageBreakCustomSeparator';
     const key = 'markdown:presentation:pageBreakCustomSeparator';
-
-    // return default value if undefined
-    if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs[key];
-    }
-
-    return config.markdown[key];
+    return getValueForMarkdownNS(config, key);
   };
   };
 
 
   configSchema.statics.isEnabledXssPrevention = function(config) {
   configSchema.statics.isEnabledXssPrevention = function(config) {
     const key = 'markdown:xss:isEnabledPrevention';
     const key = 'markdown:xss:isEnabledPrevention';
-
-    // return default value if undefined
-    if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs[key];
-    }
-
-    return config.markdown[key];
+    return getValueForMarkdownNS(config, key);
   };
   };
 
 
   configSchema.statics.xssOption = function(config) {
   configSchema.statics.xssOption = function(config) {
     const key = 'markdown:xss:option';
     const key = 'markdown:xss:option';
-
-    // return default value if undefined
-    if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs[key];
-    }
-
-    return config.markdown[key];
+    return getValueForMarkdownNS(config, key);
   };
   };
 
 
   configSchema.statics.tagWhiteList = function(config) {
   configSchema.statics.tagWhiteList = function(config) {
     const key = 'markdown:xss:tagWhiteList';
     const key = 'markdown:xss:tagWhiteList';
 
 
-    // return default value if undefined
-    if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs[key];
-    }
-
     if (this.isEnabledXssPrevention(config)) {
     if (this.isEnabledXssPrevention(config)) {
       switch (this.xssOption(config)) {
       switch (this.xssOption(config)) {
         case 1: // ignore all: use default option
         case 1: // ignore all: use default option
@@ -435,11 +408,6 @@ module.exports = function(crowi) {
   configSchema.statics.attrWhiteList = function(config) {
   configSchema.statics.attrWhiteList = function(config) {
     const key = 'markdown:xss:attrWhiteList';
     const key = 'markdown:xss:attrWhiteList';
 
 
-    // return default value if undefined
-    if (undefined === config.markdown || undefined === config.markdown[key]) {
-      return getDefaultMarkdownConfigs[key];
-    }
-
     if (this.isEnabledXssPrevention(config)) {
     if (this.isEnabledXssPrevention(config)) {
       switch (this.xssOption(config)) {
       switch (this.xssOption(config)) {
         case 1: // ignore all: use default option
         case 1: // ignore all: use default option
@@ -496,6 +464,8 @@ module.exports = function(crowi) {
   };
   };
 
 
   configSchema.statics.customTitle = function(config, page) {
   configSchema.statics.customTitle = function(config, page) {
+    validateCrowi();
+
     const key = 'customize:title';
     const key = 'customize:title';
     let customTitle = getValueForCrowiNS(config, key);
     let customTitle = getValueForCrowiNS(config, key);
 
 
@@ -546,6 +516,11 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
     return getValueForCrowiNS(config, key);
   };
   };
 
 
+  configSchema.statics.showRecentCreatedNumber = function(config) {
+    const key = 'customize:showRecentCreatedNumber';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.fileUploadEnabled = function(config) {
   configSchema.statics.fileUploadEnabled = function(config) {
     const Config = this;
     const Config = this;
 
 
@@ -588,12 +563,12 @@ module.exports = function(crowi) {
 
 
   configSchema.statics.getLocalconfig = function(config) {
   configSchema.statics.getLocalconfig = function(config) {
     const Config = this;
     const Config = this;
-    const env = crowi.getEnv();
+    const env = process.env;
 
 
     const local_config = {
     const local_config = {
       crowi: {
       crowi: {
         title: Config.appTitle(crowi),
         title: Config.appTitle(crowi),
-        url: config.crowi['app:url'] || '',
+        url: config.crowi['app:siteUrl:fixed'] || '',
       },
       },
       upload: {
       upload: {
         image: Config.isUploadable(config),
         image: Config.isUploadable(config),
@@ -616,6 +591,7 @@ module.exports = function(crowi) {
         HACKMD_URI: env.HACKMD_URI || null,
         HACKMD_URI: env.HACKMD_URI || null,
         MATHJAX: env.MATHJAX || null,
         MATHJAX: env.MATHJAX || null,
       },
       },
+      recentCreatedLimit: Config.showRecentCreatedNumber(config),
     };
     };
 
 
     return local_config;
     return local_config;

+ 37 - 26
src/server/models/page.js

@@ -491,6 +491,7 @@ module.exports = function(crowi) {
           return true;
           return true;
         }
         }
       }).then((checkResult) => {
       }).then((checkResult) => {
+        console.log(checkResult);
         if (checkResult) {
         if (checkResult) {
           return resolve(pageData);
           return resolve(pageData);
         }
         }
@@ -540,7 +541,7 @@ module.exports = function(crowi) {
     const Page = this;
     const Page = this;
     const templatePath = cutOffLastSlash(path);
     const templatePath = cutOffLastSlash(path);
     const pathList = generatePathsOnTree(templatePath, []);
     const pathList = generatePathsOnTree(templatePath, []);
-    const regexpList = pathList.map(path => new RegExp(`^${path}/_{1,2}template$`));
+    const regexpList = pathList.map(path => new RegExp(`^${escapeStringRegexp(path)}/_{1,2}template$`));
 
 
     return Page
     return Page
       .find({path: {$in: regexpList}})
       .find({path: {$in: regexpList}})
@@ -669,38 +670,48 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-  pageSchema.statics.findListByCreator = function(user, option, currentUser) {
-    var Page = this;
-    var User = crowi.model('User');
-    var limit = option.limit || 50;
-    var offset = option.offset || 0;
-    var conditions = {
+  pageSchema.statics.findListByCreator = async function(user, option, currentUser) {
+    let Page = this;
+    let User = crowi.model('User');
+    let limit = option.limit || 50;
+    let offset = option.offset || 0;
+    let conditions = setPageListConditions(user);
+
+    let pages =  await Page.find(conditions).sort({createdAt: -1}).skip(offset).limit(limit).populate('revision').exec();
+    let PagesList = await Page.populate(pages, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS});
+    let totalCount = await Page.countListByCreator(user);
+    let PagesArray = [
+      {totalCount: totalCount}
+    ];
+    PagesArray.push(PagesList);
+    return PagesArray;
+  };
+  function setPageListConditions(user) {
+    const conditions = {
       creator: user._id,
       creator: user._id,
       redirectTo: null,
       redirectTo: null,
-      $or: [
-        {status: null},
-        {status: STATUS_PUBLISHED},
-      ],
+      $and: [
+        {$or: [
+          {status: null},
+          {status: STATUS_PUBLISHED},
+        ]},
+        {$or: [
+          {grant: GRANT_PUBLIC},
+          {grant: GRANT_USER_GROUP},
+        ]}],
     };
     };
 
 
-    if (!user.equals(currentUser._id)) {
-      conditions.grant = GRANT_PUBLIC;
-    }
+    return conditions;
+  }
 
 
-    return new Promise(function(resolve, reject) {
-      Page
-      .find(conditions)
-      .sort({createdAt: -1})
-      .skip(offset)
-      .limit(limit)
-      .populate('revision')
-      .exec()
-      .then(function(pages) {
-        return Page.populate(pages, {path: 'lastUpdateUser', model: 'User', select: User.USER_PUBLIC_FIELDS}).then(resolve);
-      });
-    });
+  pageSchema.statics.countListByCreator = function(user) {
+    let Page = this;
+    let conditions = setPageListConditions(user);
+
+    return Page.find(conditions).count();
   };
   };
 
 
+
   /**
   /**
    * Bulk get (for internal only)
    * Bulk get (for internal only)
    */
    */

+ 21 - 4
src/server/models/user.js

@@ -21,9 +21,14 @@ module.exports = function(crowi) {
 
 
     , PAGE_ITEMS        = 50
     , PAGE_ITEMS        = 50
 
 
-    , userEvent = crowi.event('user')
-
   let userSchema;
   let userSchema;
+  let userEvent;
+
+  // init event
+  if (crowi != null) {
+    userEvent = crowi.event('user');
+    userEvent.on('activated', userEvent.onActivated);
+  }
 
 
   userSchema = new mongoose.Schema({
   userSchema = new mongoose.Schema({
     userId: String,
     userId: String,
@@ -55,9 +60,15 @@ module.exports = function(crowi) {
   userSchema.plugin(mongoosePaginate);
   userSchema.plugin(mongoosePaginate);
   userSchema.plugin(uniqueValidator);
   userSchema.plugin(uniqueValidator);
 
 
-  userEvent.on('activated', userEvent.onActivated);
+  function validateCrowi() {
+    if (crowi == null) {
+      throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
+    }
+  }
 
 
   function decideUserStatusOnRegistration() {
   function decideUserStatusOnRegistration() {
+    validateCrowi();
+
     var Config = crowi.model('Config'),
     var Config = crowi.model('Config'),
       config = crowi.getConfig();
       config = crowi.getConfig();
 
 
@@ -96,6 +107,8 @@ module.exports = function(crowi) {
   }
   }
 
 
   function generatePassword(password) {
   function generatePassword(password) {
+    validateCrowi();
+
     var hasher = crypto.createHash('sha256');
     var hasher = crypto.createHash('sha256');
     hasher.update(crowi.env.PASSWORD_SEED + password);
     hasher.update(crowi.env.PASSWORD_SEED + password);
 
 
@@ -309,6 +322,8 @@ module.exports = function(crowi) {
   };
   };
 
 
   userSchema.statics.isEmailValid = function(email, callback) {
   userSchema.statics.isEmailValid = function(email, callback) {
+    validateCrowi();
+
     var config = crowi.getConfig()
     var config = crowi.getConfig()
       , whitelist = config.crowi['security:registrationWhiteList'];
       , whitelist = config.crowi['security:registrationWhiteList'];
 
 
@@ -596,6 +611,8 @@ module.exports = function(crowi) {
   };
   };
 
 
   userSchema.statics.createUsersByInvitation = function(emailList, toSendEmail, callback) {
   userSchema.statics.createUsersByInvitation = function(emailList, toSendEmail, callback) {
+    validateCrowi();
+
     var User = this
     var User = this
       , createdUserList = []
       , createdUserList = []
       , Config = crowi.model('Config')
       , Config = crowi.model('Config')
@@ -681,7 +698,7 @@ module.exports = function(crowi) {
                 vars: {
                 vars: {
                   email: user.email,
                   email: user.email,
                   password: user.password,
                   password: user.password,
-                  url: config.crowi['app:url'],
+                  url: config.crowi['app:siteUrl:fixed'],
                   appTitle: Config.appTitle(config),
                   appTitle: Config.appTitle(config),
                 }
                 }
               },
               },

+ 3 - 3
src/server/routes/attachment.js

@@ -55,7 +55,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id
    * @apiParam {String} page_id
    */
    */
   api.list = function(req, res) {
   api.list = function(req, res) {
-    var id = req.query.page_id || null;
+    const id = req.query.page_id || null;
     if (!id) {
     if (!id) {
       return res.json(ApiResponse.error('Parameters page_id is required.'));
       return res.json(ApiResponse.error('Parameters page_id is required.'));
     }
     }
@@ -69,10 +69,10 @@ module.exports = function(crowi, app) {
       //   2. ensure backward compatibility of data
       //   2. ensure backward compatibility of data
 
 
       // var config = crowi.getConfig();
       // var config = crowi.getConfig();
-      // var baseUrl = (config.crowi['app:url'] || '');
+      // var baseUrl = (config.crowi['app:siteUrl:fixed'] || '');
       return res.json(ApiResponse.success({
       return res.json(ApiResponse.success({
         attachments: attachments.map(at => {
         attachments: attachments.map(at => {
-          var fileUrl = at.fileUrl;
+          const fileUrl = at.fileUrl;
           at = at.toObject();
           at = at.toObject();
           // at.url = baseUrl + fileUrl;
           // at.url = baseUrl + fileUrl;
           at.url = fileUrl;
           at.url = fileUrl;

+ 3 - 3
src/server/routes/hackmd.js

@@ -41,10 +41,10 @@ module.exports = function(crowi, app) {
 
 
     let origin = `${req.protocol}://${req.get('host')}`;
     let origin = `${req.protocol}://${req.get('host')}`;
 
 
-    // use config.crowi['app:url'] when exist req.headers['x-forwarded-proto'].
+    // use config.crowi['app:siteUrl:fixed'] when exist req.headers['x-forwarded-proto'].
     // refs: lib/crowi/express-init.js
     // refs: lib/crowi/express-init.js
-    if (config.crowi && config.crowi['app:url']) {
-      origin = config.crowi['app:url'];
+    if (config.crowi && config.crowi['app:siteUrl:fixed']) {
+      origin = config.crowi['app:siteUrl:fixed'];
     }
     }
 
 
     // generate definitions to replace
     // generate definitions to replace

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

@@ -188,6 +188,7 @@ module.exports = function(crowi, app) {
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/users.list'          , accessTokenParser , loginRequired(crowi, app, false) , user.api.list);
   app.get('/_api/users.list'          , accessTokenParser , loginRequired(crowi, app, false) , user.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired(crowi, app, false) , page.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired(crowi, app, false) , page.api.list);
+  app.get('/_api/pages.recentCreated' , accessTokenParser , loginRequired(crowi, app, false) , page.api.recentCreated);
   app.post('/_api/pages.create'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.create);
   app.post('/_api/pages.create'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.create);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.update);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.update);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired(crowi, app, false) , page.api.get);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired(crowi, app, false) , page.api.get);

+ 28 - 11
src/server/routes/login-passport.js

@@ -321,30 +321,42 @@ module.exports = function(crowi, app) {
     passport.authenticate('saml')(req, res);
     passport.authenticate('saml')(req, res);
   };
   };
 
 
-  const loginPassportSamlCallback = async(req, res, next) => {
+  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'] || 'id';
-    const attrMapUsername = config.crowi['security:passport-saml:attrMapUsername'] || 'userName';
+    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 attrMapFirstName = config.crowi['security:passport-saml:attrMapFirstName'] || 'firstName';
     const attrMapLastName = config.crowi['security:passport-saml:attrMapLastName'] || 'lastName';
     const attrMapLastName = config.crowi['security:passport-saml:attrMapLastName'] || 'lastName';
-    const response = await promisifiedPassportAuthentication(req, res, next, strategyName);
+
+    const response = await promisifiedPassportAuthentication(req, res, loginFailure, strategyName);
     const userInfo = {
     const userInfo = {
       'id': response[attrMapId],
       'id': response[attrMapId],
       'username': response[attrMapUsername],
       'username': response[attrMapUsername],
-      'name': `${response[attrMapFirstName]} ${response[attrMapLastName]}`,
+      'email': response[attrMapMail]
     };
     };
 
 
-    const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
+    // determine name
+    const firstName = response[attrMapFirstName];
+    const lastName = response[attrMapLastName];
+    if (firstName != null || lastName != null) {
+      userInfo['name'] = `${response[attrMapFirstName]} ${response[attrMapLastName]}`.trim();
+    }
+
+    const externalAccount = await getOrCreateUser(req, res, loginFailure, userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
     }
 
 
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
 
 
     // login
     // login
     req.logIn(user, err => {
     req.logIn(user, err => {
-      if (err) { return next(err) }
+      if (err != null) {
+        logger.error(err);
+        return loginFailure(req, res);
+      }
       return loginSuccess(req, res, user);
       return loginSuccess(req, res, user);
     });
     });
   };
   };
@@ -356,17 +368,22 @@ module.exports = function(crowi, app) {
           return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
           return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
         }
         }
 
 
+        logger.debug(`--- authenticate with ${strategyName} strategy ---`);
+
         if (err) {
         if (err) {
           logger.error(`'${strategyName}' passport authentication error: `, err);
           logger.error(`'${strategyName}' passport authentication error: `, err);
-          req.flash('warningMessage', `Error occured in '${strategyName}' passport authentication`);
-          return next(); // pass and the flash message is displayed when all of authentications are failed.
+          req.flash('warningMessage', `Error occured in '${strategyName}' passport authentication`);  // pass and the flash message is displayed when all of authentications are failed.
+          return next(req, res);
         }
         }
 
 
         // authentication failure
         // authentication failure
         if (!response) {
         if (!response) {
-          return next();
+          return next(req, res);
         }
         }
 
 
+        logger.debug('response', response);
+        logger.debug('info', info);
+
         resolve(response);
         resolve(response);
       })(req, res, next);
       })(req, res, next);
     });
     });

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

@@ -212,7 +212,7 @@ module.exports = function(crowi, app) {
                       vars: {
                       vars: {
                         createdUser: userData,
                         createdUser: userData,
                         adminUser: adminUser,
                         adminUser: adminUser,
-                        url: config.crowi['app:url'],
+                        url: config.crowi['app:siteUrl:fixed'],
                         appTitle: appTitle,
                         appTitle: appTitle,
                       }
                       }
                     },
                     },

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

@@ -734,7 +734,7 @@ module.exports = function(crowi, app) {
   api.list = function(req, res) {
   api.list = function(req, res) {
     const username = req.query.user || null;
     const username = req.query.user || null;
     const path = req.query.path || null;
     const path = req.query.path || null;
-    const limit = 50;
+    const limit = + req.query.limit || 50;
     const offset = parseInt(req.query.offset) || 0;
     const offset = parseInt(req.query.offset) || 0;
 
 
     const pagerOptions = { offset: offset, limit: limit };
     const pagerOptions = { offset: offset, limit: limit };
@@ -1262,5 +1262,37 @@ module.exports = function(crowi, app) {
     });
     });
   };
   };
 
 
+  api.recentCreated = async function(req, res) {
+    const pageId = req.query.page_id;
+
+    if (pageId == null) {
+      return res.json(ApiResponse.error('param \'pageId\' must not be null'));
+    }
+
+    const page = await Page.findPageById(pageId);
+    if (page == null) {
+      return res.json(ApiResponse.error(`Page (id='${pageId}') does not exist`));
+    }
+    if (!isUserPage(page.path)) {
+      return res.json(ApiResponse.error(`Page (id='${pageId}') is not a user home`));
+    }
+
+    const limit = + req.query.limit || 50;
+    const offset = + req.query.offset || 0;
+    const queryOptions = { offset: offset, limit: limit };
+
+    try {
+      let pages = await Page.findListByCreator(page.creator, queryOptions, req.user);
+
+      const result = {};
+      result.pages = pagePathUtils.encodePagesPath(pages);
+
+      return res.json(ApiResponse.success(result));
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+  };
+
   return actions;
   return actions;
 };
 };

+ 13 - 4
src/server/service/passport.js

@@ -298,7 +298,9 @@ 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['security:passport-google:callbackUrl'] || process.env.OAUTH_GOOGLE_CALLBACK_URI,
+      callbackURL: (config.crowi['app:siteUrl'] != null)
+        ? `${config.crowi['app:siteUrl']}/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
       skipUserProfile: false,
       skipUserProfile: false,
     }, function(accessToken, refreshToken, profile, done) {
     }, function(accessToken, refreshToken, profile, done) {
       if (profile) {
       if (profile) {
@@ -343,7 +345,9 @@ 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['security:passport-github:callbackUrl'] || process.env.OAUTH_GITHUB_CALLBACK_URI,
+      callbackURL: (config.crowi['app:siteUrl'] != null)
+        ? `${config.crowi['app:siteUrl']}/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
       skipUserProfile: false,
       skipUserProfile: false,
     }, function(accessToken, refreshToken, profile, done) {
     }, function(accessToken, refreshToken, profile, done) {
       if (profile) {
       if (profile) {
@@ -388,7 +392,9 @@ 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['security:passport-twitter:callbackUrl'] || process.env.OAUTH_TWITTER_CALLBACK_URI,
+      callbackURL: (config.crowi['app:siteUrl'] != null)
+        ? `${config.crowi['app:siteUrl']}/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
       skipUserProfile: false,
       skipUserProfile: false,
     }, function(accessToken, refreshToken, profile, done) {
     }, function(accessToken, refreshToken, profile, done) {
       if (profile) {
       if (profile) {
@@ -431,9 +437,12 @@ class PassportService {
 
 
     debug('SamlStrategy: setting up..');
     debug('SamlStrategy: setting up..');
     passport.use(new SamlStrategy({
     passport.use(new SamlStrategy({
-      path: config.crowi['security:passport-saml:callbackUrl'] || process.env.SAML_CALLBACK_URI,
       entryPoint: config.crowi['security:passport-saml:entryPoint'] || process.env.SAML_ENTRY_POINT,
       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,
       issuer: config.crowi['security:passport-saml:issuer'] || process.env.SAML_ISSUER,
+      cert: config.crowi['security:passport-saml:cert'] || process.env.SAML_CERT,
     }, function(profile, done) {
     }, function(profile, done) {
       if (profile) {
       if (profile) {
         return done(null, profile);
         return done(null, profile);

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

@@ -20,7 +20,7 @@ module.exports = function(config) {
   }
   }
 
 
   lib.createAuthUrl = function(req, callback) {
   lib.createAuthUrl = function(req, callback) {
-    var callbackUrl = config.crowi['app:url'] + '/google/callback';
+    var callbackUrl = config.crowi['app:siteUrl:fixed'] + '/google/callback';
     var oauth2Client = createOauth2Client(callbackUrl);
     var oauth2Client = createOauth2Client(callbackUrl);
     google.options({auth: oauth2Client});
     google.options({auth: oauth2Client});
 
 
@@ -33,7 +33,7 @@ module.exports = function(config) {
   };
   };
 
 
   lib.handleCallback = function(req, callback) {
   lib.handleCallback = function(req, callback) {
-    var callbackUrl = config.crowi['app:url'] + '/google/callback';
+    var callbackUrl = config.crowi['app:siteUrl:fixed'] + '/google/callback';
     var oauth2Client = createOauth2Client(callbackUrl);
     var oauth2Client = createOauth2Client(callbackUrl);
     google.options({auth: oauth2Client});
     google.options({auth: oauth2Client});
 
 

+ 6 - 6
src/server/util/slack.js

@@ -46,8 +46,8 @@ module.exports = function(crowi) {
 
 
   const convertMarkdownToMrkdwn = function(body) {
   const convertMarkdownToMrkdwn = function(body) {
     var url = '';
     var url = '';
-    if (config.crowi && config.crowi['app:url']) {
-      url = config.crowi['app:url'];
+    if (config.crowi && config.crowi['app:siteUrl:fixed']) {
+      url = config.crowi['app:siteUrl:fixed'];
     }
     }
 
 
     body = body
     body = body
@@ -113,7 +113,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:url'] || '';
+    const url = config.crowi['app:siteUrl:fixed'] || '';
     let body = page.revision.body;
     let body = page.revision.body;
 
 
     if (updateType == 'create') {
     if (updateType == 'create') {
@@ -148,7 +148,7 @@ module.exports = function(crowi) {
   };
   };
 
 
   const prepareSlackMessageForComment = function(comment, user, channel, path) {
   const prepareSlackMessageForComment = function(comment, user, channel, path) {
-    const url = config.crowi['app:url'] || '';
+    const url = config.crowi['app:siteUrl:fixed'] || '';
     const body = prepareAttachmentTextForComment(comment);
     const body = prepareAttachmentTextForComment(comment);
 
 
     const attachment = {
     const attachment = {
@@ -175,7 +175,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:url'] || '';
+    const url = config.crowi['app:siteUrl:fixed'] || '';
 
 
     const pageUrl = `<${url}${path}|${path}>`;
     const pageUrl = `<${url}${path}|${path}>`;
     if (updateType == 'create') {
     if (updateType == 'create') {
@@ -189,7 +189,7 @@ module.exports = function(crowi) {
   };
   };
 
 
   const getSlackMessageTextForComment = function(path, user) {
   const getSlackMessageTextForComment = function(path, user) {
-    const url = config.crowi['app:url'] || '';
+    const url = config.crowi['app:siteUrl:fixed'] || '';
     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}`;
 
 

+ 9 - 1
src/server/views/admin/app.html

@@ -38,11 +38,19 @@
         <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('') }}">
+            <input class="form-control" 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">

+ 15 - 0
src/server/views/admin/customize.html

@@ -284,6 +284,21 @@
           </div>
           </div>
         </div>
         </div>
 
 
+        <div class="form-group">
+          <label for="settingForm[customize:showRecentCreatedNumber]" class="col-xs-3 control-label">{{ t("customize_page.show_document_number") }}</label>
+          <div class="col-xs-5">
+            <select class="form-control selectpicker" name="settingForm[customize:showRecentCreatedNumber]" value="{{ settingForm['customize:showRecentCreatedNumber'] }}">
+              <option value="10" {% if 10 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>10</option>
+              <option value="30" {% if 30 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>30</option>
+              <option value="50" {% if 50 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>50</option>
+            </select>
+
+            <p class="help-block">
+              {{ t("customize_page.show_document_number_desc") }}
+            </p>
+          </div>
+        </div>
+
         <div class="form-group">
         <div class="form-group">
           <div class="col-xs-offset-3 col-xs-6">
           <div class="col-xs-offset-3 col-xs-6">
             <input type="hidden" name="_csrf" value="{{ csrf() }}">
             <input type="hidden" name="_csrf" value="{{ csrf() }}">

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

@@ -4,6 +4,7 @@
 
 
   {% 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' %}
 
 
   <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>
@@ -47,16 +48,15 @@
     </div>
     </div>
 
 
     <div class="form-group">
     <div class="form-group">
-      <label for="settingForm[security:passport-github:callbackUrl]" class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+      <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
       <div class="col-xs-6">
       <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-github:callbackUrl]" value="{{ settingForm['security:passport-github:callbackUrl'] || '' }}"
-            placeholder="http(s)://${growi.host}/passport/github/callback">
-        <p class="help-block">
-          Input <code>http(s)://${growi.host}/passport/github/callback</code><br>
-          <small>
-            {{ t("security_setting.Use env var if empty", "OAUTH_GITHUB_CALLBACK_URI") }}
-          </small>
-        </p>
+          <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
+        <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</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>
     </div>
     </div>
 
 
@@ -98,7 +98,7 @@
   </h4>
   </h4>
   <ol id="collapseHelpForGithubOauth" class="collapse">
   <ol id="collapseHelpForGithubOauth" class="collapse">
     <li>{{ t("security_setting.OAuth.GitHub.register_1", "https://github.com/settings/developers", "GitHub Developer Settings") }}</li>
     <li>{{ t("security_setting.OAuth.GitHub.register_1", "https://github.com/settings/developers", "GitHub Developer Settings") }}</li>
-    <li>{{ t("security_setting.OAuth.GitHub.register_2", "https://${growi.host}/passport/github/callback", "${growi.host}") }}</li>
+    <li>{{ t("security_setting.OAuth.GitHub.register_2", callbackUrl) }}</li>
     <li>{{ t("security_setting.OAuth.GitHub.register_3") }}</li>
     <li>{{ t("security_setting.OAuth.GitHub.register_3") }}</li>
   </ol>
   </ol>
 </div>
 </div>

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

@@ -4,6 +4,7 @@
 
 
   {% 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' %}
 
 
   <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>
@@ -47,16 +48,15 @@
     </div>
     </div>
 
 
     <div class="form-group">
     <div class="form-group">
-      <label for="settingForm[security:passport-google:callbackUrl]" class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+      <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
       <div class="col-xs-6">
       <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-google:callbackUrl]" value="{{ settingForm['security:passport-google:callbackUrl'] || '' }}"
-            placeholder="http(s)://${growi.host}/passport/google/callback">
-        <p class="help-block">
-          Input <code>http(s)://${growi.host}/passport/google/callback</code><br>
-          <small>
-            {{ t("security_setting.Use env var if empty", "OAUTH_GOOGLE_CALLBACK_URI") }}
-          </small>
-        </p>
+          <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
+        <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</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>
     </div>
     </div>
 
 
@@ -100,7 +100,7 @@
     <li>{{ t("security_setting.OAuth.Google.register_1", "https://console.cloud.google.com/apis/credentials", "Google Cloud Platform API Manager") }}</li>
     <li>{{ t("security_setting.OAuth.Google.register_1", "https://console.cloud.google.com/apis/credentials", "Google Cloud Platform API Manager") }}</li>
     <li>{{ t("security_setting.OAuth.Google.register_2") }}</li>
     <li>{{ t("security_setting.OAuth.Google.register_2") }}</li>
     <li>{{ t("security_setting.OAuth.Google.register_3") }}</li>
     <li>{{ t("security_setting.OAuth.Google.register_3") }}</li>
-    <li>{{ t("security_setting.OAuth.Google.register_4", "https://${growi.host}/passport/google/callback", "${growi.host}") }}</li>
+    <li>{{ t("security_setting.OAuth.Google.register_4", callbackUrl) }}</li>
     <li>{{ t("security_setting.OAuth.Google.register_5") }}</li>
     <li>{{ t("security_setting.OAuth.Google.register_5") }}</li>
   </ol>
   </ol>
 </div>
 </div>

+ 57 - 18
src/server/views/admin/widget/passport/saml.html

@@ -35,16 +35,15 @@
     </div>
     </div>
 
 
     <div class="form-group">
     <div class="form-group">
-      <label for="settingForm[security:passport-saml:callbackUrl]" class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+      <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
       <div class="col-xs-6">
       <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-saml:callbackUrl]" value="{{ settingForm['security:passport-saml:callbackUrl'] || '' }}"
-            placeholder="http(s)://${growi.host}/passport/saml/callback">
-        <p class="help-block">
-          Input <code>http(s)://${growi.host}/passport/saml/callback</code><br>
-          <small>
-            {{ t("security_setting.Use env var if empty", "SAML_ISSUER") }}
-          </small>
-        </p>
+        <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>
     </div>
     </div>
 
 
@@ -63,13 +62,13 @@
     <h4>Attribute Mapping</h4>
     <h4>Attribute Mapping</h4>
 
 
     <div class="form-group">
     <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapId]" class="col-xs-3 control-label">User ID</label>
+      <label for="settingForm[security:passport-saml:attrMapId]" class="col-xs-3 control-label">Identifier</label>
       <div class="col-xs-6">
       <div class="col-xs-6">
-        <input class="form-control" type="text" placeholder="Default: id"
+        <input class="form-control" type="text"
             name="settingForm[security:passport-saml:attrMapId]" value="{{ settingForm['security:passport-saml:attrMapId'] || '' }}">
             name="settingForm[security:passport-saml:attrMapId]" value="{{ settingForm['security:passport-saml:attrMapId'] || '' }}">
         <p class="help-block">
         <p class="help-block">
           <small>
           <small>
-            {{ t("security_setting.SAML.mapping_detail", "User ID") }}
+            {{ t("security_setting.SAML.id_detail") }}
           </small>
           </small>
         </p>
         </p>
       </div>
       </div>
@@ -78,11 +77,11 @@
     <div class="form-group">
     <div class="form-group">
       <label for="settingForm[security:passport-saml:attrMapUsername]" class="col-xs-3 control-label">Username</label>
       <label for="settingForm[security:passport-saml:attrMapUsername]" class="col-xs-3 control-label">Username</label>
       <div class="col-xs-6">
       <div class="col-xs-6">
-        <input class="form-control" type="text" placeholder="Default: username"
+        <input class="form-control" type="text"
             name="settingForm[security:passport-saml:attrMapUsername]" value="{{ settingForm['security:passport-saml:attrMapUsername'] || '' }}">
             name="settingForm[security:passport-saml:attrMapUsername]" value="{{ settingForm['security:passport-saml:attrMapUsername'] || '' }}">
         <p class="help-block">
         <p class="help-block">
           <small>
           <small>
-            {{ t("security_setting.SAML.mapping_detail", "Username") }}
+            {{ t("security_setting.SAML.username_detail") }}
           </small>
           </small>
         </p>
         </p>
       </div>
       </div>
@@ -106,26 +105,66 @@
     </div>
     </div>
 
 
     <div class="form-group">
     <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapFirstName]" class="col-xs-3 control-label">First Name</label>
+      <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">
+      <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">
       <div class="col-xs-6">
         <input class="form-control" type="text" placeholder="Default: firstName"
         <input class="form-control" type="text" placeholder="Default: firstName"
             name="settingForm[security:passport-saml:attrMapFirstName]" value="{{ settingForm['security:passport-saml:attrMapFirstName'] || '' }}">
             name="settingForm[security:passport-saml:attrMapFirstName]" value="{{ settingForm['security:passport-saml:attrMapFirstName'] || '' }}">
         <p class="help-block">
         <p class="help-block">
           <small>
           <small>
-            {{ t("security_setting.SAML.mapping_detail", "First Name") }}
+            {{ t("security_setting.SAML.mapping_detail", t("security_setting.SAML.First Name")) }}
           </small>
           </small>
         </p>
         </p>
       </div>
       </div>
     </div>
     </div>
 
 
     <div class="form-group">
     <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapLastName]" class="col-xs-3 control-label">Last Name</label>
+      <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">
       <div class="col-xs-6">
         <input class="form-control" type="text" placeholder="Default: lastName"
         <input class="form-control" type="text" placeholder="Default: lastName"
             name="settingForm[security:passport-saml:attrMapLastName]" value="{{ settingForm['security:passport-saml:attrMapLastName'] || '' }}">
             name="settingForm[security:passport-saml:attrMapLastName]" value="{{ settingForm['security:passport-saml:attrMapLastName'] || '' }}">
         <p class="help-block">
         <p class="help-block">
           <small>
           <small>
-            {{ t("security_setting.SAML.mapping_detail", "Last Name") }}
+            {{ 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>
           </small>
         </p>
         </p>
       </div>
       </div>

+ 10 - 8
src/server/views/admin/widget/passport/twitter.html

@@ -4,6 +4,7 @@
 
 
   {% 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' %}
 
 
   <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>
@@ -49,14 +50,15 @@
     </div>
     </div>
 
 
     <div class="form-group">
     <div class="form-group">
-      <label for="settingForm[security:passport-twitter:callbackUrl]" class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+      <label class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
       <div class="col-xs-6">
       <div class="col-xs-6">
-        <input class="form-control" type="text" name="settingForm[security:passport-twitter:callbackUrl]" value="{{ settingForm['security:passport-twitter:callbackUrl'] || '' }}">
-        <p class="help-block">
-          <small>
-            {{ t("security_setting.Use env var if empty", "OAUTH_TWITTER_CALLBACK_URL") }}
-          </small>
-        </p>
+          <input class="form-control" type="text" value="{{ callbackUrl }}" readonly>
+        <p class="help-block small">{{ t("security_setting.desc_of_callback_URL", 'OAuth') }}</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>
     </div>
     </div>
 
 
@@ -101,7 +103,7 @@
     <li>{{ t("security_setting.OAuth.Twitter.register_1", "https://apps.twitter.com/", "Twitter Application Management") }}</li>
     <li>{{ t("security_setting.OAuth.Twitter.register_1", "https://apps.twitter.com/", "Twitter Application Management") }}</li>
     <li>{{ t("security_setting.OAuth.Twitter.register_2") }}</li>
     <li>{{ t("security_setting.OAuth.Twitter.register_2") }}</li>
     <li>{{ t("security_setting.OAuth.Twitter.register_3") }}</li>
     <li>{{ t("security_setting.OAuth.Twitter.register_3") }}</li>
-    <li>{{ t("security_setting.OAuth.Twitter.register_4", "https://${growi.host}/passport/twitter/callback", "${growi.host}") }}</li>
+    <li>{{ t("security_setting.OAuth.Twitter.register_4", callbackUrl) }}</li>
   </ol>
   </ol>
 </div>
 </div>
 
 

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

@@ -46,7 +46,7 @@
 
 
         <div id="template-form" class="row form-horizontal m-t-15">
         <div id="template-form" class="row form-horizontal m-t-15">
           <fieldset class="col-xs-12">
           <fieldset class="col-xs-12">
-            <legend>{{ t('template.modal_label.Create template under', parentPath(path | preventXss)) }}</legend>
+            <legend>{{ t('template.modal_label.Create template under', parentPath(path | preventXss | escape)) }}</legend>
             <div class="d-flex create-page-input-container">
             <div class="d-flex create-page-input-container">
               <div class="create-page-input-row d-flex align-items-center">
               <div class="create-page-input-row d-flex align-items-center">
                 <select id="template-type" class="form-control selectpicker" title="{{ t('template.option_label.select') }}">
                 <select id="template-type" class="form-control selectpicker" title="{{ t('template.option_label.select') }}">

+ 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:url'] }}</span>
+                <span class="input-group-addon">{{ config.crowi['app:siteUrl:fixed'] }}</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('New page name') }}</label><br>
               <label for="newPageName">{{ t('New page name') }}</label><br>
               <div class="input-group">
               <div class="input-group">
-                <span class="input-group-addon">{{ config.crowi['app:url'] }}</span>
+                <span class="input-group-addon">{{ config.crowi['app:siteUrl:fixed'] }}</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>

+ 0 - 5
src/server/views/widget/user_page_content.html

@@ -27,11 +27,6 @@
 
 
     <div class="tab-pane user-created-list page-list" id="user-created-list">
     <div class="tab-pane user-created-list page-list" id="user-created-list">
       <div class="page-list-container">
       <div class="page-list-container">
-        {% if createdList.length == 0 %}
-        No created pages yet.
-        {% else %}
-          {% include 'page_list.html' with { pages: createdList } %}
-        {% endif %}
       </div>
       </div>
     </div>
     </div>
   </div>
   </div>

+ 5 - 5
src/test/crowi/crowi.test.js

@@ -13,21 +13,21 @@ describe('Test for Crowi application context', function() {
 
 
   describe('construction', function() {
   describe('construction', function() {
     it('initialize crowi context', function() {
     it('initialize crowi context', function() {
-      const crowi = new Crowi(helpers.root(), process.env);
+      const crowi = new Crowi(helpers.root());
       expect(crowi).to.be.instanceof(Crowi);
       expect(crowi).to.be.instanceof(Crowi);
       expect(crowi.version).to.equal(require('../../../package.json').version);
       expect(crowi.version).to.equal(require('../../../package.json').version);
       expect(crowi.env).to.be.an('Object');
       expect(crowi.env).to.be.an('Object');
     });
     });
 
 
     it('config getter, setter', function() {
     it('config getter, setter', function() {
-      const crowi = new Crowi(helpers.root(), process.env);
+      const crowi = new Crowi(helpers.root());
       expect(crowi.getConfig()).to.deep.equals({});
       expect(crowi.getConfig()).to.deep.equals({});
       crowi.setConfig({test: 1});
       crowi.setConfig({test: 1});
       expect(crowi.getConfig()).to.deep.equals({test: 1});
       expect(crowi.getConfig()).to.deep.equals({test: 1});
     });
     });
 
 
     it('model getter, setter', function() {
     it('model getter, setter', function() {
-      const crowi = new Crowi(helpers.root(), process.env);
+      const crowi = new Crowi(helpers.root());
       // set
       // set
       crowi.model('hoge', { fuga: 1 });
       crowi.model('hoge', { fuga: 1 });
       expect(crowi.model('hoge')).to.deep.equals({ fuga: 1 });
       expect(crowi.model('hoge')).to.deep.equals({ fuga: 1 });
@@ -39,9 +39,9 @@ describe('Test for Crowi application context', function() {
       mongoose.disconnect(); // avoid error of Trying to open unclosed connection
       mongoose.disconnect(); // avoid error of Trying to open unclosed connection
     });
     });
     it('setup completed', function(done) {
     it('setup completed', function(done) {
-      const crowi = new Crowi(helpers.root(), process.env);
+      const crowi = new Crowi(helpers.root());
       // set
       // set
-      const p = crowi.setupDatabase()
+      const p = crowi.setupDatabase();
       expect(p).to.instanceof(Promise);
       expect(p).to.instanceof(Promise);
       p.then(function() {
       p.then(function() {
         expect(mongoose.connection.readyState).to.equals(1);
         expect(mongoose.connection.readyState).to.equals(1);

+ 2 - 2
src/test/utils.js

@@ -16,7 +16,7 @@ before('Create database connection and clean up', function (done) {
     return done();
     return done();
   }
   }
 
 
-  mongoose.connect(mongoUri);
+  mongoose.connect(mongoUri, { useNewUrlParser: true });
 
 
   function clearDB() {
   function clearDB() {
     for (var i in mongoose.connection.collections) {
     for (var i in mongoose.connection.collections) {
@@ -26,7 +26,7 @@ before('Create database connection and clean up', function (done) {
   }
   }
 
 
   if (mongoose.connection.readyState === 0) {
   if (mongoose.connection.readyState === 0) {
-    mongoose.connect(mongoUri, function (err) {
+    mongoose.connect(mongoUri, { useNewUrlParser: true }, function (err) {
       if (err) {
       if (err) {
         throw err;
         throw err;
       }
       }

Datei-Diff unterdrückt, da er zu groß ist
+ 379 - 145
yarn.lock


Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.