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

Merge pull request #637 from weseek/master

release v3.2.4
Yuki Takei 7 лет назад
Родитель
Сommit
970bcf5cd8
77 измененных файлов с 1941 добавлено и 657 удалено
  1. 18 1
      CHANGES.md
  2. 7 2
      README.md
  3. 1 0
      config/env.dev.js
  4. 29 0
      config/migrate.js
  5. 21 12
      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. 70 50
      src/client/js/app.js
  13. 28 27
      src/client/js/components/Page.js
  14. 6 0
      src/client/js/components/PageEditor/AbstractEditor.js
  15. 14 0
      src/client/js/components/PageEditor/CodeMirrorEditor.js
  16. 27 1
      src/client/js/components/PageEditor/Editor.js
  17. 149 0
      src/client/js/components/PageEditor/HandsontableModal.jsx
  18. 54 0
      src/client/js/components/PageEditor/HandsontableUtil.js
  19. 3 2
      src/client/js/components/PageEditor/MarkdownTableInterceptor.js
  20. 62 71
      src/client/js/components/PageEditor/MarkdownTableUtil.js
  21. 4 6
      src/client/js/components/PageEditor/OptionsSelector.js
  22. 191 0
      src/client/js/components/RecentCreated/RecentCreated.jsx
  23. 0 19
      src/client/js/legacy/crowi.js
  24. 82 0
      src/client/js/models/MarkdownTable.js
  25. 15 0
      src/client/js/util/Crowi.js
  26. 7 2
      src/client/js/util/GrowiRenderer.js
  27. 16 0
      src/client/js/util/markdown-it/header-with-edit-link.js
  28. 18 0
      src/client/js/util/markdown-it/table-with-handsontable-button.js
  29. 16 14
      src/client/styles/agile-admin/inverse/colors/_apply-colors.scss
  30. 12 6
      src/client/styles/agile-admin/inverse/colors/halloween.scss
  31. 14 7
      src/client/styles/agile-admin/inverse/colors/island.scss
  32. 15 9
      src/client/styles/agile-admin/inverse/colors/wood.scss
  33. 15 1
      src/client/styles/scss/_mixins.scss
  34. 14 6
      src/client/styles/scss/_on-edit.scss
  35. 3 0
      src/client/styles/scss/_override-handsontable.scss
  36. 29 0
      src/client/styles/scss/_page.scss
  37. 3 0
      src/client/styles/scss/_vendor.scss
  38. 3 0
      src/client/styles/scss/style.scss
  39. 47 0
      src/migrations/20180926134048-make-email-unique.js
  40. 89 0
      src/migrations/20180927102719-init-serverurl.js
  41. 3 1
      src/server/.node-dev.json
  42. 3 3
      src/server/app.js
  43. 47 11
      src/server/crowi/dev.js
  44. 4 3
      src/server/crowi/express-init.js
  45. 33 52
      src/server/crowi/index.js
  46. 2 1
      src/server/form/admin/app.js
  47. 2 1
      src/server/form/admin/customfeatures.js
  48. 0 1
      src/server/form/admin/securityPassportGitHub.js
  49. 0 1
      src/server/form/admin/securityPassportGoogle.js
  50. 8 8
      src/server/form/admin/securityPassportSaml.js
  51. 0 1
      src/server/form/admin/securityPassportTwitter.js
  52. 41 65
      src/server/models/config.js
  53. 36 26
      src/server/models/page.js
  54. 21 4
      src/server/models/user.js
  55. 3 3
      src/server/routes/attachment.js
  56. 3 3
      src/server/routes/hackmd.js
  57. 1 0
      src/server/routes/index.js
  58. 28 11
      src/server/routes/login-passport.js
  59. 1 1
      src/server/routes/login.js
  60. 29 1
      src/server/routes/page.js
  61. 13 4
      src/server/service/passport.js
  62. 2 2
      src/server/util/googleAuth.js
  63. 6 6
      src/server/util/slack.js
  64. 9 1
      src/server/views/admin/app.html
  65. 15 0
      src/server/views/admin/customize.html
  66. 10 10
      src/server/views/admin/widget/passport/github.html
  67. 10 10
      src/server/views/admin/widget/passport/google-oauth.html
  68. 57 18
      src/server/views/admin/widget/passport/saml.html
  69. 10 8
      src/server/views/admin/widget/passport/twitter.html
  70. 1 1
      src/server/views/layout-crowi/base/layout.html
  71. 1 1
      src/server/views/layout-growi/base/layout.html
  72. 1 1
      src/server/views/modal/duplicate.html
  73. 1 1
      src/server/views/modal/rename.html
  74. 0 5
      src/server/views/widget/user_page_content.html
  75. 5 5
      src/test/crowi/crowi.test.js
  76. 2 2
      src/test/utils.js
  77. 380 142
      yarn.lock

+ 18 - 1
CHANGES.md

@@ -1,8 +1,25 @@
 CHANGES
 ========
 
-## 3.2.3-RC
+## 3.2.4-RC
 
+* 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
+* Support: Mongoose migration mechanism
+* Support: Upgrade libs
+    * googleapis
+    * mocha
+    * mongoose
+    * mongoose-paginate
+    * mongoose-unique-validator
+    * multer
+
+## 3.2.3
+
+* Feature: Kibela like layout
+* Improvement: Custom newpage separator for presentation view
 * Support: Shrink image size for themes which recently added
 
 ## 3.2.2

+ 7 - 2
README.md

@@ -173,10 +173,15 @@ Environment Variables
 * **Option (Overwritable in admin page)**
     * OAUTH_GOOGLE_CLIENT_ID: Google API client id 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_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

+ 1 - 0
config/env.dev.js

@@ -8,4 +8,5 @@ module.exports = {
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',
   ],
+  // 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 - 12
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.2.3-RC",
+  "version": "3.2.4-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -37,10 +37,16 @@
     "heroku-postbuild": "sh bin/heroku/install-plugins.sh && npm run build:prod",
     "lint:fix": "eslint . --fix",
     "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",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
     "prebuild:dev:app": "env-cmd config/env.dev.js npm run plugin:def",
     "prebuild:prod": "npm run plugin:def",
+    "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
     "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",
@@ -48,7 +54,7 @@
     "server:prod": "env-cmd config/env.prod.js node src/server/app.js",
     "server": "npm run server:dev",
     "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\"",
     "webpack": "webpack"
   },
@@ -79,7 +85,7 @@
     "express-sanitizer": "^1.0.4",
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^33.0.0",
+    "googleapis": "^34.0.0",
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "helmet": "^3.13.0",
@@ -90,12 +96,13 @@
     "markdown-it-blockdiag": "^1.0.2",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
+    "migrate-mongo": "^4.0.0",
     "mkdirp": "~0.5.1",
     "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-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
@@ -116,6 +123,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
+    "@handsontable/react": "^1.1.0",
     "autoprefixer": "^9.0.0",
     "babel-core": "^6.25.0",
     "babel-loader": "^7.1.1",
@@ -144,6 +152,7 @@
     "eslint-plugin-react": "^7.7.0",
     "extract-text-webpack-plugin": "^4.0.0-beta.0",
     "file-loader": "^2.0.0",
+    "handsontable": "^5.0.1",
     "i18next-browser-languagedetector": "^2.2.0",
     "imports-loader": "^0.8.0",
     "jquery-slimscroll": "^1.3.8",
@@ -162,7 +171,7 @@
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^2.7.4",
-    "mocha": "^5.0.0",
+    "mocha": "^5.2.0",
     "morgan": "^1.9.0",
     "node-dev": "^3.1.3",
     "node-sass": "^4.5.0",
@@ -180,9 +189,9 @@
     "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^5.0.4",
     "react-dom": "^16.4.1",
-    "react-dropzone": "^5.0.1",
+    "react-dropzone": "^6.0.2",
     "react-frame-component": "^4.0.0",
-    "react-i18next": "^7.6.1",
+    "react-i18next": "=7.13.0",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.1.0",
     "simple-load-script": "^1.0.2",
@@ -192,11 +201,11 @@
     "style-loader": "^0.23.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
-    "uglifyjs-webpack-plugin": "^1.2.5",
+    "uglifyjs-webpack-plugin": "^2.0.1",
     "url-join": "^4.0.0",
     "webpack": "^4.12.0",
     "webpack-assets-manifest": "^3.0.1",
-    "webpack-bundle-analyzer": "^2.9.0",
+    "webpack-bundle-analyzer": "^3.0.2",
     "webpack-cli": "^3.0.8",
     "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",
     "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.",
