yusueketk 7 лет назад
Родитель
Сommit
afbafeaca2
48 измененных файлов с 1082 добавлено и 423 удалено
  1. 12 1
      CHANGES.md
  2. 7 2
      README.md
  3. 1 0
      config/env.dev.js
  4. 29 0
      config/migrate.js
  5. 18 11
      package.json
  6. 13 0
      resource/certs/localhost/cert.pem
  7. 11 0
      resource/certs/localhost/csr.pem
  8. 15 0
      resource/certs/localhost/key.pem
  9. 11 1
      resource/locales/en-US/translation.json
  10. 14 4
      resource/locales/ja/translation.json
  11. 52 53
      src/client/js/app.js
  12. 16 8
      src/client/js/components/Page.js
  13. 1 1
      src/client/js/components/PageEditor/CodeMirrorEditor.js
  14. 30 6
      src/client/js/components/PageEditor/MarkdownTableUtil.js
  15. 1 1
      src/client/js/util/markdown-it/table-with-handsontable-button.js
  16. 10 0
      src/client/styles/agile-admin/inverse/colors/_apply-colors.scss
  17. 29 0
      src/client/styles/scss/_page.scss
  18. 47 0
      src/migrations/20180926134048-make-email-unique.js
  19. 94 0
      src/migrations/20180927102719-init-serverurl.js
  20. 3 1
      src/server/.node-dev.json
  21. 3 3
      src/server/app.js
  22. 47 11
      src/server/crowi/dev.js
  23. 4 3
      src/server/crowi/express-init.js
  24. 33 52
      src/server/crowi/index.js
  25. 2 1
      src/server/form/admin/app.js
  26. 0 1
      src/server/form/admin/securityPassportGitHub.js
  27. 0 1
      src/server/form/admin/securityPassportGoogle.js
  28. 8 8
      src/server/form/admin/securityPassportSaml.js
  29. 0 1
      src/server/form/admin/securityPassportTwitter.js
  30. 19 13
      src/server/models/config.js
  31. 21 4
      src/server/models/user.js
  32. 3 3
      src/server/routes/attachment.js
  33. 3 3
      src/server/routes/hackmd.js
  34. 28 11
      src/server/routes/login-passport.js
  35. 1 1
      src/server/routes/login.js
  36. 13 4
      src/server/service/passport.js
  37. 2 2
      src/server/util/googleAuth.js
  38. 6 6
      src/server/util/slack.js
  39. 9 1
      src/server/views/admin/app.html
  40. 10 10
      src/server/views/admin/widget/passport/github.html
  41. 10 10
      src/server/views/admin/widget/passport/google-oauth.html
  42. 57 18
      src/server/views/admin/widget/passport/saml.html
  43. 10 8
      src/server/views/admin/widget/passport/twitter.html
  44. 1 1
      src/server/views/modal/duplicate.html
  45. 1 1
      src/server/views/modal/rename.html
  46. 5 5
      src/test/crowi/crowi.test.js
  47. 2 2
      src/test/utils.js
  48. 370 150
      yarn.lock

+ 12 - 1
CHANGES.md

@@ -3,7 +3,18 @@ CHANGES
 
 ## 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
 

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

+ 18 - 11
package.json

@@ -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,13 +96,14 @@
     "markdown-it-blockdiag": "^1.0.2",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
+    "migrate-mongo": "^3.0.5",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
-    "mongoose": "^5.0.0",
+    "mongoose": "^5.2.0",
     "mongoose-gridfs": "^0.5.0",
-    "mongoose-paginate": "^5.0.0",
-    "mongoose-unique-validator": "^2.0.0",
-    "multer": "~1.3.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",
@@ -165,7 +172,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",
@@ -183,9 +190,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",
@@ -195,11 +202,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"
   },

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

+ 11 - 1
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",

+ 14 - 4
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": {

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

@@ -110,59 +110,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);
-  }
-});
-
-// set page if exists
-if (componentInstances['page'] != null) {
-  crowi.setPage(componentInstances['page']);
-}
-
 /**
  * save success handler when reloading is not needed
  * @param {object} page Page instance
@@ -231,10 +183,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
@@ -308,6 +256,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');

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

@@ -4,6 +4,7 @@ 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 {
 
@@ -11,12 +12,15 @@ export default class Page extends React.Component {
     super(props);
 
     this.state = {
-      html: ''
+      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() {
@@ -27,10 +31,6 @@ export default class Page extends React.Component {
     this.appendEditSectionButtons();
   }
 
-  componentWillReceiveProps(nextProps) {
-    this.renderHtml(nextProps.markdown, nextProps.highlightKeywords);
-  }
-
   setMarkdown(markdown) {
     this.renderHtml(markdown, this.props.highlightKeywords);
   }
@@ -76,10 +76,17 @@ export default class Page extends React.Component {
    * @param endLineNumber
    */
   launchHandsontableModal(beginLineNumber, endLineNumber) {
-    const tableLines = this.props.markdown.split('\n').slice(beginLineNumber - 1, endLineNumber).join('\n');
+    const tableLines = this.state.markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
+    this.setState({currentTargetTableArea: {beginLineNumber, endLineNumber}});
     this.refs.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
   }
 
+  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) {
     let context = {
       markdown,
@@ -110,7 +117,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));
@@ -129,7 +136,7 @@ export default class Page extends React.Component {
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={true}
       />
-      <HandsontableModal ref='handsontableModal' />
+      <HandsontableModal ref='handsontableModal' onSave={this.saveHandlerForHandsontableModal} />
     </div>;
   }
 }
@@ -137,6 +144,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,

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

@@ -730,7 +730,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         { this.state.isCheatsheetModalButtonShown && this.renderCheatsheetModalButton() }
       </div>
 
-      <HandsontableModal ref='handsontableModal' onSave={ table => mtu.replaceMarkdownTable(this.getCodeMirror(), table) }/>
+      <HandsontableModal ref='handsontableModal' onSave={ table => mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }/>
     </React.Fragment>;
   }
 

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,94 @@
+'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');
+
+const queryToFindSiteUrl = {
+  ns: 'crowi',
+  key: 'app:siteUrl',
+};
+
+/**
+ * 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()
 );

+ 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(),
 );

+ 19 - 13
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,
@@ -148,8 +155,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 +232,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 +314,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'] ||
@@ -496,6 +500,8 @@ module.exports = function(crowi) {
   };
 
   configSchema.statics.customTitle = function(config, page) {
+    validateCrowi();
+
     const key = 'customize:title';
     let customTitle = getValueForCrowiNS(config, key);
 
@@ -588,12 +594,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),

+ 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

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

+ 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">

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

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

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


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