+    "Site URL": "Site URL",
+    "siteurl_help": "Site full URL beginning from <code>http://</code> or <code>https://</code>.",
     "Confidential name": "Confidential name",
     "ex): internal use only":"ex): internal use only",
     "enable_files_except_image": "Enable file upload other than image files.",
@@ -308,6 +310,7 @@
     "auth_mechanism": "authentication mechanism",
     "recommended": "Recommended",
     "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",
     "saml_auth": "SAML authentication",
     "google_auth2": "Google OAuth authentication",
@@ -331,6 +334,7 @@
     "xss_prevent_setting":"Prevent XSS(Cross Site Scripting)",
     "xss_prevent_setting_link":"Go to Markdown settings",
     "callback_URL": "Callback URL",
+    "desc_of_callback_URL": "Use it in the setting of the %s provider",
     "guest_mode": {
       "deny": "Deny Unregistered Users",
       "readonly": "View Only"
@@ -379,7 +383,13 @@
       "name": "SAML",
       "entry_point": "Entry Point",
       "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": {
       "register": "Register for %s",
@@ -487,7 +497,9 @@
     "Custom script": "Custom script",
     "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_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": {

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

@@ -278,6 +278,8 @@
     "Site Name": "サイト名",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
     "header_content": "ここに入力した内容は、ヘッダー等に表示されます。",
+    "Site URL": "サイトURL",
+    "siteurl_help": "<code>http://</code> または <code>https://</code> から始まるサイトのURL。",
     "Confidential name": "コンフィデンシャル表示",
     "ex): internal use only": "例: 社外秘",
     "enable_files_except_image": "画像以外のファイルアップロードを許可",
@@ -327,6 +329,7 @@
     "auth_mechanism": "認証機構",
     "recommended": "推奨",
     "username_email_password": "ユーザー名、Eメール、パスワードでの認証",
+    "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。%s から設定してください。",
     "ldap_auth": "LDAP 認証",
     "saml_auth": "SAML 認証",
     "google_auth2": "Google OAuth 認証",
@@ -349,6 +352,7 @@
     "xss_prevent_setting":"XSS(Cross Site Scripting)対策設定",
     "xss_prevent_setting_link":"マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
+    "desc_of_callback_URL": "%s プロバイダ側の設定で利用してください。",
     "guest_mode": {
       "deny": "アカウントを持たないユーザーはアクセス不可",
       "readonly": "閲覧のみ許可"
@@ -397,17 +401,23 @@
       "name": "SAML",
       "entry_point": "エントリーポイント",
       "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": {
       "register": "%sに登録",
-      "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力<br>(<code>%s</code>は環境に合わせて変更してください)",
+      "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力",
       "Google": {
         "name": "Google OAuth",
         "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
         "register_2": "プロジェクトがない場合はプロジェクトを作成",
         "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とクライアントシークレットを入力"
       },
       "Facebook": {
@@ -424,7 +434,7 @@
       "GitHub": {
         "name": "GitHub OAuth",
         "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とクライアントシークレットを入力"
       },
       "how_to": {
@@ -504,7 +514,9 @@
     "Custom script": "カスタムスクリプト",
     "write_java": "システム全体に適用されるJavaScriptを記述できます。",
     "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": {

+ 70 - 50
src/client/js/app.js

@@ -33,6 +33,7 @@ import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import BookmarkButton   from './components/BookmarkButton';
 import NewPageNameInput from './components/NewPageNameInput';
+import RecentCreated from './components/RecentCreated/RecentCreated';
 
 import CustomCssEditor  from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
@@ -110,55 +111,11 @@ if (isEnabledPlugins) {
   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 = {};
 
-Object.keys(componentMappings).forEach((key) => {
-  const elem = document.getElementById(key);
-  if (elem) {
-    componentInstances[key] = ReactDOM.render(componentMappings[key], elem);
-  }
-});
-
-
 /**
  * save success handler when reloading is not needed
  * @param {object} page Page instance
@@ -227,10 +184,6 @@ const errorHandler = function(error) {
 
 const saveWithShortcut = function(markdown) {
   const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
-  if (editorMode == null) {
-    // do nothing
-    return;
-  }
 
   let revisionId = pageRevisionId;
   // get options
@@ -304,6 +257,57 @@ const saveWithSubmitButton = function() {
     .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
 let savePageControls = null;
 const savePageControlsElem = document.getElementById('save-page-controls');
@@ -327,6 +331,21 @@ if (savePageControlsElem) {
   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
  */
@@ -402,7 +421,8 @@ if (pageEditorOptionsSelectorElem) {
           // save
           crowi.saveEditorOptions(newEditorOptions);
           crowi.savePreviewOptions(newPreviewOptions);
-        }} />,
+        }}
+      />,
     pageEditorOptionsSelectorElem
   );
 }

+ 28 - 27
src/client/js/components/Page.js

@@ -2,6 +2,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import RevisionBody from './Page/RevisionBody';
+import HandsontableModal from './PageEditor/HandsontableModal';
+import MarkdownTable from '../models/MarkdownTable';
+import mtu from './PageEditor/MarkdownTableUtil';
 
 export default class Page extends React.Component {
 
@@ -10,43 +13,23 @@ export default class Page extends React.Component {
 
     this.state = {
       html: '',
+      markdown: '',
+      currentTargetTableArea: null
     };
 
-    this.appendEditSectionButtons = this.appendEditSectionButtons.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
     this.getHighlightedBody = this.getHighlightedBody.bind(this);
+    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
   }
 
   componentWillMount() {
     this.renderHtml(this.props.markdown, this.props.highlightKeywords);
   }
 
-  componentDidUpdate() {
-    this.appendEditSectionButtons();
-  }
-
-  componentWillReceiveProps(nextProps) {
-    this.renderHtml(nextProps.markdown, nextProps.highlightKeywords);
-  }
-
   setMarkdown(markdown) {
-    this.setState({ markdown });
     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
    * @param {string} body html strings
@@ -69,8 +52,25 @@ export default class Page extends React.Component {
     return returnBody;
   }
 
+  /**
+   * launch HandsontableModal with data specified by arguments
+   * @param beginLineNumber
+   * @param endLineNumber
+   */
+  launchHandsontableModal(beginLineNumber, endLineNumber) {
+    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));
+  }
+
+  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) {
-    var context = {
+    let context = {
       markdown,
       dom: this.revisionBodyElement,
       currentPagePath: this.props.pagePath,
@@ -85,8 +85,7 @@ export default class Page extends React.Component {
       })
       .then(() => interceptorManager.process('postPreProcess', context))
       .then(() => {
-        var parsedHTML = crowiRenderer.process(context.markdown);
-        context['parsedHTML'] = parsedHTML;
+        context['parsedHTML'] = crowiRenderer.process(context.markdown);
       })
       .then(() => interceptorManager.process('prePostProcess', context))
       .then(() => {
@@ -100,7 +99,7 @@ export default class Page extends React.Component {
       .then(() => interceptorManager.process('postPostProcess', context))
       .then(() => interceptorManager.process('preRenderHtml', context))
       .then(() => {
-        this.setState({ html: context.parsedHTML });
+        this.setState({ html: context.parsedHTML, markdown });
       })
       // process interceptors for post rendering
       .then(() => interceptorManager.process('postRenderHtml', context));
@@ -119,6 +118,7 @@ export default class Page extends React.Component {
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={true}
       />
+      <HandsontableModal ref='handsontableModal' onSave={this.saveHandlerForHandsontableModal} />
     </div>;
   }
 }
@@ -126,6 +126,7 @@ export default class Page extends React.Component {
 Page.propTypes = {
   crowi: PropTypes.object.isRequired,
   crowiRenderer: PropTypes.object.isRequired,
+  onSaveWithShortcut: PropTypes.func.isRequired,
   markdown: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   showHeadEditButton: PropTypes.bool,

+ 6 - 0
src/client/js/components/PageEditor/AbstractEditor.js

@@ -110,6 +110,12 @@ export default class AbstractEditor extends React.Component {
     }
   }
 
+  /**
+   * returns items(an array of react elements) in navigation bar for editor
+   */
+  getNavbarItems() {
+    return null;
+  }
 }
 
 AbstractEditor.propTypes = {

+ 14 - 0
src/client/js/components/PageEditor/CodeMirrorEditor.js

@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import Modal from 'react-bootstrap/es/Modal';
+import Button from 'react-bootstrap/es/Button';
 
 import InterceptorManager from '@commons/service/interceptor-manager';
 
@@ -49,6 +50,7 @@ import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import mtu from './MarkdownTableUtil';
+import HandsontableModal from './HandsontableModal';
 
 export default class CodeMirrorEditor extends AbstractEditor {
 
@@ -89,6 +91,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.renderLoadingKeymapOverlay = this.renderLoadingKeymapOverlay.bind(this);
     this.renderCheatsheetModalButton = this.renderCheatsheetModalButton.bind(this);
+
+    this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
   }
 
   init() {
@@ -641,6 +645,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
     );
   }
 
+  showHandsonTableHandler() {
+    this.refs.handsontableModal.show(mtu.getMarkdownTable(this.getCodeMirror()));
+  }
+
+  getNavbarItems() {
+    return <Button bsSize="small" onClick={ this.showHandsonTableHandler }><img src="/images/icons/editor/table.svg" width="14" /></Button>;
+  }
+
   render() {
     const mode = this.state.isGfmMode ? 'gfm' : undefined;
     const defaultEditorOptions = {
@@ -653,6 +665,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plane Text..';
 
     return <React.Fragment>
+
       <ReactCodeMirror
         ref="cm"
         className={additionalClasses}
@@ -717,6 +730,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         { this.state.isCheatsheetModalButtonShown && this.renderCheatsheetModalButton() }
       </div>
 
+      <HandsontableModal ref='handsontableModal' onSave={ table => mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }/>
     </React.Fragment>;
   }
 

+ 27 - 1
src/client/js/components/PageEditor/Editor.js

@@ -194,6 +194,31 @@ export default class Editor extends AbstractEditor {
     );
   }
 
+  renderNavbar() {
+    return (
+      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
+        <ul className="pr-4 nav nav-navbar navbar-right">
+          { this.getNavbarItems() != null && this.getNavbarItems().map((item, idx) => {
+            return <li key={idx}>{item}</li>;
+          }) }
+        </ul>
+      </div>
+    );
+  }
+
+  getNavbarItems() {
+    // wait for rendering CodeMirrorEditor or TextAreaEditor
+    if (this.getEditorSubstance() == null) {
+      return null;
+    }
+
+    // set navbar items(react elements) here that are common in CodeMirrorEditor or TextAreaEditor
+    const navbarItems = [];
+
+    // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
+    return navbarItems.concat(this.getEditorSubstance().getNavbarItems());
+  }
+
   render() {
     const flexContainer = {
       height: '100%',
@@ -220,6 +245,8 @@ export default class Editor extends AbstractEditor {
 
           { this.state.dropzoneActive && this.renderDropzoneOverlay() }
 
+          { this.renderNavbar() }
+
           {/* for PC */}
           { !isMobile &&
             <CodeMirrorEditor
@@ -253,7 +280,6 @@ export default class Editor extends AbstractEditor {
             or pasting from the clipboard.
           </span>
         </button>
-
       </div>
     );
   }

+ 149 - 0
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -0,0 +1,149 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Modal from 'react-bootstrap/es/Modal';
+import Button from 'react-bootstrap/es/Button';
+
+import Handsontable from 'handsontable';
+import { HotTable } from '@handsontable/react';
+
+import MarkdownTable from '../../models/MarkdownTable';
+import HandsontableUtil from './HandsontableUtil';
+
+export default class HandsontableModal extends React.Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      show: false,
+      markdownTableOnInit: HandsontableModal.getDefaultMarkdownTable(),
+      markdownTable: HandsontableModal.getDefaultMarkdownTable(),
+      handsontableSetting: HandsontableModal.getDefaultHandsotableSetting()
+    };
+
+    this.init = this.init.bind(this);
+    this.reset = this.reset.bind(this);
+    this.cancel = this.cancel.bind(this);
+    this.save = this.save.bind(this);
+  }
+
+  init(markdownTable) {
+    const initMarkdownTable = markdownTable || HandsontableModal.getDefaultMarkdownTable();
+    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) {
+    this.init(markdownTable);
+    this.setState({ show: true });
+  }
+
+  reset() {
+    this.setState({ markdownTable: this.state.markdownTableOnInit.clone() });
+  }
+
+  cancel() {
+    this.setState({ show: false });
+  }
+
+  save() {
+    let newMarkdownTable = this.state.markdownTable.clone();
+    newMarkdownTable.options.align = HandsontableUtil.getMarkdownTableAlignmentFrom(this.refs.hotTable.hotInstance);
+
+    if (this.props.onSave != null) {
+      this.props.onSave(newMarkdownTable);
+    }
+
+    this.setState({ show: false });
+  }
+
+  render() {
+    return (
+      <Modal show={this.state.show} onHide={this.cancel} bsSize="large">
+        <Modal.Header closeButton>
+          <Modal.Title>Edit Table</Modal.Title>
+        </Modal.Header>
+        <Modal.Body className="p-0">
+          <div className="p-4">
+            <HotTable ref='hotTable' data={this.state.markdownTable.table} settings={this.state.handsontableSetting} />
+          </div>
+        </Modal.Body>
+        <Modal.Footer>
+          <div className="d-flex justify-content-between">
+            <Button bsStyle="danger" onClick={this.reset}>Reset</Button>
+            <div className="d-flex">
+              <Button bsStyle="default" onClick={this.cancel}>Cancel</Button>
+              <Button bsStyle="primary" onClick={this.save}>Done</Button>
+            </div>
+          </div>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+  static getDefaultMarkdownTable() {
+    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');
+                }}
+              ]
+            }
+          }
+        }
+      },
+      stretchH: 'all',
+      selectionMode: 'multiple'
+    };
+  }
+}
+
+HandsontableModal.propTypes = {
+  onSave: PropTypes.func
+};

+ 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;
+  }
+}
+

+ 3 - 2
src/client/js/components/PageEditor/MarkdownTableInterceptor.js

@@ -1,6 +1,7 @@
 import { BasicInterceptor } from 'growi-pluginkit';
 
 import mtu from './MarkdownTableUtil';
+import MarkdownTable from '../../models/MarkdownTable';
 
 /**
  * Interceptor for markdown table
@@ -47,12 +48,12 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
     if (mtu.isEndOfLine(cm) && mtu.linePartOfTableRE.test(strFromBol)) {
       // get lines all of table from current position to beginning of table
       const strFromBot = mtu.getStrFromBot(cm);
-      let table = mtu.parseFromTableStringToMarkdownTable(strFromBot);
+      let table = MarkdownTable.fromMarkdownString(strFromBot);
 
       mtu.addRowToMarkdownTable(table);
 
       const strToEot = mtu.getStrToEot(cm);
-      const tableBottom = mtu.parseFromTableStringToMarkdownTable(strToEot);
+      const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
       if (tableBottom.table.length > 0) {
         table = mtu.mergeMarkdownTable([table, tableBottom]);
       }

+ 62 - 71
src/client/js/components/PageEditor/MarkdownTableUtil.js

@@ -1,5 +1,4 @@
-import markdownTable from 'markdown-table';
-import stringWidth from 'string-width';
+import MarkdownTable from '../../models/MarkdownTable';
 
 /**
  * Utility for markdown table
@@ -18,18 +17,22 @@ class MarkdownTableUtil {
     this.getBol = this.getBol.bind(this);
     this.getStrFromBot = this.getStrFromBot.bind(this);
     this.getStrToEot = this.getStrToEot.bind(this);
-
-    this.parseFromTableStringToMarkdownTable = this.parseFromTableStringToMarkdownTable.bind(this);
-    this.replaceMarkdownTableWithReformed = this.replaceMarkdownTableWithReformed.bind(this);
+    this.isInTable = this.isInTable.bind(this);
+    this.replaceFocusedMarkdownTableWithEditor = this.replaceFocusedMarkdownTableWithEditor.bind(this);
+    this.replaceMarkdownTableWithReformed = this.replaceFocusedMarkdownTableWithEditor; // alias
   }
 
   /**
    * return the postion of the BOT(beginning of table)
-   * (It is assumed that current line is a part of table)
+   * (If the cursor is not in a table, return its position)
    */
   getBot(editor) {
-    const firstLine = editor.getDoc().firstLine();
     const curPos = editor.getCursor();
+    if (!this.isInTable(editor)) {
+      return { line: curPos.line, ch: curPos.ch};
+    }
+
+    const firstLine = editor.getDoc().firstLine();
     let line = curPos.line - 1;
     for (; line >= firstLine; line--) {
       const strLine = editor.getDoc().getLine(line);
@@ -43,11 +46,15 @@ class MarkdownTableUtil {
 
   /**
    * return the postion of the EOT(end of table)
-   * (It is assumed that current line is a part of table)
+   * (If the cursor is not in a table, return its position)
    */
   getEot(editor) {
-    const lastLine = editor.getDoc().lastLine();
     const curPos = editor.getCursor();
+    if (!this.isInTable(editor)) {
+      return { line: curPos.line, ch: curPos.ch};
+    }
+
+    const lastLine = editor.getDoc().lastLine();
     let line = curPos.line + 1;
     for (; line <= lastLine; line++) {
       const strLine = editor.getDoc().getLine(line);
@@ -69,7 +76,7 @@ class MarkdownTableUtil {
   }
 
   /**
-   * return strings from BOT(beginning of table) to current position
+   * return strings from BOT(beginning of table) to the cursor position
    */
   getStrFromBot(editor) {
     const curPos = editor.getCursor();
@@ -77,7 +84,7 @@ class MarkdownTableUtil {
   }
 
   /**
-   * return strings from current position to EOT(end of table)
+   * return strings from the cursor position to EOT(end of table)
    */
   getStrToEot(editor) {
     const curPos = editor.getCursor();
@@ -85,52 +92,34 @@ class MarkdownTableUtil {
   }
 
   /**
-   * returns markdown table whose described by 'markdown-table' format
-   *   ref. https://github.com/wooorm/markdown-table
-   * @param {string} lines all of table
+   * return MarkdownTable instance of the table where the cursor is
+   * (If the cursor is not in a table, return null)
    */
-  parseFromTableStringToMarkdownTable(strMDTable) {
-    const arrMDTableLines = strMDTable.split(/(\r\n|\r|\n)/);
-    let contents = [];
-    let aligns = [];
-    for (let n = 0; n < arrMDTableLines.length; n++) {
-      const line = arrMDTableLines[n];
-
-      if (this.tableAlignmentLineRE.test(line) && !this.tableAlignmentLineNegRE.test(line)) {
-        // parse line which described alignment
-        const alignRuleRE = [
-          { align: 'c', regex: /^:-+:$/ },
-          { align: 'l', regex: /^:-+$/  },
-          { align: 'r', regex: /^-+:$/  },
-        ];
-        let lineText = '';
-        lineText = line.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
-        lineText = lineText.replace(/\s*/g, '');
-        aligns = lineText.split(/\|/).map(col => {
-          const rule = alignRuleRE.find(rule => col.match(rule.regex));
-          return (rule != undefined) ? rule.align : '';
-        });
-      }
-      else if (this.linePartOfTableRE.test(line)) {
-        // parse line whether header or body
-        let lineText = '';
-        lineText = line.replace(/\s*\|\s*/g, '|');
-        lineText = lineText.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
-        const row = lineText.split(/\|/);
-        contents.push(row);
-      }
+  getMarkdownTable(editor) {
+    if (!this.isInTable(editor)) {
+      return null;
     }
-    return (new MarkdownTable(contents, { align: aligns, stringLength: stringWidth }));
+
+    const strFromBotToEot = editor.getDoc().getRange(this.getBot(editor), this.getEot(editor));
+    return MarkdownTable.fromMarkdownString(strFromBotToEot);
   }
 
   /**
-   * return boolean value whether the current position of cursor is end of line
+   * return boolean value whether the cursor position is end of line
    */
   isEndOfLine(editor) {
     const curPos = editor.getCursor();
     return (curPos.ch == editor.getDoc().getLine(curPos.line).length);
   }
 
+  /**
+   * return boolean value whether the cursor position is in a table
+   */
+  isInTable(editor) {
+    const curPos = editor.getCursor();
+    return this.linePartOfTableRE.test(editor.getDoc().getLine(curPos.line));
+  }
+
   /**
    * add a row at the end
    * (This function overwrite directory markdown table specified as argument.)
@@ -144,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.)
    * @param {Array} array of markdown table
    */
@@ -163,36 +152,38 @@ class MarkdownTableUtil {
   }
 
   /**
-   * replace markdown table which is reformed by markdown-table
-   * @param {MarkdownTable} markdown table
+   * replace focused markdown table with editor
+   * (A replaced table is reformed by markdown-table.)
+   * @param {MarkdownTable} table
    */
-  replaceMarkdownTableWithReformed(editor, table) {
+  replaceFocusedMarkdownTableWithEditor(editor, table) {
     const curPos = editor.getCursor();
-
-    // replace the lines to strTableLinesFormated
-    const strTableLinesFormated = table.toString();
-    editor.getDoc().replaceRange(strTableLinesFormated, this.getBot(editor), this.getEot(editor));
-
-    // set cursor to first column
+    editor.getDoc().replaceRange(table.toString(), this.getBot(editor), this.getEot(editor));
     editor.getDoc().setCursor(curPos.line + 1, 2);
   }
-}
 
-/**
- * markdown table class for markdown-table module
- *   ref. https://github.com/wooorm/markdown-table
- */
-class MarkdownTable {
-
-  constructor(table, options) {
-    this.table = table || [];
-    this.options = options || {};
-
-    this.toString = this.toString.bind(this);
-  }
+  /**
+   * 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');
+    }
 
-  toString() {
-    return markdownTable(this.table, this.options);
+    return newMarkdown;
   }
 }
 

+ 4 - 6
src/client/js/components/PageEditor/OptionsSelector.js

@@ -108,9 +108,7 @@ export default class OptionsSelector extends React.Component {
    * dispatch onChange event
    */
   dispatchOnChange() {
-    if (this.props.onChange != null) {
-      this.props.onChange(this.state.editorOptions, this.state.previewOptions);
-    }
+    this.props.onChange(this.state.editorOptions, this.state.previewOptions);
   }
 
   renderThemeSelector() {
@@ -255,7 +253,7 @@ export class PreviewOptions {
 
 OptionsSelector.propTypes = {
   crowi: PropTypes.object.isRequired,
-  editorOptions: PropTypes.instanceOf(EditorOptions),
-  previewOptions: PropTypes.instanceOf(PreviewOptions),
-  onChange: PropTypes.func,
+  editorOptions: PropTypes.instanceOf(EditorOptions).isRequired,
+  previewOptions: PropTypes.instanceOf(PreviewOptions).isRequired,
+  onChange: PropTypes.func.isRequired,
 };

+ 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);
 };
 
-/**
- * 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
  * @param {number} line

+ 82 - 0
src/client/js/models/MarkdownTable.js

@@ -0,0 +1,82 @@
+import markdownTable from 'markdown-table';
+import stringWidth from 'string-width';
+
+// https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83
+// https://regex101.com/r/7BN2fR/7
+const tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
+const tableAlignmentLineNegRE = /^[^-:]*$/;  // it is need to check to ignore empty row which is matched above RE
+const linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^|\r\n]+\|[^|\r\n]*)+/; // own idea
+
+/**
+ * markdown table class for markdown-table module
+ *   ref. https://github.com/wooorm/markdown-table
+ */
+export default class MarkdownTable {
+
+  constructor(table, options) {
+    this.table = table || [];
+    this.options = options || {};
+
+    this.toString = this.toString.bind(this);
+  }
+
+  toString() {
+    return markdownTable(this.table, this.options);
+  }
+
+  /**
+   * returns cloned Markdowntable instance
+   * (This method clones only the table field.)
+   */
+  clone() {
+    let newTable = [];
+    for (let i = 0; i < this.table.length; i++) {
+      newTable.push([].concat(this.table[i]));
+    }
+    return new MarkdownTable(newTable, this.options);
+  }
+
+  static fromTableTag(str) {
+    // TODO impl
+    return new MarkdownTable();
+  }
+
+  /**
+   * returns MarkdownTable instance
+   *   ref. https://github.com/wooorm/markdown-table
+   * @param {string} str markdown string
+   */
+  static fromMarkdownString(str) {
+    const arrMDTableLines = str.split(/(\r\n|\r|\n)/);
+    let contents = [];
+    let aligns = [];
+    for (let n = 0; n < arrMDTableLines.length; n++) {
+      const line = arrMDTableLines[n];
+
+      if (tableAlignmentLineRE.test(line) && !tableAlignmentLineNegRE.test(line)) {
+        // parse line which described alignment
+        const alignRuleRE = [
+          { align: 'c', regex: /^:-+:$/ },
+          { align: 'l', regex: /^:-+$/  },
+          { align: 'r', regex: /^-+:$/  },
+        ];
+        let lineText = '';
+        lineText = line.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
+        lineText = lineText.replace(/\s*/g, '');
+        aligns = lineText.split(/\|/).map(col => {
+          const rule = alignRuleRE.find(rule => col.match(rule.regex));
+          return (rule != undefined) ? rule.align : '';
+        });
+      }
+      else if (linePartOfTableRE.test(line)) {
+        // parse line whether header or body
+        let lineText = '';
+        lineText = line.replace(/\s*\|\s*/g, '|');
+        lineText = lineText.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
+        const row = lineText.split(/\|/);
+        contents.push(row);
+      }
+    }
+    return (new MarkdownTable(contents, { align: aligns, stringLength: stringWidth }));
+  }
+}

+ 15 - 0
src/client/js/util/Crowi.js

@@ -26,6 +26,7 @@ export default class Crowi {
     this.document = window.document || {};
     this.localStorage = window.localStorage || {};
     this.socketClientId = Math.floor(Math.random() * 100000);
+    this.page = undefined;
     this.pageEditor = undefined;
 
     this.fetchUsers = this.fetchUsers.bind(this);
@@ -66,6 +67,10 @@ export default class Crowi {
     return this.config;
   }
 
+  setPage(page) {
+    this.page = page;
+  }
+
   setPageEditor(pageEditor) {
     this.pageEditor = pageEditor;
   }
@@ -227,6 +232,16 @@ export default class Crowi {
       });
   }
 
+  launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
+    let targetComponent;
+    switch (componentKind) {
+      case 'page':
+        targetComponent = this.page;
+        break;
+    }
+    targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
+  }
+
   apiGet(path, params) {
     return this.apiRequest('get', path, {params: params});
   }

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

@@ -16,6 +16,8 @@ import TableConfigurer from './markdown-it/table';
 import TaskListsConfigurer from './markdown-it/task-lists';
 import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
 import BlockdiagConfigurer from './markdown-it/blockdiag';
+import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
+import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
 
 export default class GrowiRenderer {
 
@@ -72,7 +74,6 @@ export default class GrowiRenderer {
     this.markdownItConfigurers = [
       new TaskListsConfigurer(crowi),
       new HeaderConfigurer(crowi),
-      new TableConfigurer(crowi),
       new EmojiConfigurer(crowi),
       new MathJaxConfigurer(crowi),
       new PlantUMLConfigurer(crowi),
@@ -87,16 +88,20 @@ export default class GrowiRenderer {
           new FooternoteConfigurer(crowi),
           new TocAndAnchorConfigurer(crowi, options.renderToc),
           new HeaderLineNumberConfigurer(crowi),
+          new HeaderWithEditLinkConfigurer(crowi),
+          new TableWithHandsontableButtonConfigurer(crowi)
         ]);
         break;
       case 'editor':
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
           new FooternoteConfigurer(crowi),
-          new HeaderLineNumberConfigurer(crowi)
+          new HeaderLineNumberConfigurer(crowi),
+          new TableConfigurer(crowi)
         ]);
         break;
       case 'comment':
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
+          new TableConfigurer(crowi)
         ]);
         break;
       default:

+ 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}>`;
+    };
+  }
+}

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

@@ -0,0 +1,18 @@
+export default class TableWithHandsontableButtonConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  configure(md) {
+    md.renderer.rules.table_open = (tokens, idx) => {
+      const beginLine = tokens[idx].map[0] + 1;
+      const endLine  = tokens[idx].map[1];
+      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) => {
+      return '</table></div>';
+    };
+  }
+}

+ 16 - 14
src/client/styles/agile-admin/inverse/colors/_apply-colors.scss

@@ -211,31 +211,20 @@ legend {
 /*
  * Tabs
  */
+$active-nav-tabs-bgcolor: $bodycolor !default;
 .nav.nav-tabs {
-  > li > a {
-    &, &:hover, &:focus {
-      background: transparent;
-    }
-  }
-  > li.active > a {
-    background: $bodycolor;
-    border-bottom: 1px solid $bodycolor;
-  }
-}
 
-/*
- * Tabs
- */
- .nav.nav-tabs {
   border-bottom-color: $navbar-border;
 
   > li > a {
     color:$linktext;
     &:hover, &:focus {
       color: $linktext-hover;
+      background: transparent;
     }
   }
   > li.active > a {
+    background: $active-nav-tabs-bgcolor;
     border-top-color: $navbar-border;
     border-left-color: $navbar-border;
     border-right-color: $navbar-border;
@@ -290,6 +279,16 @@ legend {
       color: $wikilinktext-hover;
     }
   }
+
+  // table with handsontable modal button
+  .editable-with-handsontable {
+    button {
+      color: $wikilinktext;
+    }
+    button:hover {
+      color: $wikilinktext-hover;
+    }
+  }
 }
 
 
@@ -309,6 +308,9 @@ body.on-edit {
 
     .page-editor-editor-container {
       border-right-color: $navbar-border;
+      .navbar-editor {
+        background-color: $active-nav-tabs-bgcolor;   // same color with active tab
+      }
     }
     .page-editor-preview-container {
       background-color: $bodycolor;

+ 12 - 6
src/client/styles/agile-admin/inverse/colors/halloween.scss

@@ -22,6 +22,7 @@ $dark: darken($bodytext, 5%);
 $border: $themecolor;
 $navbar-border: lighten($basecolor, 25%);
 $active-navbar-border: darken($navbar-border, 3%);
+$active-nav-tabs-bgcolor: #231313;
 $btn-default-bgcolor: darken($basecolor, 10%);
 $inline-code-color: #a94f04;
 $inline-code-bg: #0a121b;
@@ -63,7 +64,8 @@ $inline-code-bg: #0a121b;
   background-image: url("/images/themes/halloween/halloween-navbar.jpg");
 }
 
-.main-container > #wrapper > #page-wrapper {
+.main-container > #wrapper > #page-wrapper,
+.page-editor-preview-container {
   background-image: url("/images/themes/halloween/halloween.jpg");
   background-attachment: fixed;
 }
@@ -72,10 +74,14 @@ $inline-code-bg: #0a121b;
   background-color: rgba(0, 0, 0, 0.3);
 }
 
-/* Tabs */
-.nav.nav-tabs {
-  >li.active>a {
-    background: transparent;
-    border-bottom: 1px solid #1f1b1b;
+/*
+ * Tabs
+ */
+body:not(.on-edit) .nav.nav-tabs {
+  > li.active > a {
+    background: linear-gradient(
+      rgba($active-nav-tabs-bgcolor, 0) 0%,
+      rgba($active-nav-tabs-bgcolor, 0) 90%,
+      $active-nav-tabs-bgcolor 100%);         // overwrite only the bottom pixel
   }
 }

+ 14 - 7
src/client/styles/agile-admin/inverse/colors/island.scss

@@ -8,7 +8,7 @@ $linkcolor: #3c6d72;
 $themecolor: #97cbc3;
 $topbar: #0c2a44;
 $sidebar: $themelight;
-$bodycolor: lighten($themelight, 5%);
+$bodycolor: lighten($themelight, 10%);
 $headingtext:#3c6d72;
 $bodytext: #3c6d72;
 $linktext: $linkcolor;
@@ -22,6 +22,7 @@ $dark: darken($bodytext, 5%);
 $border: #76b1a8;
 $navbar-border: #76b1a8;
 $active-navbar-border: darken($navbar-border, 13%);
+$active-nav-tabs-bgcolor: #dbf0ed;
 $btn-default-bgcolor: darken($themecolor, 10%);
 $inline-code-color: #8f5313;
 $inline-code-bg: darken($themelight, 3%);
@@ -74,18 +75,24 @@ $inline-code-bg: darken($themelight, 3%);
   background: lighten($themelight, 5%);
 }
 
-.main-container > #wrapper > #page-wrapper {
+.main-container > #wrapper > #page-wrapper,
+.page-editor-preview-container {
   background-image: url("/images/themes/island/island.png");
   background-attachment: fixed;
 }
 
-/* Tabs */
-.nav.nav-tabs {
-  >li.active>a {
-    background: transparent;
-    border-bottom: 1px solid #d0ece7;
+/*
+ * Tabs
+ */
+ body:not(.on-edit) .nav.nav-tabs {
+  > li.active > a {
+    background: linear-gradient(
+      rgba($active-nav-tabs-bgcolor, 0) 0%,
+      rgba($active-nav-tabs-bgcolor, 0) 90%,
+      $active-nav-tabs-bgcolor 100%);         // overwrite only the bottom pixel
   }
 }
+
 /* Table */
  .table > thead > tr > th, .table > tbody > tr > th, .table > tfoot > tr > th,
  .table > thead > tr > td, .table > tbody > tr > td, .table > tfoot > tr > td ,

+ 15 - 9
src/client/styles/agile-admin/inverse/colors/wood.scss

@@ -18,6 +18,7 @@ $wikilinktext: lighten($themecolor, 5%);
 $wikilinktext-hover: lighten($wikilinktext, 15%);
 $inline-code-color: darken($themecolor, 20%);
 $inline-code-bg: lighten($subthemecolor, 70%);
+$active-nav-tabs-bgcolor: #fffffc;
 
 @import 'apply-colors';
 @import 'apply-colors-light';
@@ -39,7 +40,8 @@ $inline-code-bg: lighten($subthemecolor, 70%);
   background: $themelight;
 }
 
-.main-container > #wrapper > #page-wrapper {
+.main-container > #wrapper > #page-wrapper,
+.page-editor-preview-container {
   background-image: url("/images/themes/wood/wood.jpg");
   background-attachment: fixed;
 }
@@ -48,18 +50,22 @@ $inline-code-bg: lighten($subthemecolor, 70%);
   background-color: rgba(226, 221, 192, 0.205);
 }
 
-/* Tabs */
-.nav.nav-tabs {
-  >li.active>a {
-    background: transparent;
-    border-bottom: 1px solid $bodycolor;
-  }
-}
-
 #wrapper > .navbar > .navbar-header {
   background-image: url("/images/themes/wood/wood-navbar.jpg");
 }
 
+/*
+ * Tabs
+ */
+body:not(.on-edit) .nav.nav-tabs {
+  > li.active > a {
+    background: linear-gradient(
+      rgba($active-nav-tabs-bgcolor, 0) 0%,
+      rgba($active-nav-tabs-bgcolor, 0) 90%,
+      $active-nav-tabs-bgcolor 100%);         // overwrite only the bottom pixel
+  }
+}
+
 // login page
 .nologin {
   .input-group {

+ 15 - 1
src/client/styles/scss/_mixins.scss

@@ -16,7 +16,9 @@
 
 @mixin expand-editor($header-plus-footer) {
   $header-plus-footer: $header-plus-footer + 2px;   // add .main padding-top
-  $editor-margin: $header-plus-footer + 26px;       // add .btn-open-dropzone height
+  $editor-margin: $header-plus-footer
+                  + 25px        // add .btn-open-dropzone height
+                  + 30px;       // add .navbar-editor height
 
   .main {
     width: 100%;
@@ -47,6 +49,18 @@
           min-height: calc(100vh - #{$header-plus-footer});   // for IE11
           height: calc(100vh - #{$header-plus-footer});
         }
+
+        .navbar-editor {
+          button {
+            padding: 7px 8px;
+            line-height: 1;
+          }
+
+          img {
+            vertical-align: bottom;
+          }
+        }
+
         // left(editor)
         .page-editor-editor-container {
           min-height: calc(100vh - #{$header-plus-footer});   // for IE11

+ 14 - 6
src/client/styles/scss/_on-edit.scss

@@ -87,11 +87,12 @@ body.on-edit {
     z-index: 1;
     left: $left-margin;
     width: calc(100% - #{$left-margin} - #{$right-margin});
+    pointer-events: none;                               // disable pointer-events because it becomes an obstacle
 
-    // for crowi layout
-    > .col-md-9, .col-xs-12 {
-      padding: 0;
-      width: 100%;
+    > .header-container {
+      pointer-events: initial;                          // enable pointer-events
+      padding: 0;   // for crowi layout
+      width: 100%;  // for crowi layout
     }
 
     background: none;
@@ -168,8 +169,15 @@ body.on-edit {
       // add icon on cursor
       .autoformat-markdown-table-activated .CodeMirror-cursor {
         &: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;
         }
       }
 

+ 3 - 0
src/client/styles/scss/_override-handsontable.scss

@@ -0,0 +1,3 @@
+.modal .handsontable .wtBorder {
+  z-index: 110;
+}

+ 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
  */

+ 3 - 0
src/client/styles/scss/_vendor.scss

@@ -18,3 +18,6 @@ $bootstrap-sass-asset-helper: true;
 @import '~codemirror/lib/codemirror.css';
 @import '~codemirror/theme/elegant.css';
 @import '~codemirror/theme/eclipse.css';
+
+// import Handsontable styles
+@import '~handsontable/dist/handsontable.full.css';

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

@@ -14,6 +14,9 @@
 // override react-bootstrap-typeahead styles
 @import 'override-rbt';
 
+// override Handsontable styles
+@import 'override-handsontable';
+
 // crowi component
 @import 'admin';
 @import 'attachments';

+ 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": [
     "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 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()
-  .then(express => {
+  .then(server => {
     if (helpers.hasProcessFlag('ci')) {
       logger.info('"--ci" flag is detected. Exit process.');
-      express.close(() => {
+      server.close(() => {
         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 path = require('path');
 
@@ -50,30 +50,66 @@ class CrowiDev {
 
   /**
    *
-   *
-   * @param {any} server http server
    * @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.setupBrowserSync(app);
   }
 
   setupHeaderDebugger(app) {
-    debug('setupHeaderDebugger');
+    logger.debug('setupHeaderDebugger');
 
     app.use((req, res, next) => {
       onHeaders(res, () => {
-        debug('HEADERS GOING TO BE WRITTEN');
+        logger.debug('HEADERS GOING TO BE WRITTEN');
       });
       next();
     });
   }
 
   setupBrowserSync(app) {
-    debug('setupBrowserSync');
+    logger.debug('setupBrowserSync');
 
     const browserSync = require('browser-sync');
     const bs = browserSync.create().init({
@@ -93,12 +129,12 @@ class CrowiDev {
         && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
 
       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
       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);
       }
     }

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

@@ -57,17 +57,18 @@ module.exports = function(crowi, app) {
       , User = crowi.model('User')
       , Config = crowi.model('Config')
       ;
-    let baseUrl;
 
     app.set('tzoffset', tzoffset);
 
     req.config = config;
     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.baseUrl  = baseUrl;
+    res.locals.baseUrl  = config.crowi['app:siteUrl:fixed'];
     res.locals.config   = config;
     res.locals.env      = env;
     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;
 
   this.version = pkg.version;
@@ -45,7 +45,7 @@ function Crowi(rootdir, env) {
 
   this.models = {};
 
-  this.env = env;
+  this.env = process.env;
   this.node_env = this.env.NODE_ENV || 'development';
 
   this.port = this.env.PORT || 3000;
@@ -124,8 +124,8 @@ Crowi.prototype.getEnv = function() {
 // getter/setter of model instance
 //
 Crowi.prototype.model = function(name, model) {
-  if (model) {
-    return this.models[name] = model;
+  if (model != null) {
+    this.models[name] = model;
   }
 
   return this.models[name];
@@ -146,17 +146,17 @@ Crowi.prototype.setupDatabase = function() {
 
   const mongoUri = getMongoUrl(this.env);
 
-  return mongoose.connect(mongoUri);
+  return mongoose.connect(mongoUri, { useNewUrlParser: true });
 };
 
 Crowi.prototype.setupSessionConfig = function() {
-  var self = this
+  const self = this
     , session  = require('express-session')
-    , sessionConfig
     , sessionAge = (1000*3600*24*30)
     , redisUrl = this.env.REDISTOGO_URL || this.env.REDIS_URI || this.env.REDIS_URL || null
     , mongoUrl = getMongoUrl(this.env)
     ;
+  let sessionConfig;
 
   return new Promise(function(resolve, reject) {
     sessionConfig = {
@@ -347,56 +347,37 @@ Crowi.prototype.getTokens = function() {
   return this.tokens;
 };
 
-Crowi.prototype.start = function() {
-  var self = this
-    , server
-    , io;
-
+Crowi.prototype.start = async function() {
   // init CrowiDev
-  if (self.node_env === 'development') {
+  if (this.node_env === 'development') {
     const CrowiDev = require('./dev');
-    this.crowiDev = new CrowiDev(self);
+    this.crowiDev = new CrowiDev(this);
     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() {

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

@@ -4,7 +4,8 @@ var form = require('express-form')
   , field = form.field;
 
 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:fileUpload]').trim().toBooleanStrict()
 );

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

@@ -6,6 +6,7 @@ var form = require('express-form')
 module.exports = form(
   field('settingForm[customize:isEnabledTimeline]').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:clientId]').trim(),
   field('settingForm[security:passport-github:clientSecret]').trim(),
-  field('settingForm[security:passport-github:callbackUrl]').trim(),
   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:clientId]').trim(),
   field('settingForm[security:passport-google:clientSecret]').trim(),
-  field('settingForm[security:passport-google:callbackUrl]').trim(),
   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(
   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(),
 );

+ 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:consumerKey]').trim(),
   field('settingForm[security:passport-twitter:consumerSecret]').trim(),
-  field('settingForm[security:passport-twitter:callbackUrl]').trim(),
   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 }
   });
 
+  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
    */
@@ -67,6 +73,7 @@ module.exports = function(crowi) {
       'security:passport-ldap:groupSearchFilter' : undefined,
       'security:passport-ldap:groupDnProperty' : undefined,
       'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': false,
+      'security:passport-saml:isEnabled' : false,
       'security:passport-google:isEnabled' : false,
       'security:passport-github:isEnabled' : false,
       'security:passport-twitter:isEnabled' : false,
@@ -99,6 +106,7 @@ module.exports = function(crowi) {
       'customize:isEnabledTimeline' : true,
       'customize:isSavedStatesOfTabChanges' : true,
       'customize:isEnabledAttachTitleHeader' : false,
+      'customize:showRecentCreatedNumber' : 10,
 
       'importer:esa:team_name': '',
       'importer:esa:access_token': '',
@@ -130,6 +138,15 @@ module.exports = function(crowi) {
     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() {
     var labels = {};
     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) {
-    var originalConfig = crowi.getConfig();
-    var newNSConfig = originalConfig[ns] || {};
+    validateCrowi();
+
+    const originalConfig = crowi.getConfig();
+    const newNSConfig = originalConfig[ns] || {};
     Object.keys(config).forEach(function(key) {
       if (config[key] || config[key] === '' || config[key] === false) {
         newNSConfig[key] = config[key];
@@ -223,16 +242,11 @@ module.exports = function(crowi) {
     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, value: JSON.stringify(value) },
-      { upsert: true, },
-      function(err, config) {
-        debug('Config.findAndUpdate', err, config);
-        callback(err, config);
-      });
+      { upsert: true, });
   };
 
   configSchema.statics.getConfig = function(callback) {
@@ -310,7 +324,7 @@ module.exports = function(crowi) {
   };
 
   configSchema.statics.isUploadable = function(config) {
-    var method = crowi.env.FILE_UPLOAD || 'aws';
+    const method = process.env.FILE_UPLOAD || 'aws';
 
     if (method == 'aws' && (
       !config.crowi['aws:accessKeyId'] ||
@@ -339,78 +353,37 @@ module.exports = function(crowi) {
 
   configSchema.statics.isEnabledLinebreaks = function(config) {
     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) {
     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) {
     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) {
     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) {
     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) {
     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) {
     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)) {
       switch (this.xssOption(config)) {
         case 1: // ignore all: use default option
@@ -435,11 +408,6 @@ module.exports = function(crowi) {
   configSchema.statics.attrWhiteList = function(config) {
     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)) {
       switch (this.xssOption(config)) {
         case 1: // ignore all: use default option
@@ -496,6 +464,8 @@ module.exports = function(crowi) {
   };
 
   configSchema.statics.customTitle = function(config, page) {
+    validateCrowi();
+
     const key = 'customize:title';
     let customTitle = getValueForCrowiNS(config, key);
 
@@ -546,6 +516,11 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
   };
 
+  configSchema.statics.showRecentCreatedNumber = function(config) {
+    const key = 'customize:showRecentCreatedNumber';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.fileUploadEnabled = function(config) {
     const Config = this;
 
@@ -588,12 +563,12 @@ module.exports = function(crowi) {
 
   configSchema.statics.getLocalconfig = function(config) {
     const Config = this;
-    const env = crowi.getEnv();
+    const env = process.env;
 
     const local_config = {
       crowi: {
         title: Config.appTitle(crowi),
-        url: config.crowi['app:url'] || '',
+        url: config.crowi['app:siteUrl:fixed'] || '',
       },
       upload: {
         image: Config.isUploadable(config),
@@ -616,6 +591,7 @@ module.exports = function(crowi) {
         HACKMD_URI: env.HACKMD_URI || null,
         MATHJAX: env.MATHJAX || null,
       },
+      recentCreatedLimit: Config.showRecentCreatedNumber(config),
     };
 
     return local_config;

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

@@ -540,7 +540,7 @@ module.exports = function(crowi) {
     const Page = this;
     const templatePath = cutOffLastSlash(path);
     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
       .find({path: {$in: regexpList}})
@@ -669,38 +669,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,
       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)
    */

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

@@ -21,9 +21,14 @@ module.exports = function(crowi) {
 
     , PAGE_ITEMS        = 50
 
-    , userEvent = crowi.event('user')
-
   let userSchema;
+  let userEvent;
+
+  // init event
+  if (crowi != null) {
+    userEvent = crowi.event('user');
+    userEvent.on('activated', userEvent.onActivated);
+  }
 
   userSchema = new mongoose.Schema({
     userId: String,
@@ -55,9 +60,15 @@ module.exports = function(crowi) {
   userSchema.plugin(mongoosePaginate);
   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() {
+    validateCrowi();
+
     var Config = crowi.model('Config'),
       config = crowi.getConfig();
 
@@ -96,6 +107,8 @@ module.exports = function(crowi) {
   }
 
   function generatePassword(password) {
+    validateCrowi();
+
     var hasher = crypto.createHash('sha256');
     hasher.update(crowi.env.PASSWORD_SEED + password);
 
@@ -302,6 +315,8 @@ module.exports = function(crowi) {
   };
 
   userSchema.statics.isEmailValid = function(email, callback) {
+    validateCrowi();
+
     var config = crowi.getConfig()
       , whitelist = config.crowi['security:registrationWhiteList'];
 
@@ -573,6 +588,8 @@ module.exports = function(crowi) {
   };
 
   userSchema.statics.createUsersByInvitation = function(emailList, toSendEmail, callback) {
+    validateCrowi();
+
     var User = this
       , createdUserList = []
       , Config = crowi.model('Config')
@@ -658,7 +675,7 @@ module.exports = function(crowi) {
                 vars: {
                   email: user.email,
                   password: user.password,
-                  url: config.crowi['app:url'],
+                  url: config.crowi['app:siteUrl:fixed'],
                   appTitle: Config.appTitle(config),
                 }
               },

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

@@ -55,7 +55,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id
    */
   api.list = function(req, res) {
-    var id = req.query.page_id || null;
+    const id = req.query.page_id || null;
     if (!id) {
       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
 
       // var config = crowi.getConfig();
-      // var baseUrl = (config.crowi['app:url'] || '');
+      // var baseUrl = (config.crowi['app:siteUrl:fixed'] || '');
       return res.json(ApiResponse.success({
         attachments: attachments.map(at => {
-          var fileUrl = at.fileUrl;
+          const fileUrl = at.fileUrl;
           at = at.toObject();
           // at.url = baseUrl + 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')}`;
 
-    // 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
-    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

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

@@ -188,6 +188,7 @@ module.exports = function(crowi, app) {
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   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.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.update'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.update);
   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);
   };
 
-  const loginPassportSamlCallback = async(req, res, next) => {
+  const loginPassportSamlCallback = async(req, res) => {
     const providerId = '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 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 = {
       'id': response[attrMapId],
       '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) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const user = await externalAccount.getPopulatedUser();
 
     // login
     req.logIn(user, err => {
-      if (err) { return next(err) }
+      if (err != null) {
+        logger.error(err);
+        return loginFailure(req, res);
+      }
       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
         }
 
+        logger.debug(`--- authenticate with ${strategyName} strategy ---`);
+
         if (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
         if (!response) {
-          return next();
+          return next(req, res);
         }
 
+        logger.debug('response', response);
+        logger.debug('info', info);
+
         resolve(response);
       })(req, res, next);
     });

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

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

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

@@ -734,7 +734,7 @@ module.exports = function(crowi, app) {
   api.list = function(req, res) {
     const username = req.query.user || null;
     const path = req.query.path || null;
-    const limit = 50;
+    const limit = + req.query.limit || 50;
     const offset = parseInt(req.query.offset) || 0;
 
     const pagerOptions = { offset: offset, limit: limit };
@@ -1262,5 +1262,33 @@ module.exports = function(crowi, app) {
     });
   };
 
+  api.recentCreated = async function(req, res) {
+    const username = req.query.user || null;
+    const limit = + req.query.limit || 50;
+    const offset = + req.query.offset || 0;
+
+    const queryOptions = { offset: offset, limit: limit };
+
+    if (username == null ) {
+      return res.json(ApiResponse.error('Parameter user is required.'));
+    }
+
+    try {
+      let user = await User.findUserByUsername(username);
+      if (user == null) {
+        throw new Error('The user not found.');
+      }
+      let pages = await Page.findListByCreator(user, 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;
 };

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

@@ -298,7 +298,9 @@ class PassportService {
     passport.use(new GoogleStrategy({
       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,
-      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,
     }, function(accessToken, refreshToken, profile, done) {
       if (profile) {
@@ -343,7 +345,9 @@ class PassportService {
     passport.use(new GitHubStrategy({
       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,
-      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,
     }, function(accessToken, refreshToken, profile, done) {
       if (profile) {
@@ -388,7 +392,9 @@ class PassportService {
     passport.use(new TwitterStrategy({
       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,
-      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,
     }, function(accessToken, refreshToken, profile, done) {
       if (profile) {
@@ -431,9 +437,12 @@ class PassportService {
 
     debug('SamlStrategy: setting up..');
     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,
+      callbackUrl: (config.crowi['app:siteUrl'] != null)
+        ? `${config.crowi['app:siteUrl']}/passport/saml/callback`                                 // auto-generated with v3.2.4 and above
+        : config.crowi['security:passport-saml:callbackUrl'] || process.env.SAML_CALLBACK_URI,    // DEPRECATED: backward compatible with v3.2.3 and below
       issuer: config.crowi['security:passport-saml:issuer'] || process.env.SAML_ISSUER,
+      cert: config.crowi['security:passport-saml:cert'] || process.env.SAML_CERT,
     }, function(profile, done) {
       if (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) {
-    var callbackUrl = config.crowi['app:url'] + '/google/callback';
+    var callbackUrl = config.crowi['app:siteUrl:fixed'] + '/google/callback';
     var oauth2Client = createOauth2Client(callbackUrl);
     google.options({auth: oauth2Client});
 
@@ -33,7 +33,7 @@ module.exports = function(config) {
   };
 
   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);
     google.options({auth: oauth2Client});
 

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

@@ -46,8 +46,8 @@ module.exports = function(crowi) {
 
   const convertMarkdownToMrkdwn = function(body) {
     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
@@ -113,7 +113,7 @@ module.exports = function(crowi) {
   };
 
   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;
 
     if (updateType == 'create') {
@@ -148,7 +148,7 @@ module.exports = function(crowi) {
   };
 
   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 attachment = {
@@ -175,7 +175,7 @@ module.exports = function(crowi) {
 
   const getSlackMessageTextForPage = function(path, user, updateType) {
     let text;
-    const url = config.crowi['app:url'] || '';
+    const url = config.crowi['app:siteUrl:fixed'] || '';
 
     const pageUrl = `<${url}${path}|${path}>`;
     if (updateType == 'create') {
@@ -189,7 +189,7 @@ module.exports = function(crowi) {
   };
 
   const getSlackMessageTextForComment = function(path, user) {
-    const url = config.crowi['app:url'] || '';
+    const url = config.crowi['app:siteUrl:fixed'] || '';
     const pageUrl = `<${url}${path}|${path}>`;
     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">
           <label for="settingForm[app:title]" class="col-xs-3 control-label">{{ t('app_setting.Site Name') }}</label>
           <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>
           </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">
           <label for="settingForm[app:confidential]" class="col-xs-3 control-label">{{ t('app_setting.Confidential name') }}</label>
           <div class="col-xs-6">

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

@@ -284,6 +284,21 @@
           </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="col-xs-offset-3 col-xs-6">
             <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 isGitHubEnabled = settingForm['security:passport-github:isEnabled'] %}
+  {% set callbackUrl = settingForm['app:siteUrl'] || '[INVALID]' + '/passport/github/callback' %}
 
   <div class="form-group">
     <label for="{{nameForIsGitHubEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.GitHub.name") }}</label>
@@ -47,16 +48,15 @@
     </div>
 
     <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">
-        <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>
 
@@ -98,7 +98,7 @@
   </h4>
   <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_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>
   </ol>
 </div>

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

@@ -4,6 +4,7 @@
 
   {% set nameForIsGoogleEnabled = "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">
     <label for="{{nameForIsGoogleEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Google.name") }}</label>
@@ -47,16 +48,15 @@
     </div>
 
     <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">
-        <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>
 
@@ -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_2") }}</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>
   </ol>
 </div>

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

@@ -35,16 +35,15 @@
     </div>
 
     <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">
-        <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>
 
@@ -63,13 +62,13 @@
     <h4>Attribute Mapping</h4>
 
     <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">
-        <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'] || '' }}">
         <p class="help-block">
           <small>
-            {{ t("security_setting.SAML.mapping_detail", "User ID") }}
+            {{ t("security_setting.SAML.id_detail") }}
           </small>
         </p>
       </div>
@@ -78,11 +77,11 @@
     <div class="form-group">
       <label for="settingForm[security:passport-saml:attrMapUsername]" class="col-xs-3 control-label">Username</label>
       <div class="col-xs-6">
-        <input class="form-control" type="text" placeholder="Default: username"
+        <input class="form-control" type="text"
             name="settingForm[security:passport-saml:attrMapUsername]" value="{{ settingForm['security:passport-saml:attrMapUsername'] || '' }}">
         <p class="help-block">
           <small>
-            {{ t("security_setting.SAML.mapping_detail", "Username") }}
+            {{ t("security_setting.SAML.username_detail") }}
           </small>
         </p>
       </div>
@@ -106,26 +105,66 @@
     </div>
 
     <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">
         <input class="form-control" type="text" placeholder="Default: firstName"
             name="settingForm[security:passport-saml:attrMapFirstName]" value="{{ settingForm['security:passport-saml:attrMapFirstName'] || '' }}">
         <p class="help-block">
           <small>
-            {{ t("security_setting.SAML.mapping_detail", "First Name") }}
+            {{ t("security_setting.SAML.mapping_detail", t("security_setting.SAML.First Name")) }}
           </small>
         </p>
       </div>
     </div>
 
     <div class="form-group">
-      <label for="settingForm[security:passport-saml:attrMapLastName]" class="col-xs-3 control-label">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">
         <input class="form-control" type="text" placeholder="Default: lastName"
             name="settingForm[security:passport-saml:attrMapLastName]" value="{{ settingForm['security:passport-saml:attrMapLastName'] || '' }}">
         <p class="help-block">
           <small>
-            {{ t("security_setting.SAML.mapping_detail", "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>
         </p>
       </div>

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

@@ -4,6 +4,7 @@
 
   {% set nameForIsTwitterEnabled = "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">
     <label for="{{nameForIsTwitterEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Twitter.name") }}</label>
@@ -49,14 +50,15 @@
     </div>
 
     <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">
-        <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>
 
@@ -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_2") }}</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>
 </div>
 

+ 1 - 1
src/server/views/layout-crowi/base/layout.html

@@ -25,7 +25,7 @@
   </aside>
 
   <div class="row hidden-print bg-title">
-    <div class="col-md-9">
+    <div class="col-md-9 header-container">
       {% block content_header %}
       {% endblock %}
     </div>

+ 1 - 1
src/server/views/layout-growi/base/layout.html

@@ -4,7 +4,7 @@
 <div class="container-fluid">
 
   <div class="row bg-title hidden-print">
-    <div class="col-xs-12">
+    <div class="col-xs-12 header-container">
       {% block content_header %}
       {% endblock %}
     </div>

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

@@ -16,7 +16,7 @@
             <div class="form-group">
               <label for="duplicatePageName">{{ t('modal_duplicate.label.New page name') }}</label><br>
               <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 }}">
               </div>
             </div>

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

@@ -16,7 +16,7 @@
             <div class="form-group">
               <label for="newPageName">{{ t('New page name') }}</label><br>
               <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 }}">
               </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="page-list-container">
-        {% if createdList.length == 0 %}
-        No created pages yet.
-        {% else %}
-          {% include 'page_list.html' with { pages: createdList } %}
-        {% endif %}
       </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() {
     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.version).to.equal(require('../../../package.json').version);
       expect(crowi.env).to.be.an('Object');
     });
 
     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({});
       crowi.setConfig({test: 1});
       expect(crowi.getConfig()).to.deep.equals({test: 1});
     });
 
     it('model getter, setter', function() {
-      const crowi = new Crowi(helpers.root(), process.env);
+      const crowi = new Crowi(helpers.root());
       // set
       crowi.model('hoge', { 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
     });
     it('setup completed', function(done) {
-      const crowi = new Crowi(helpers.root(), process.env);
+      const crowi = new Crowi(helpers.root());
       // set
-      const p = crowi.setupDatabase()
+      const p = crowi.setupDatabase();
       expect(p).to.instanceof(Promise);
       p.then(function() {
         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();
   }
 
-  mongoose.connect(mongoUri);
+  mongoose.connect(mongoUri, { useNewUrlParser: true });
 
   function clearDB() {
     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) {
-    mongoose.connect(mongoUri, function (err) {
+    mongoose.connect(mongoUri, { useNewUrlParser: true }, function (err) {
       if (err) {
         throw err;
       }

Разница между файлами не показана из-за своего большого размера
+ 380 - 142
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов