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

Merge pull request #2563 from weseek/support/share-link-for-outside-for-merge

Support/share link for outside for merge
Yuki Takei 5 лет назад
Родитель
Сommit
74e5209447
50 измененных файлов с 1887 добавлено и 59 удалено
  1. 33 5
      resource/locales/en_US/translation.json
  2. 30 2
      resource/locales/ja_JP/translation.json
  3. 25 5
      resource/locales/zh_CN/translation.json
  4. 3 1
      src/client/js/app.jsx
  5. 3 1
      src/client/js/base.jsx
  6. 66 0
      src/client/js/components/Admin/Security/DeleteAllShareLinksModal.jsx
  7. 8 1
      src/client/js/components/Admin/Security/SecurityManagement.jsx
  8. 147 0
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  9. 139 0
      src/client/js/components/OutsideShareLinkModal.jsx
  10. 18 9
      src/client/js/components/Page/CopyDropdown.jsx
  11. 38 11
      src/client/js/components/Page/PageManagement.jsx
  12. 100 0
      src/client/js/components/Page/PageShareManagement.jsx
  13. 60 0
      src/client/js/components/Page/ShareLinkAlert.jsx
  14. 7 2
      src/client/js/components/PageHistory.jsx
  15. 272 0
      src/client/js/components/ShareLinkForm.jsx
  16. 79 0
      src/client/js/components/ShareLinkList.jsx
  17. 3 1
      src/client/js/components/Sidebar.jsx
  18. 10 1
      src/client/js/components/Sidebar/SidebarContents.jsx
  19. 3 3
      src/client/js/components/Sidebar/SidebarNav.jsx
  20. 29 0
      src/client/js/services/AdminGeneralSecurityContainer.js
  21. 3 0
      src/client/js/services/AppContainer.js
  22. 2 0
      src/client/js/services/PageContainer.js
  23. 2 0
      src/client/styles/scss/_override-bootstrap.scss
  24. 12 0
      src/client/styles/scss/_sharelink.scss
  25. 1 0
      src/client/styles/scss/_sidebar.scss
  26. 1 0
      src/client/styles/scss/style-app.scss
  27. 46 0
      src/server/middlewares/certify-shared-file.js
  28. 31 0
      src/server/middlewares/certify-shared-page.js
  29. 6 0
      src/server/middlewares/login-required.js
  30. 1 0
      src/server/models/index.js
  31. 12 7
      src/server/models/page.js
  32. 38 0
      src/server/models/share-link.js
  33. 1 0
      src/server/routes/apiv3/index.js
  34. 71 0
      src/server/routes/apiv3/security-setting.js
  35. 212 0
      src/server/routes/apiv3/share-links.js
  36. 7 3
      src/server/routes/index.js
  37. 53 0
      src/server/routes/page.js
  38. 2 1
      src/server/routes/revision.js
  39. 13 0
      src/server/views/layout-growi/expired_shared_page.html
  40. 13 0
      src/server/views/layout-growi/not_found_shared_page.html
  41. 51 0
      src/server/views/layout-growi/shared_page.html
  42. 13 0
      src/server/views/layout-kibela/expired_shared_page.html
  43. 13 0
      src/server/views/layout-kibela/not_found_shared_page.html
  44. 46 0
      src/server/views/layout-kibela/shared_page.html
  45. 2 0
      src/server/views/widget/page_content.html
  46. 5 0
      src/server/views/widget/page_tabs.html
  47. 1 1
      src/server/views/widget/page_tabs_kibela.html
  48. 16 0
      src/test/middlewares/login-required.test.js
  49. 26 5
      src/test/models/page.test.js
  50. 114 0
      src/test/models/shareLink.test.js

+ 33 - 5
resource/locales/en_US/translation.json

@@ -19,6 +19,7 @@
   "Tag": "Tag",
   "Tags": "Tags",
   "New": "New",
+  "Close": "Close",
   "Shortcuts": "Shortcuts",
   "eg": "e.g.",
   "add": "Add",
@@ -47,12 +48,16 @@
   "History": "History",
   "Presentation Mode": "Presentation",
   "Not available for guest": "Not available for guest",
+  "Create Archive Page": "Create Archive Page",
+  "File type": "File type",
+  "Include Attachment File": "Include Attachment File",
+  "Include Comment": "Include Comment",
+  "Include Subordinated Page": "Include Subordinated Page",
   "username": "Username",
   "Created": "Created",
   "Last updated": "Updated",
   "Last_Login": "Last login",
   "Share": "Share",
-  "Share Link": "Share Link",
   "Markdown Link": "Markdown Link",
   "Create/Edit Template": "Create/Edit template page",
   "Go to this version": "View this version",
@@ -189,6 +194,25 @@
     "password_is_not_set": "Password is not set"
   },
   "security_settings": "Security settings",
+  "share_links": {
+    "Shere this page link to public": "Shere this page link to public",
+    "share_link_list": "Share link list",
+    "share_link_management": "Share Link Management",
+    "No_share_links":"No share links",
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
+    "expire": "Expiration",
+    "Days": "Days",
+    "Custom": "Custom",
+    "description": "description",
+    "enter_desc": "Enter description",
+    "Unlimited": "unlimited",
+    "Issue": "Issue",
+    "share_settings" :"Share settings",
+    "Invalid_Number_of_Date" : "You entered invalid value"
+  },
   "API Settings": "API settings",
   "API Token Settings": "API token settings",
   "Current API Token": "Current API token",
@@ -249,7 +273,8 @@
       "unlinked": "Redirect pages to this page have been deleted.",
       "restricted": "Access to this page is restricted",
       "stale": "More than {{count}} year has passed since last update.",
-      "stale_plural": "More than {{count}} years has passed since last update."
+      "stale_plural": "More than {{count}} years has passed since last update.",
+      "expiration": "This share link will expire <strong>{{expiredAt}}</strong>."
     }
   },
   "page_edit": {
@@ -338,11 +363,14 @@
     "update_successed": "Succeeded to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin ",
+    "remove_user_admin": "Succeeded to remove {{username}} admin",
     "activate_user_success": "Succeeded to activating {{username}}",
     "deactivate_user_success": "Succeeded to deactivate {{username}}",
-    "remove_user_success": "Succeeded to removing {{username}} ",
-    "remove_external_user_success": "Succeeded to remove {{accountId}} ",
+    "remove_user_success": "Succeeded to removing {{username}}",
+    "remove_external_user_success": "Succeeded to remove {{accountId}}",
+    "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
+    "issue_share_link": "Succeeded to issue new share link",
+    "remove_share_link": "Succeeded to remove {{count}} share links",
     "failed_to_reset_password":"Failed to reset password"
   },
   "template": {

+ 30 - 2
resource/locales/ja_JP/translation.json

@@ -19,6 +19,7 @@
   "Tag": "タグ",
   "Tags": "タグ",
   "New": "作成",
+  "Close": "閉じる",
   "Shortcuts": "ショートカット",
   "eg": "例:",
   "add": "追加",
@@ -48,12 +49,16 @@
   "History": "更新履歴",
   "Presentation Mode": "プレゼンテーション",
   "Not available for guest": "ゲストユーザーは利用できません",
+  "Create Archive Page": "アーカイブページの作成",
+  "File type": "ファイル形式",
+  "Include Attachment File": "添付ファイルも含める",
+  "Include Comment": "コメントも含める",
+  "Include Subordinated Page": "配下ページも含める",
   "username": "ユーザー名",
   "Created": "作成日",
   "Last updated": "最終更新",
   "Last_Login": "最終ログイン",
   "Share": "共有",
-  "Share Link": "共有用リンク",
   "Markdown Link": "Markdown形式のリンク",
   "Create/Edit Template": "テンプレートページの作成/編集",
   "Go to this version": "このバージョンを見る",
@@ -192,6 +197,25 @@
     "password_is_not_set": "パスワードが設定されていません"
   },
   "security_settings": "セキュリティ設定",
+  "share_links": {
+    "Shere this page link to public": "外部に共有するリンクを発行する",
+    "share_link_list": "共有リンクリスト",
+    "share_link_management": "共有リンク管理",
+    "No_share_links":"共有リンクが存在しません",
+    "Share Link": "共有用リンク",
+    "Page Path": "ページパス",
+    "share_link_notice":"共有リンクを全て削除します",
+    "delete_all_share_links":"全ての共有リンクを削除します",
+    "expire": "有効期限",
+    "Days": "日間",
+    "Custom": "カスタム",
+    "description": "概要",
+    "enter_desc": "概要を入力",
+    "Unlimited": "無期限",
+    "Issue": "発行",
+    "share_settings" :"共有設定",
+    "Invalid_Number_of_Date" : "有効期限の日数には整数を入力してください"
+  },
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
@@ -251,7 +275,8 @@
       "duplicated": "このページは <code>%s</code> から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "restricted": "このページの閲覧は制限されています",
-      "stale": "このページは最終更新日から{{count}}年以上が経過しています。"
+      "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
+      "expiration": "この共有パーマリンクの有効期限は <strong>{{expiredAt}}</strong> です。"
     }
   },
   "page_edit": {
@@ -345,6 +370,9 @@
     "deactivate_user_success": "{{username}}を無効化しました",
     "remove_user_success": "{{username}}を削除しました",
     "remove_external_user_success": "{{accountId}}を削除しました",
+    "remove_share_link_success": "{{shareLinkId}}を削除しました",
+    "issue_share_link": "共有リンクを作成しました",
+    "remove_share_link": "共有リンクを{{count}}件削除しました",
     "failed_to_reset_password":"パスワードのリセットに失敗しました"
   },
   "template": {

+ 25 - 5
resource/locales/zh_CN/translation.json

@@ -19,7 +19,8 @@
 	"administrator": "管理员",
 	"Tag": "标签",
 	"Tags": "Tags",
-	"New": "新建",
+  "New": "新建",
+  "Close": "Close",
 	"Shortcuts": "快捷方式",
 	"eg": "e.g.",
 	"add": "添加",
@@ -48,13 +49,13 @@
 	"Timeline View": "时间线",
 	"History": "历史",
 	"Presentation Mode": "演示文稿",
-	"Not available for guest": "Not available for guest",
+  "Not available for guest": "Not available for guest",
 	"username": "用户名",
 	"Created": "创建",
 	"Last updated": "上次更新",
-	"Last_Login": "上次登录",
+  "Last_Login": "上次登录",
 	"Share": "分享",
-	"Share Link": "分享链接",
+  "Share Link": "分享链接",
 	"Markdown Link": "Markdown链接",
 	"Create/Edit Template": "创建/编辑 模板页面",
 	"Unportalize": "未启动",
@@ -409,7 +410,26 @@
 		"someone_editing": "Someone editing this page on HackMD",
 		"this_page_has_draft": "This page has a draft on HackMD"
 	},
-	"security_settings": "安全设置",
+  "security_settings": "安全设置",
+  "share_links": {
+    "Shere this page link to public": "Shere this page link to public",
+    "share_link_list": "Share link list",
+    "share_link_management": "Share Link Management",
+    "No_share_links":"No share links",
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
+    "expire": "Expiration",
+    "Days": "Days",
+    "Custom": "Custom",
+    "description": "description",
+    "enter_desc": "Enter description",
+    "Unlimited": "unlimited",
+    "Issue": "Issue",
+    "share_settings" :"Share settings",
+    "Invalid_Number_of_Date" : "You entered invalid value"
+  },
 	"security_setting": {
 		"Security settings": "安全设置",
 		"Guest Users Access": "来宾用户访问",

+ 3 - 1
src/client/js/app.jsx

@@ -19,6 +19,7 @@ import PageComments from './components/PageComments';
 import PageTimeline from './components/PageTimeline';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageManagement from './components/Page/PageManagement';
+import PageShareManagement from './components/Page/PageShareManagement';
 import TrashPageAlert from './components/Page/TrashPageAlert';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
@@ -87,6 +88,7 @@ if (pageContainer.state.pageId != null) {
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-attachment': <PageAttachment />,
     'page-management': <PageManagement />,
+    'page-share-management': <PageShareManagement />,
 
     'revision-toc': <TableOfContents />,
     'seen-user-list': <SeenUserList />,
@@ -139,7 +141,7 @@ $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
       <ErrorBoundary>
-        <PageHistory pageId={pageContainer.state.pageId} crowi={appContainer} />
+        <PageHistory shareLinkId={pageContainer.state.shareLinkId} pageId={pageContainer.state.pageId} crowi={appContainer} />
       </ErrorBoundary>
     </I18nextProvider>, document.getElementById('revision-history'),
   );

+ 3 - 1
src/client/js/base.jsx

@@ -6,6 +6,7 @@ import Xss from '@commons/service/xss';
 import GrowiNavbar from './components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from './components/Navbar/GrowiNavbarBottom';
 import Sidebar from './components/Sidebar';
+import ShareLinkAlert from './components/Page/ShareLinkAlert';
 import HotkeysManager from './components/Hotkeys/HotkeysManager';
 import Fab from './components/Fab';
 
@@ -45,9 +46,10 @@ const componentMappings = {
 
   'grw-sidebar-wrapper': <Sidebar />,
 
+  'share-link-alert': <ShareLinkAlert />,
+  'grw-fab-container': <Fab />,
   'grw-hotkeys-manager': <HotkeysManager />,
 
-  'grw-fab-container': <Fab />,
 };
 
 export { appContainer, componentMappings };

+ 66 - 0
src/client/js/components/Admin/Security/DeleteAllShareLinksModal.jsx

@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import {
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+const DeleteAllShareLinksModal = React.memo((props) => {
+  const { t } = props;
+
+  function closeModal() {
+    if (props.onClose == null) {
+      return;
+    }
+
+    props.onClose();
+  }
+
+  function deleteAllLinkHandler() {
+    if (props.onClickDeleteButton == null) {
+      return;
+    }
+
+    props.onClickDeleteButton();
+
+    closeModal();
+  }
+
+  function closeButtonHandler() {
+    closeModal();
+  }
+
+  return (
+    <Modal isOpen={props.isOpen} toggle={closeButtonHandler} className="page-comment-delete-modal">
+      <ModalHeader tag="h4" toggle={closeButtonHandler} className="bg-danger text-light">
+        <span>
+          <i className="icon-fw icon-fire"></i>
+          {t('share_links.delete_all_share_links')}
+        </span>
+      </ModalHeader>
+      <ModalBody>
+        { t('share_links.share_link_notice')}
+      </ModalBody>
+      <ModalFooter>
+        <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
+        <Button color="danger" onClick={deleteAllLinkHandler}>
+          <i className="icon icon-fire"></i>
+          {t('Delete')}
+        </Button>
+      </ModalFooter>
+    </Modal>
+  );
+
+});
+
+DeleteAllShareLinksModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func,
+  onClickDeleteButton: PropTypes.func,
+};
+
+export default withTranslation()(DeleteAllShareLinksModal);

+ 8 - 1
src/client/js/components/Admin/Security/SecurityManagement.jsx

@@ -18,6 +18,7 @@ import GoogleSecuritySetting from './GoogleSecuritySetting';
 import GitHubSecuritySetting from './GitHubSecuritySetting';
 import TwitterSecuritySetting from './TwitterSecuritySetting';
 import FacebookSecuritySetting from './FacebookSecuritySetting';
+import ShareLinkSetting from './ShareLinkSetting';
 
 class SecurityManagement extends React.Component {
 
@@ -44,10 +45,16 @@ class SecurityManagement extends React.Component {
     const { activeTab, activeComponents } = this.state;
     return (
       <Fragment>
-        <div>
+        <div className="mb-5">
           <SecuritySetting />
         </div>
 
+        {/* Shared Link List */}
+        <div className="mb-5">
+          <ShareLinkSetting />
+        </div>
+
+
         {/* XSS configuration link */}
         <div className="mb-5">
           <h2 className="border-bottom">{t('security_setting.xss_prevent_setting')}</h2>

+ 147 - 0
src/client/js/components/Admin/Security/ShareLinkSetting.jsx

@@ -0,0 +1,147 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import PaginationWrapper from '../../PaginationWrapper';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+
+import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
+import ShareLinkList from '../../ShareLinkList';
+
+class ShareLinkSetting extends React.Component {
+
+  constructor() {
+    super();
+
+    this.state = {
+      isDeleteConfirmModalShown: false,
+    };
+    this.getShareLinkList = this.getShareLinkList.bind(this);
+    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
+    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
+    this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
+    this.deleteLinkById = this.deleteLinkById.bind(this);
+  }
+
+  componentWillMount() {
+    this.getShareLinkList(1);
+  }
+
+  async getShareLinkList(page) {
+    try {
+      await this.props.adminGeneralSecurityContainer.retrieveShareLinksByPagingNum(page);
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }
+
+  showDeleteConfirmModal() {
+    this.setState({ isDeleteConfirmModalShown: true });
+  }
+
+  closeDeleteConfirmModal() {
+    this.setState({ isDeleteConfirmModalShown: false });
+  }
+
+  async deleteAllLinksButtonHandler() {
+    const { t, appContainer } = this.props;
+
+    try {
+      const res = await appContainer.apiv3Delete('/share-links/all');
+      const { deletedCount } = res.data;
+      toastSuccess(t('toaster.remove_share_link', { count: deletedCount }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.getShareLinkList(1);
+  }
+
+  async deleteLinkById(shareLinkId) {
+    const { t, appContainer, adminGeneralSecurityContainer } = this.props;
+
+    try {
+      const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
+      const { deletedShareLink } = res.data;
+      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    this.getShareLinkList(adminGeneralSecurityContainer.state.shareLinksActivePage);
+  }
+
+
+  render() {
+    const { t, adminGeneralSecurityContainer } = this.props;
+
+    const pager = (
+      <div className="pull-right my-3">
+        <PaginationWrapper
+          activePage={adminGeneralSecurityContainer.state.shareLinksActivePage}
+          changePage={this.getShareLinkList}
+          totalItemsCount={adminGeneralSecurityContainer.state.totalshareLinks}
+          pagingLimit={adminGeneralSecurityContainer.state.shareLinksPagingLimit}
+        />
+      </div>
+    );
+
+    const deleteAllButton = (
+      adminGeneralSecurityContainer.state.shareLinks.length > 0
+        ? (
+          <button
+            className="pull-right btn btn-danger"
+            type="button"
+            onClick={this.showDeleteConfirmModal}
+          >
+            {t('share_links.delete_all_share_links')}
+          </button>
+        )
+        : (
+          <p className="pull-right mr-2">{t('share_links.No_share_links')}</p>
+        )
+    );
+
+    return (
+      <Fragment>
+        <div className="mb-3">
+          {deleteAllButton}
+          <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
+        </div>
+
+        {pager}
+        <ShareLinkList
+          shareLinks={adminGeneralSecurityContainer.state.shareLinks}
+          onClickDeleteButton={this.deleteLinkById}
+          isAdmin
+        />
+
+        <DeleteAllShareLinksModal
+          isOpen={this.state.isDeleteConfirmModalShown}
+          onClose={this.closeDeleteConfirmModal}
+          onClickDeleteButton={this.deleteAllLinksButtonHandler}
+        />
+
+      </Fragment>
+    );
+  }
+
+}
+
+const ShareLinkSettingWrapper = withUnstatedContainers(ShareLinkSetting, [AppContainer, AdminGeneralSecurityContainer]);
+
+ShareLinkSetting.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+};
+
+export default withTranslation()(ShareLinkSettingWrapper);

+ 139 - 0
src/client/js/components/OutsideShareLinkModal.jsx

@@ -0,0 +1,139 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import ShareLinkList from './ShareLinkList';
+import ShareLinkForm from './ShareLinkForm';
+
+import { toastSuccess, toastError } from '../util/apiNotification';
+
+class OutsideShareLinkModal extends React.Component {
+
+  constructor() {
+    super();
+    this.state = {
+      shareLinks: [],
+      isOpenShareLinkForm: false,
+    };
+
+    this.toggleShareLinkFormHandler = this.toggleShareLinkFormHandler.bind(this);
+    this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
+    this.deleteLinkById = this.deleteLinkById.bind(this);
+  }
+
+  componentDidMount() {
+    this.retrieveShareLinks();
+  }
+
+  async retrieveShareLinks() {
+    const { appContainer, pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    try {
+      const res = await appContainer.apiv3.get('/share-links/', { relatedPage: pageId });
+      const { shareLinksResult } = res.data;
+      this.setState({ shareLinks: shareLinksResult });
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }
+
+  toggleShareLinkFormHandler() {
+    this.setState({ isOpenShareLinkForm: !this.state.isOpenShareLinkForm });
+    this.retrieveShareLinks();
+  }
+
+  async deleteAllLinksButtonHandler() {
+    const { t, appContainer, pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    try {
+      const res = await appContainer.apiv3.delete('/share-links/', { relatedPage: pageId });
+      const count = res.data.n;
+      toastSuccess(t('toaster.remove_share_link', { count }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    this.retrieveShareLinks();
+  }
+
+  async deleteLinkById(shareLinkId) {
+    const { t, appContainer } = this.props;
+
+    try {
+      const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
+      const { deletedShareLink } = res.data;
+      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    this.retrieveShareLinks();
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal size="xl" isOpen={this.props.isOpen} toggle={this.props.onClose}>
+        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-primary text-light">{t('share_links.Shere this page link to public')}
+        </ModalHeader>
+        <ModalBody>
+          <div className="container">
+            <h3 className="grw-modal-head  d-flex  pb-2">
+              { t('share_links.share_link_list') }
+              <button className="btn btn-danger ml-auto " type="button" onClick={this.deleteAllLinksButtonHandler}>{t('delete_all')}</button>
+            </h3>
+
+            <div>
+              <ShareLinkList
+                shareLinks={this.state.shareLinks}
+                onClickDeleteButton={this.deleteLinkById}
+              />
+              <button
+                className="btn btn-outline-secondary d-block mx-auto px-5 mb-3"
+                type="button"
+                onClick={this.toggleShareLinkFormHandler}
+              >
+                {this.state.isOpenShareLinkForm ? t('Close') : t('New')}
+              </button>
+              {this.state.isOpenShareLinkForm && <ShareLinkForm onCloseForm={this.toggleShareLinkFormHandler} />}
+            </div>
+          </div>
+        </ModalBody>
+      </Modal>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const ModalControlWrapper = withUnstatedContainers(OutsideShareLinkModal, [AppContainer, PageContainer]);
+
+OutsideShareLinkModal.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(ModalControlWrapper);

+ 18 - 9
src/client/js/components/Page/CopyDropdown.jsx

@@ -66,11 +66,14 @@ class CopyDropdown extends React.Component {
   }
 
   generatePermalink() {
-    const { pageId } = this.props;
+    const { pageId, isShareLinkMode } = this.props;
 
     if (pageId == null) {
       return null;
     }
+    if (isShareLinkMode) {
+      return decodeURI(`${origin}/share/${pageId}`);
+    }
 
     return decodeURI(`${origin}/${pageId}${this.uriParams}`);
   }
@@ -92,30 +95,36 @@ class CopyDropdown extends React.Component {
   );
 
   render() {
-    const { t, pageId } = this.props;
+    const {
+      t, pageId, isShareLinkMode,
+    } = this.props;
     const { isParamsAppended } = this.state;
 
     const pagePathWithParams = this.generatePagePathWithParams();
     const pagePathUrl = this.generatePagePathUrl();
     const permalink = this.generatePermalink();
 
+    const copyTarget = isShareLinkMode ? `copyShareLink${pageId}` : 'copyPagePathDropdown';
+    const dropdownToggleStyle = isShareLinkMode ? 'btn btn-secondary' : 'd-block text-muted bg-transparent btn-copy border-0';
+
     const { id, DropdownItemContents } = this;
 
     const customSwitchForParamsId = `customSwitchForParams_${id}`;
 
     return (
       <>
-        <UncontrolledDropdown id="copyPagePathDropdown" className="grw-copy-dropdown">
-
+        <UncontrolledDropdown id={copyTarget} className="grw-copy-dropdown">
           <DropdownToggle
             caret
-            className="d-block text-muted bg-transparent btn-copy border-0"
+            className={dropdownToggleStyle}
             style={this.props.buttonStyle}
           >
-            <i className="ti-clipboard"></i>
+            { isShareLinkMode ? (
+              <>Copy Link</>
+            ) : (<i className="ti-clipboard"></i>)}
           </DropdownToggle>
 
-          <DropdownMenu>
+          <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: null } }}>
 
             <div className="d-flex align-items-center justify-content-between">
               <DropdownItem header className="px-3">
@@ -150,7 +159,6 @@ class CopyDropdown extends React.Component {
                 <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={pagePathUrl} />
               </DropdownItem>
             </CopyToClipboard>
-
             <DropdownItem divider className="my-0"></DropdownItem>
 
             {/* Permanent Link */}
@@ -187,7 +195,7 @@ class CopyDropdown extends React.Component {
 
         </UncontrolledDropdown>
 
-        <Tooltip placement="bottom" isOpen={this.state.tooltipOpen} target="copyPagePathDropdown" fade={false}>
+        <Tooltip placement="bottom" isOpen={this.state.tooltipOpen} target={copyTarget} fade={false}>
           copied!
         </Tooltip>
       </>
@@ -202,6 +210,7 @@ CopyDropdown.propTypes = {
   pagePath: PropTypes.string.isRequired,
   pageId: PropTypes.string,
   buttonStyle: PropTypes.object,
+  isShareLinkMode: PropTypes.bool,
 };
 
 export default withTranslation()(CopyDropdown);

+ 38 - 11
src/client/js/components/Page/PageManagement.jsx

@@ -1,5 +1,6 @@
 import React, { useState } from 'react';
 import PropTypes from 'prop-types';
+import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 
 import { isTopPage } from '@commons/util/path-utils';
@@ -83,6 +84,10 @@ const PageManagement = (props) => {
   }
 
   function renderModals() {
+    if (currentUser == null) {
+      return null;
+    }
+
     return (
       <>
         <PageRenameModal
@@ -108,19 +113,41 @@ const PageManagement = (props) => {
     );
   }
 
+  function renderDotsIconForCurrentUser() {
+    return (
+      <>
+        <button
+          type="button"
+          className="btn-link nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret"
+          data-toggle="dropdown"
+        >
+          <i className="icon-options-vertical"></i>
+        </button>
+      </>
+    );
+  }
+
+  function renderDotsIconForGuestUser() {
+    return (
+      <>
+        <button
+          type="button"
+          className="btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled"
+          id="icon-options-guest-tltips"
+        >
+          <i className="icon-options-vertical"></i>
+        </button>
+        <UncontrolledTooltip placement="top" target="icon-options-guest-tltips">
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      </>
+    );
+  }
+
+
   return (
     <>
-      <a
-        role="button"
-        className={`nav-link dropdown-toggle dropdown-toggle-no-caret ${currentUser == null && 'dropdown-toggle-disabled'}`}
-        href="#"
-        data-toggle={`${currentUser == null ? 'tooltip' : 'dropdown'}`}
-        data-placement="top"
-        data-container="body"
-        title={t('Not available for guest')}
-      >
-        <i className="icon-options-vertical"></i>
-      </a>
+      {currentUser == null ? renderDotsIconForGuestUser() : renderDotsIconForCurrentUser()}
       <div className="dropdown-menu dropdown-menu-right">
         {!isTopPagePath && renderDropdownItemForNotTopPage()}
         <button className="dropdown-item" type="button" onClick={openPageTemplateModalHandler}>

+ 100 - 0
src/client/js/components/Page/PageShareManagement.jsx

@@ -0,0 +1,100 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { UncontrolledTooltip } from 'reactstrap';
+import { withTranslation } from 'react-i18next';
+import { withUnstatedContainers } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import OutsideShareLinkModal from '../OutsideShareLinkModal';
+
+const PageShareManagement = (props) => {
+  const { t, appContainer, pageContainer } = props;
+
+  const { currentUser } = appContainer;
+
+  const [isOutsideShareLinkModalShown, setIsOutsideShareLinkModalShown] = useState(false);
+
+
+  function openOutsideShareLinkModalHandler() {
+    setIsOutsideShareLinkModalShown(true);
+  }
+
+  function closeOutsideShareLinkModalHandler() {
+    setIsOutsideShareLinkModalShown(false);
+  }
+
+  function renderModals() {
+    if (currentUser == null) {
+      return null;
+    }
+
+    return (
+      <>
+        <OutsideShareLinkModal
+          isOpen={isOutsideShareLinkModalShown}
+          onClose={closeOutsideShareLinkModalHandler}
+        />
+      </>
+    );
+  }
+
+
+  function renderCurrentUser() {
+    return (
+      <>
+        <button
+          type="button"
+          className="btn-link nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret"
+          data-toggle="dropdown"
+        >
+          <i className="icon-share"></i>
+        </button>
+      </>
+    );
+  }
+
+  function renderGuestUser() {
+    return (
+      <>
+        <button
+          type="button"
+          className="btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled"
+          id="auth-guest-tltips"
+        >
+          <i className="icon-share"></i>
+        </button>
+        <UncontrolledTooltip placement="top" target="auth-guest-tltips">
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      </>
+    );
+  }
+
+  return (
+    <>
+      {currentUser == null ? renderGuestUser() : renderCurrentUser()}
+      <div className="dropdown-menu dropdown-menu-right">
+        <button className="dropdown-item" type="button" onClick={openOutsideShareLinkModalHandler}>
+          <i className="icon-fw icon-link"></i>{t('share_links.Shere this page link to public')}
+          <span className="ml-2 badge badge-info badge-pill">{pageContainer.state.shareLinksNumber}</span>
+        </button>
+      </div>
+      {renderModals()}
+    </>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageShareManagementWrapper = withUnstatedContainers(PageShareManagement, [AppContainer, PageContainer]);
+
+
+PageShareManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+};
+
+export default withTranslation()(PageShareManagementWrapper);

+ 60 - 0
src/client/js/components/Page/ShareLinkAlert.jsx

@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+const ShareLinkAlert = (props) => {
+  const { t } = props;
+
+
+  const shareContent = document.getElementById('is-shared-page');
+  let expiredAt = shareContent.getAttribute('data-share-link-expired-at');
+  const createdAt = shareContent.getAttribute('data-share-link-created-at');
+
+  function generateRatio() {
+    const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
+    const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
+    return remainingTime / wholeTime;
+  }
+
+  let ratio = 1;
+
+  if (expiredAt !== '') {
+    ratio = generateRatio();
+  }
+  else {
+    expiredAt = t('share_links.Unlimited');
+  }
+
+  function specifyColor() {
+    let color;
+    if (ratio >= 0.75) {
+      color = 'success';
+    }
+    else if (ratio < 0.75 && ratio >= 0.5) {
+      color = 'info';
+    }
+    else if (ratio < 0.5 && ratio >= 0.25) {
+      color = 'warning';
+    }
+    else {
+      color = 'danger';
+    }
+    return color;
+  }
+
+  return (
+    <p className={`alert alert-${specifyColor()} py-3 px-4`}>
+      <i className="icon-fw icon-link"></i>
+      {/* eslint-disable-next-line react/no-danger */}
+      <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
+    </p>
+  );
+};
+
+
+ShareLinkAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(ShareLinkAlert);

+ 7 - 2
src/client/js/components/PageHistory.jsx

@@ -26,6 +26,7 @@ class PageHistory extends React.Component {
 
   async componentWillMount() {
     const pageId = this.props.pageId;
+    const shareLinkId = this.props.shareLinkId || null;
 
     if (!pageId) {
       return;
@@ -34,7 +35,7 @@ class PageHistory extends React.Component {
     let res;
     try {
       this.setState({ isLoading: true });
-      res = await this.props.crowi.apiGet('/revisions.ids', { page_id: pageId });
+      res = await this.props.crowi.apiGet('/revisions.ids', { page_id: pageId, share_link_id: shareLinkId });
     }
     catch (err) {
       logger.error(err);
@@ -110,12 +111,14 @@ class PageHistory extends React.Component {
   }
 
   fetchPageRevisionBody(revision) {
+    const shareLinkId = this.props.shareLinkId || null;
+
     if (revision.body) {
       return;
     }
 
     this.props.crowi.apiGet('/revisions.get',
-      { page_id: this.props.pageId, revision_id: revision._id })
+      { page_id: this.props.pageId, revision_id: revision._id, share_link_id: shareLinkId })
       .then((res) => {
         if (res.ok) {
           this.setState({
@@ -166,6 +169,8 @@ class PageHistory extends React.Component {
 
 PageHistory.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+
+  shareLinkId: PropTypes.string,
   pageId: PropTypes.string,
   crowi: PropTypes.object.isRequired,
 };

+ 272 - 0
src/client/js/components/ShareLinkForm.jsx

@@ -0,0 +1,272 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+import parse from 'date-fns/parse';
+
+import { isInteger } from 'core-js/fn/number';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import { toastSuccess, toastError } from '../util/apiNotification';
+
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+class ShareLinkForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      expirationType: 'unlimited',
+      numberOfDays: '7',
+      description: '',
+      customExpirationDate: dateFnsFormat(new Date(), 'yyyy-MM-dd'),
+      customExpirationTime: dateFnsFormat(new Date(), 'hh:mm'),
+    };
+
+    this.handleChangeExpirationType = this.handleChangeExpirationType.bind(this);
+    this.handleChangeNumberOfDays = this.handleChangeNumberOfDays.bind(this);
+    this.handleChangeDescription = this.handleChangeDescription.bind(this);
+    this.handleIssueShareLink = this.handleIssueShareLink.bind(this);
+  }
+
+  /**
+   * change expirationType
+   * @param {string} expirationType
+   */
+  handleChangeExpirationType(expirationType) {
+    this.setState({ expirationType });
+  }
+
+  /**
+   * change numberOfDays
+   * @param {string} numberOfDays
+   */
+  handleChangeNumberOfDays(numberOfDays) {
+    this.setState({ numberOfDays });
+  }
+
+  /**
+   * change description
+   * @param {string} description
+   */
+  handleChangeDescription(description) {
+    this.setState({ description });
+  }
+
+  /**
+   * change customExpirationDate
+   * @param {date} customExpirationDate
+   */
+  handleChangeCustomExpirationDate(customExpirationDate) {
+    this.setState({ customExpirationDate });
+  }
+
+  /**
+   * change customExpirationTime
+   * @param {date} customExpirationTime
+   */
+  handleChangeCustomExpirationTime(customExpirationTime) {
+    this.setState({ customExpirationTime });
+  }
+
+  /**
+   * Generate expiredAt by expirationType
+   */
+  generateExpired() {
+    const { t } = this.props;
+    const { expirationType } = this.state;
+    let expiredAt;
+
+    if (expirationType === 'unlimited') {
+      return null;
+    }
+
+    if (expirationType === 'numberOfDays') {
+      if (!isInteger(Number(this.state.numberOfDays))) {
+        throw new Error(t('share_links.Invalid_Number_of_Date'));
+      }
+      const date = new Date();
+      date.setDate(date.getDate() + Number(this.state.numberOfDays));
+      expiredAt = date;
+    }
+
+    if (expirationType === 'custom') {
+      const { customExpirationDate, customExpirationTime } = this.state;
+      expiredAt = parse(`${customExpirationDate}T${customExpirationTime}`, "yyyy-MM-dd'T'HH:mm", new Date());
+    }
+
+    return expiredAt;
+  }
+
+  closeForm() {
+    const { onCloseForm } = this.props;
+
+    if (onCloseForm == null) {
+      return;
+    }
+    onCloseForm();
+  }
+
+  async handleIssueShareLink() {
+    const {
+      t, appContainer, pageContainer,
+    } = this.props;
+    const { pageId } = pageContainer.state;
+    const { description } = this.state;
+
+    let expiredAt;
+
+    try {
+      expiredAt = this.generateExpired();
+    }
+    catch (err) {
+      return toastError(err);
+    }
+
+    try {
+      await appContainer.apiv3Post('/share-links/', { relatedPage: pageId, expiredAt, description });
+      this.closeForm();
+      toastSuccess(t('toaster.issue_share_link'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }
+
+  renderExpirationTypeOptions() {
+    const { expirationType } = this.state;
+    const { t } = this.props;
+
+    return (
+      <div className="form-group row">
+        <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.expire')}</label>
+        <div className="col-md-7">
+
+
+          <div className="custom-control custom-radio form-group ">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="customRadio1"
+              name="expirationType"
+              value="customRadio1"
+              checked={expirationType === 'unlimited'}
+              onChange={() => { this.handleChangeExpirationType('unlimited') }}
+            />
+            <label className="custom-control-label" htmlFor="customRadio1">{t('share_links.Unlimited')}</label>
+          </div>
+
+          <div className="custom-control custom-radio  form-group">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="customRadio2"
+              value="customRadio2"
+              checked={expirationType === 'numberOfDays'}
+              onChange={() => { this.handleChangeExpirationType('numberOfDays') }}
+              name="expirationType"
+            />
+            <label className="custom-control-label" htmlFor="customRadio2">
+              <div className="row align-items-center m-0">
+                <input
+                  type="number"
+                  min="1"
+                  className="col-4"
+                  name="expirationType"
+                  value={this.state.numberOfDays}
+                  onFocus={() => { this.handleChangeExpirationType('numberOfDays') }}
+                  onChange={e => this.handleChangeNumberOfDays(Number(e.target.value))}
+                />
+                <span className="col-auto">{t('share_links.Days')}</span>
+              </div>
+            </label>
+          </div>
+
+          <div className="custom-control custom-radio form-group text-nowrap mb-0">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="customRadio3"
+              name="expirationType"
+              value="customRadio3"
+              checked={expirationType === 'custom'}
+              onChange={() => { this.handleChangeExpirationType('custom') }}
+            />
+            <label className="custom-control-label" htmlFor="customRadio3">
+              {t('share_links.Custom')}
+            </label>
+            <div className="d-inline-flex flex-wrap">
+              <input
+                type="date"
+                className="ml-3 mb-2"
+                name="customExpirationDate"
+                value={this.state.customExpirationDate}
+                onFocus={() => { this.handleChangeExpirationType('custom') }}
+                onChange={e => this.handleChangeCustomExpirationDate(e.target.value)}
+              />
+              <input
+                type="time"
+                className="ml-3 mb-2"
+                name="customExpiration"
+                value={this.state.customExpirationTime}
+                onFocus={() => { this.handleChangeExpirationType('custom') }}
+                onChange={e => this.handleChangeCustomExpirationTime(e.target.value)}
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  renderDescriptionForm() {
+    const { t } = this.props;
+    return (
+      <div className="form-group row">
+        <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.description')}</label>
+        <div className="col-md-4">
+          <input
+            type="text"
+            className="form-control"
+            id="inputDesc"
+            placeholder={t('share_links.enter_desc')}
+            value={this.state.description}
+            onChange={e => this.handleChangeDescription(e.target.value)}
+          />
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+    return (
+      <div className="share-link-form p-3">
+        <h3 className="grw-modal-head pb-2"> { t('share_links.share_settings') }</h3>
+        <div className=" p-3">
+          {this.renderExpirationTypeOptions()}
+          {this.renderDescriptionForm()}
+          <button type="button" className="btn btn-primary d-block mx-auto px-5" onClick={this.handleIssueShareLink}>
+            {t('share_links.Issue')}
+          </button>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+const ShareLinkFormWrapper = withUnstatedContainers(ShareLinkForm, [AppContainer, PageContainer]);
+
+ShareLinkForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  onCloseForm: PropTypes.func,
+};
+
+export default withTranslation()(ShareLinkFormWrapper);

+ 79 - 0
src/client/js/components/ShareLinkList.jsx

@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import AppContainer from '../services/AppContainer';
+import CopyDropdown from './Page/CopyDropdown';
+
+const ShareLinkList = (props) => {
+
+  const { t } = props;
+  function deleteLinkHandler(shareLinkId) {
+    if (props.onClickDeleteButton == null) {
+      return;
+    }
+    props.onClickDeleteButton(shareLinkId);
+  }
+
+  function renderShareLinks() {
+    return (
+      <>
+        {props.shareLinks.map(shareLink => (
+          <tr key={shareLink._id}>
+            <td>
+              <div className="d-flex">
+                <span className="mr-auto my-auto">{shareLink._id}</span>
+                <CopyDropdown isShareLinkMode pagePath={shareLink.relatedPage.path} pageId={shareLink._id} />
+              </div>
+            </td>
+            {props.isAdmin && <td><a href={shareLink.relatedPage.path}>{shareLink.relatedPage.path}</a></td>}
+            <td>{shareLink.expiredAt && <span>{dateFnsFormat(new Date(shareLink.expiredAt), 'yyyy-MM-dd HH:mm')}</span>}</td>
+            <td>{shareLink.description}</td>
+            <td>
+              <button className="btn btn-outline-warning" type="button" onClick={() => deleteLinkHandler(shareLink._id)}>
+                <i className="icon-trash"></i>{t('Delete')}
+              </button>
+            </td>
+          </tr>
+        ))}
+      </>
+    );
+  }
+
+  return (
+    <div className="table-responsive">
+      <table className="table table-bordered">
+        <thead>
+          <tr>
+            <th>{t('share_links.Share Link')}</th>
+            {props.isAdmin && <th>{t('share_links.Page Path')}</th>}
+            <th>{t('share_links.expire')}</th>
+            <th>{t('share_links.description')}</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          {renderShareLinks()}
+        </tbody>
+      </table>
+    </div>
+  );
+};
+
+const ShareLinkListWrapper = withUnstatedContainers(ShareLinkList, [AppContainer]);
+
+ShareLinkList.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  shareLinks: PropTypes.array.isRequired,
+  onClickDeleteButton: PropTypes.func,
+  isAdmin: PropTypes.bool,
+};
+
+export default withTranslation()(ShareLinkListWrapper);

+ 3 - 1
src/client/js/components/Sidebar.jsx

@@ -159,7 +159,9 @@ class Sidebar extends React.Component {
           calcViewHeightFunc={this.calcViewHeight}
         />
         <div id="grw-sidebar-content-container" className="grw-sidebar-content-container">
-          <SidebarContents />
+          <SidebarContents
+            isSharedUser={this.props.appContainer.isSharedUser}
+          />
         </div>
 
         <DrawerToggler iconClass="icon-arrow-left" />

+ 10 - 1
src/client/js/components/Sidebar/SidebarContents.jsx

@@ -10,8 +10,11 @@ import RecentChanges from './RecentChanges';
 import CustomSidebar from './CustomSidebar';
 
 const SidebarContents = (props) => {
+  const { navigationContainer, isSharedUser } = props;
 
-  const { navigationContainer } = props;
+  if (isSharedUser) {
+    return null;
+  }
 
   let Contents;
   switch (navigationContainer.state.sidebarContentsId) {
@@ -30,6 +33,12 @@ const SidebarContents = (props) => {
 
 SidebarContents.propTypes = {
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+
+  isSharedUser: PropTypes.bool,
+};
+
+SidebarContents.defaultProps = {
+  isSharedUser: false,
 };
 
 /**

+ 3 - 3
src/client/js/components/Sidebar/SidebarNav.jsx

@@ -56,7 +56,7 @@ class SidebarNav extends React.Component {
   }
 
   render() {
-    const { isAdmin, currentUsername } = this.props.appContainer;
+    const { isAdmin, currentUsername, isSharedUser } = this.props.appContainer;
     const isLoggedIn = currentUsername != null;
 
     const { PrimaryItem, SecondaryItem } = this;
@@ -64,8 +64,8 @@ class SidebarNav extends React.Component {
     return (
       <div className="grw-sidebar-nav">
         <div className="grw-sidebar-nav-primary-container">
-          <PrimaryItem id="custom" label="Custom Sidebar" iconName="code" />
-          <PrimaryItem id="recent" label="Recent Changes" iconName="update" />
+          {!isSharedUser && <PrimaryItem id="custom" label="Custom Sidebar" iconName="code" />}
+          {!isSharedUser && <PrimaryItem id="recent" label="Recent Changes" iconName="update" />}
           {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
           {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
         </div>

+ 29 - 0
src/client/js/services/AdminGeneralSecurityContainer.js

@@ -30,6 +30,10 @@ export default class AdminGeneralSecurityContainer extends Container {
       isGitHubEnabled: false,
       isTwitterEnabled: false,
       setupStrategies: [],
+      shareLinks: [],
+      totalshareLinks: 0,
+      shareLinksPagingLimit: Infinity,
+      shareLinksActivePage: 1,
     };
 
   }
@@ -151,6 +155,31 @@ export default class AdminGeneralSecurityContainer extends Container {
     }
   }
 
+  /**
+   * Retrieve All Sharelinks
+   */
+  async retrieveShareLinksByPagingNum(page) {
+
+    const params = {
+      page,
+    };
+
+    const { data } = await this.appContainer.apiv3.get('/security-setting/all-share-links', params);
+
+    if (data.paginateResult == null) {
+      throw new Error('data must conclude \'paginateResult\' property.');
+    }
+
+    const { docs: shareLinks, totalDocs: totalshareLinks, limit: shareLinksPagingLimit } = data.paginateResult;
+
+    this.setState({
+      shareLinks,
+      totalshareLinks,
+      shareLinksPagingLimit,
+      shareLinksActivePage: page,
+    });
+  }
+
   /**
    * Switch local enabled
    */

+ 3 - 0
src/client/js/services/AppContainer.js

@@ -39,6 +39,9 @@ export default class AppContainer extends Container {
 
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
+    const isSharedPageElem = document.getElementById('is-shared-page');
+    this.isSharedUser = (isSharedPageElem != null);
+
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
 

+ 2 - 0
src/client/js/services/PageContainer.js

@@ -64,6 +64,8 @@ export default class PageContainer extends Container {
       tags: null,
       hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
+      shareLinksNumber:  mainContent.getAttribute('data-share-links-number'),
+      shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
 
       // latest(on remote) information
       remoteRevisionId: revisionId,

+ 2 - 0
src/client/styles/scss/_override-bootstrap.scss

@@ -79,7 +79,9 @@
   // Dropdowns
   .dropdown-toggle {
     &.btn.disabled {
+      pointer-events: auto;
       cursor: not-allowed;
+      opacity: unset;
     }
 
     // hide caret

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

@@ -0,0 +1,12 @@
+.share-link-form {
+  /* Chrome/Safari */
+  input[type='number']::-webkit-outer-spin-button,
+  input[type='number']::-webkit-inner-spin-button {
+    -webkit-appearance: none;
+  }
+
+  /* Firefox */
+  input[type='number'] {
+    -moz-appearance: textfield;
+  }
+}

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

@@ -82,6 +82,7 @@
   }
 
   .grw-sidebar-nav {
+    min-width: 62px;
     height: 100vh;
 
     .btn {

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

@@ -60,6 +60,7 @@
 @import 'staff_credit';
 @import 'waves';
 @import 'wiki';
+@import 'sharelink';
 @import 'linkedit-preview';
 
 /*

+ 46 - 0
src/server/middlewares/certify-shared-file.js

@@ -0,0 +1,46 @@
+const loggerFactory = require('@alias/logger');
+const url = require('url');
+
+const logger = loggerFactory('growi:middleware:certify-shared-fire');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    const { referer } = req.headers;
+    const { path } = url.parse(referer);
+
+    if (!path.startsWith('/share/')) {
+      next();
+    }
+
+    const fileId = req.params.id || null;
+
+    const Attachment = crowi.model('Attachment');
+    const ShareLink = crowi.model('ShareLink');
+
+    const attachment = await Attachment.findOne({ _id: fileId });
+
+    if (attachment == null) {
+      next();
+    }
+
+    const shareLinks = await ShareLink.find({ relatedPage: attachment.page });
+
+    // If sharelinks don't exist, skip it
+    if (shareLinks.length === 0) {
+      next();
+    }
+
+    // Is there a valid share link
+    shareLinks.map((sharelink) => {
+      if (!sharelink.isExpired()) {
+        logger.debug('Confirmed target file belong to a share page');
+        req.isSharedPage = true;
+      }
+      return;
+    });
+
+    next();
+  };
+
+};

+ 31 - 0
src/server/middlewares/certify-shared-page.js

@@ -0,0 +1,31 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:certify-shared-page');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    const pageId = req.query.page_id || req.body.page_id || null;
+    const shareLinkId = req.query.share_link_id || req.body.share_link_id || null;
+    if (pageId == null || shareLinkId == null) {
+      return next();
+    }
+
+    const ShareLink = crowi.model('ShareLink');
+    const sharelink = await ShareLink.findOne({ _id: shareLinkId, relatedPage: pageId });
+
+    // check sharelink enabled
+    if (sharelink == null || sharelink.isExpired()) {
+      return next();
+    }
+
+    logger.debug('shareLink id is', sharelink._id);
+
+    req.isSharedPage = true;
+
+    logger.debug('Confirmed target page id is a share page');
+
+    next();
+  };
+
+};

+ 6 - 0
src/server/middlewares/login-required.js

@@ -17,6 +17,12 @@ module.exports = (crowi, isGuestAllowed = false) => {
       return next();
     }
 
+    // check the page is shared
+    if (isGuestAllowed && req.isSharedPage) {
+      logger.debug('Target page is shared page');
+      return next();
+    }
+
     const User = crowi.model('User');
 
     // check the user logged in

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

@@ -15,4 +15,5 @@ module.exports = {
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
+  ShareLink: require('./share-link'),
 };

+ 12 - 7
src/server/models/page.js

@@ -1181,19 +1181,24 @@ module.exports = function(crowi) {
     const Attachment = crowi.model('Attachment');
     const Comment = crowi.model('Comment');
     const PageTagRelation = crowi.model('PageTagRelation');
+    const ShareLink = crowi.model('ShareLink');
     const Revision = crowi.model('Revision');
     const pageId = pageData._id;
     const socketClientId = options.socketClientId || null;
 
     debug('Completely delete', pageData.path);
 
-    await Bookmark.removeBookmarksByPageId(pageId);
-    await Attachment.removeAttachmentsByPageId(pageId);
-    await Comment.removeCommentsByPageId(pageId);
-    await PageTagRelation.remove({ relatedPage: pageId });
-    await Revision.removeRevisionsByPath(pageData.path);
-    await this.findByIdAndRemove(pageId);
-    await this.removeRedirectOriginPageByPath(pageData.path);
+    await Promise.all([
+      Bookmark.removeBookmarksByPageId(pageId),
+      Attachment.removeAttachmentsByPageId(pageId),
+      Comment.removeCommentsByPageId(pageId),
+      PageTagRelation.remove({ relatedPage: pageId }),
+      ShareLink.remove({ relatedPage: pageId }),
+      Revision.removeRevisionsByPath(pageData.path),
+      this.findByIdAndRemove(pageId),
+      this.removeRedirectOriginPageByPath(pageData.path),
+    ]);
+
     if (socketClientId != null) {
       pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
     }

+ 38 - 0
src/server/models/share-link.js

@@ -0,0 +1,38 @@
+// disable no-return-await for model functions
+/* eslint-disable no-return-await */
+
+const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
+const mongoosePaginate = require('mongoose-paginate-v2');
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+/*
+ * define schema
+ */
+const schema = new mongoose.Schema({
+  relatedPage: {
+    type: ObjectId,
+    ref: 'Page',
+    required: true,
+    index: true,
+  },
+  expiredAt: { type: Date },
+  description: { type: String },
+  createdAt: { type: Date, default: Date.now, required: true },
+});
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+module.exports = function(crowi) {
+
+  schema.methods.isExpired = function() {
+    if (this.expiredAt == null) {
+      return false;
+    }
+    return this.expiredAt.getTime() < new Date().getTime();
+  };
+
+  const model = mongoose.model('ShareLink', schema);
+  return model;
+};

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

@@ -39,6 +39,7 @@ module.exports = (crowi) => {
 
   router.use('/page', require('./page')(crowi));
   router.use('/pages', require('./pages')(crowi));
+  router.use('/share-links', require('./share-links')(crowi));
 
   router.use('/bookmarks', require('./bookmarks')(crowi));
 

+ 71 - 0
src/server/routes/apiv3/security-setting.js

@@ -594,6 +594,77 @@ module.exports = (crowi) => {
     }
   });
 
+
+  /**
+   * @swagger
+   *
+   *    /_api/v3/security-setting/all-share-links:
+   *      get:
+   *        tags: [ShareLinkSettings, apiv3]
+   *        description: Get All ShareLinks at Share Link Setting
+   *        responses:
+   *          200:
+   *            description: all share links
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    securityParams:
+   *                      type: object
+   *                      description: suceed to get all share links
+   */
+  router.get('/all-share-links/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const ShareLink = crowi.model('ShareLink');
+    const page = parseInt(req.query.page) || 1;
+    const limit = 10;
+    const linkQuery = {};
+    try {
+      const paginateResult = await ShareLink.paginate(
+        linkQuery,
+        {
+          page,
+          limit,
+          populate: {
+            path: 'relatedPage',
+            select: 'path',
+          },
+        },
+      );
+      return res.apiv3({ paginateResult });
+    }
+    catch (err) {
+      const msg = 'Error occured in get share link';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'get-all-share-links-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /_api/v3/security-setting/all-share-links:
+   *      delete:
+   *        tags: [ShareLinkSettings, apiv3]
+   *        description: Delete All ShareLinks at Share Link Setting
+   *        responses:
+   *          200:
+   *            description: succeed to delete all share links
+   */
+
+  router.delete('/all-share-links/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const ShareLink = crowi.model('ShareLink');
+    try {
+      const removedAct = await ShareLink.remove({});
+      const removeTotal = await removedAct.n;
+      return res.apiv3({ removeTotal });
+    }
+    catch (err) {
+      const msg = 'Error occured in delete all share links';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'failed-to-delete-all-share-links'));
+    }
+  });
+
   /**
    * @swagger
    *

+ 212 - 0
src/server/routes/apiv3/share-links.js

@@ -0,0 +1,212 @@
+// TODO remove this setting after implemented all
+/* eslint-disable no-unused-vars */
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:share-links');
+
+const express = require('express');
+
+const router = express.Router();
+
+const { body } = require('express-validator/check');
+
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+const validator = {};
+
+const today = new Date();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: ShareLink
+ */
+
+module.exports = (crowi) => {
+  const loginRequired = require('../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const csrf = require('../../middlewares/csrf')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+  const ShareLink = crowi.model('ShareLink');
+
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /share-links/:
+   *      post:
+   *        tags: [ShareLink]
+   *        description: get share links
+   *        parameters:
+   *          - name: relatedPage
+   *            in: query
+   *            required: true
+   *            description: page id of share link
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to get share links
+   */
+  router.get('/', loginRequired, async(req, res) => {
+    const { relatedPage } = req.query;
+    try {
+      const shareLinksResult = await ShareLink.find({ relatedPage: { $in: relatedPage } }).populate({ path: 'relatedPage', select: 'path' });
+      return res.apiv3({ shareLinksResult });
+    }
+    catch (err) {
+      const msg = 'Error occurred in get share link';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'get-shareLink-failed'));
+    }
+  });
+
+  validator.shareLinkStatus = [
+    // validate the page id is null
+    body('relatedPage').not().isEmpty().withMessage('Page Id is null'),
+    // validate expireation date is not empty, is not before today and is date.
+    body('expiredAt').if(value => value != null).isAfter(today.toString()).withMessage('Your Selected date is past'),
+    // validate the length of description is max 100.
+    body('description').isLength({ min: 0, max: 100 }).withMessage('Max length is 100'),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /share-links/:
+   *      post:
+   *        tags: [ShareLink]
+   *        description: Create new share link
+   *        parameters:
+   *          - name: relatedPage
+   *            in: query
+   *            required: true
+   *            description: page id of share link
+   *            schema:
+   *              type: string
+   *          - name: expiredAt
+   *            in: query
+   *            description: expiration date of share link
+   *            schema:
+   *              type: string
+   *          - name: description
+   *            in: query
+   *            description: description of share link
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Succeeded to create one share link
+   */
+
+  router.post('/', loginRequired, csrf, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
+    const { relatedPage, expiredAt, description } = req.body;
+    const ShareLink = crowi.model('ShareLink');
+
+    try {
+      const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
+      return res.apiv3(postedShareLink);
+    }
+    catch (err) {
+      const msg = 'Error occured in post share link';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'post-shareLink-failed'));
+    }
+  });
+
+  /**
+  * @swagger
+  *
+  *    /share-links/:
+  *      delete:
+  *        tags: [ShareLinks]
+  *        summary: /share-links/
+  *        description: delete all share links related one page
+  *        parameters:
+  *          - name: relatedPage
+  *            in: query
+  *            required: true
+  *            description: page id of share link
+  *            schema:
+  *              type: string
+  *        responses:
+  *          200:
+  *            description: Succeeded to delete o all share links related one page
+  */
+  router.delete('/', loginRequired, csrf, async(req, res) => {
+    const { relatedPage } = req.query;
+
+    try {
+      const deletedShareLink = await ShareLink.remove({ relatedPage });
+      return res.apiv3(deletedShareLink);
+    }
+    catch (err) {
+      const msg = 'Error occured in delete share link';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
+    }
+  });
+
+  /**
+  * @swagger
+  *
+  *    /share-links/all:
+  *      delete:
+  *        tags: [ShareLinks]
+  *        description: delete all share links
+  *        responses:
+  *          200:
+  *            description: Succeeded to remove all share links
+  */
+  router.delete('/all', loginRequired, adminRequired, csrf, async(req, res) => {
+
+    try {
+      const deletedShareLink = await ShareLink.deleteMany({});
+      const { deletedCount } = deletedShareLink;
+      return res.apiv3({ deletedCount });
+    }
+    catch (err) {
+      const msg = 'Error occurred in delete all share link';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'delete-all-shareLink-failed'));
+    }
+  });
+
+  /**
+  * @swagger
+  *
+  *    /share-links/{id}:
+  *      delete:
+  *        tags: [ShareLinks]
+  *        description: delete one share link related one page
+  *        parameters:
+  *          - name: id
+  *            in: path
+  *            required: true
+  *            description: id of share link
+  *            schema:
+  *              type: string
+  *        responses:
+  *          200:
+  *            description: Succeeded to delete one share link
+  */
+  router.delete('/:id', loginRequired, csrf, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const deletedShareLink = await ShareLink.findOneAndRemove({ _id: id });
+      return res.apiv3({ deletedShareLink });
+    }
+    catch (err) {
+      const msg = 'Error occurred in delete share link';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
+    }
+
+  });
+
+
+  return router;
+};

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

@@ -10,6 +10,8 @@ module.exports = function(crowi, app) {
   const loginRequiredStrictly = require('../middlewares/login-required')(crowi);
   const loginRequired = require('../middlewares/login-required')(crowi, true);
   const adminRequired = require('../middlewares/admin-required')(crowi);
+  const certifySharedPage = require('../middlewares/certify-shared-page')(crowi);
+  const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
@@ -120,7 +122,7 @@ module.exports = function(crowi, app) {
 
   app.get('/:id([0-9a-z]{24})'       , loginRequired , page.redirector);
   app.get('/_r/:id([0-9a-z]{24})'    , loginRequired , page.redirector); // alias
-  app.get('/attachment/:id([0-9a-z]{24})'  , loginRequired, attachment.api.get);
+  app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
   app.get('/attachment/:pageId/:fileName', loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
   app.get('/download/:id([0-9a-z]{24})'    , loginRequired, attachment.api.download);
@@ -164,8 +166,8 @@ module.exports = function(crowi, app) {
   app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
   app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
-  app.get('/_api/revisions.get'       , accessTokenParser , loginRequired , revision.api.get);
-  app.get('/_api/revisions.ids'       , accessTokenParser , loginRequired , revision.api.ids);
+  app.get('/_api/revisions.get'       , certifySharedPage , accessTokenParser , loginRequired , revision.api.get);
+  app.get('/_api/revisions.ids'       , certifySharedPage , accessTokenParser , loginRequired , revision.api.ids);
   app.get('/_api/revisions.list'      , accessTokenParser , loginRequired , revision.api.list);
 
   app.get('/trash$'                   , loginRequired , page.trashPageShowWrapper);
@@ -178,6 +180,8 @@ module.exports = function(crowi, app) {
   app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.discard);
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
+  app.get('/share/:linkId', page.showSharedPage);
+
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
   app.get('/*'                     , loginRequired , page.showPage, page.notFound);
 

+ 53 - 0
src/server/routes/page.js

@@ -142,6 +142,7 @@ module.exports = function(crowi, app) {
   const PageTagRelation = crowi.model('PageTagRelation');
   const UpdatePost = crowi.model('UpdatePost');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
+  const ShareLink = crowi.model('ShareLink');
 
   const ApiResponse = require('../util/apiResponse');
   const getToday = require('../util/getToday');
@@ -317,6 +318,9 @@ module.exports = function(crowi, app) {
     addRendarVarsForPage(renderVars, portalPage);
     await addRenderVarsForSlack(renderVars, portalPage);
 
+    const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: portalPage._id });
+    renderVars.sharelinksNumber = sharelinksNumber;
+
     const limit = 50;
     const offset = parseInt(req.query.offset) || 0;
 
@@ -361,6 +365,9 @@ module.exports = function(crowi, app) {
     await addRenderVarsForSlack(renderVars, page);
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
 
+    const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: page._id });
+    renderVars.sharelinksNumber = sharelinksNumber;
+
     if (isUserPage(page.path)) {
       // change template
       view = `layout-${layoutName}/user_page`;
@@ -406,6 +413,52 @@ module.exports = function(crowi, app) {
     return showPageForGrowiBehavior(req, res, next);
   };
 
+  actions.showSharedPage = async function(req, res, next) {
+    const { linkId } = req.params;
+    const revisionId = req.query.revision;
+
+    const layoutName = configManager.getConfig('crowi', 'customize:layout');
+    const view = `layout-${layoutName}/shared_page`;
+
+    const shareLink = await ShareLink.findOne({ _id: linkId }).populate('relatedPage');
+
+    if (shareLink == null || shareLink.relatedPage == null) {
+      // page or sharelink are not found
+      return res.render(`layout-${layoutName}/not_found_shared_page`);
+    }
+
+    let page = shareLink.relatedPage;
+
+    // check if share link is expired
+    if (shareLink.isExpired()) {
+      // page is not found
+      return res.render(`layout-${layoutName}/expired_shared_page`);
+    }
+
+    const renderVars = {};
+
+    renderVars.sharelink = shareLink;
+
+    // presentation mode
+    if (req.query.presentation) {
+      page = await page.populateDataToMakePresentation(revisionId);
+
+      // populate
+      addRendarVarsForPage(renderVars, page);
+      return res.render('page_presentation', renderVars);
+    }
+
+    page.initLatestRevisionField(revisionId);
+
+    // populate
+    page = await page.populateDataToShowRevision();
+    addRendarVarsForPage(renderVars, page);
+    addRendarVarsForScope(renderVars, page);
+
+    await interceptorManager.process('beforeRenderPage', req, res, renderVars);
+    return res.render(view, renderVars);
+  };
+
   /**
    * switch action by behaviorType
    */

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

@@ -102,6 +102,7 @@ module.exports = function(crowi, app) {
   actions.api.get = async function(req, res) {
     const pageId = req.query.page_id;
     const revisionId = req.query.revision_id;
+    const { isSharedPage } = req;
 
     if (!pageId || !revisionId) {
       return res.json(ApiResponse.error('Parameter page_id and revision_id are required.'));
@@ -109,7 +110,7 @@ module.exports = function(crowi, app) {
 
     // check whether accessible
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
-    if (!isAccessible) {
+    if (!isSharedPage && !isAccessible) {
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
 

+ 13 - 0
src/server/views/layout-growi/expired_shared_page.html

@@ -0,0 +1,13 @@
+{% extends './shared_page.html' %}
+
+{% block content_header %}
+{% endblock %}
+
+{% block content_page %}
+  <div class="col-md-12">
+    <h2 class="text-muted">
+      <i class="icon-ban" aria-hidden="true"></i>
+      Page is expired
+    </h2>
+  </div>
+{% endblock %}

+ 13 - 0
src/server/views/layout-growi/not_found_shared_page.html

@@ -0,0 +1,13 @@
+{% extends './shared_page.html' %}
+
+{% block content_header %}
+{% endblock %}
+
+{% block content_page %}
+  <div class="col-md-12">
+    <h2 class="text-muted">
+      <i class="icon-info" aria-hidden="true"></i>
+      Page is not found
+    </h2>
+  </div>
+{% endblock %}

+ 51 - 0
src/server/views/layout-growi/shared_page.html

@@ -0,0 +1,51 @@
+{% extends 'base/layout.html' %}
+
+
+{% block content_header %}
+  <h1 class="p-3">{{ page.path | preventXss }}</h1>
+{% endblock %}
+
+
+{% block content_main_before %}
+{% endblock %}
+{% block search %}
+{% endblock %}
+{% block head_warn_alert_siteurl_undefined %}
+{% endblock %}
+
+{% block content_main %}
+  <div
+    class="row"
+    id="is-shared-page"
+    data-share-link-expired-at="{% if sharelink.expiredAt %}{{ sharelink.expiredAt|datetz('Y/m/d H:i:s')}}{% endif %}"
+    data-share-link-created-at="{{ sharelink.createdAt|datetz('Y/m/d H:i:s')}}"
+  >
+    {% block content_page %}
+      <div class="col grw-page-content-container">
+        <div id="share-link-alert"></div>
+
+        {% include '../widget/page_content.html' %}
+        {# force remove #revision-toc from #content_main of parent #}
+        <script>
+          $('#revision-toc').remove();
+        </script>
+
+      </div>
+
+      {# relocate #revision-toc #}
+      <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
+        <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
+          <div id="revision-toc-content" class="revision-toc-content"></div>
+        </div>
+      </div>
+    {% endblock %}
+
+  </div>
+{% endblock %}
+
+
+{% block body_end %}
+  <div id="presentation-layer" class="fullscreen-layer">
+    <div id="presentation-container"></div>
+  </div>
+{% endblock %}

+ 13 - 0
src/server/views/layout-kibela/expired_shared_page.html

@@ -0,0 +1,13 @@
+{% extends './shared_page.html' %}
+
+{% block content_header %}
+{% endblock %}
+
+{% block content_page %}
+  <div class="col-md-12">
+    <h2 class="text-muted">
+      <i class="icon-ban" aria-hidden="true"></i>
+      Page is expired
+    </h2>
+  </div>
+{% endblock %}

+ 13 - 0
src/server/views/layout-kibela/not_found_shared_page.html

@@ -0,0 +1,13 @@
+{% extends './shared_page.html' %}
+
+{% block content_header %}
+{% endblock %}
+
+{% block content_page %}
+  <div class="col-md-12">
+    <h2 class="text-muted">
+      <i class="icon-info" aria-hidden="true"></i>
+      Page is not found
+    </h2>
+  </div>
+{% endblock %}

+ 46 - 0
src/server/views/layout-kibela/shared_page.html

@@ -0,0 +1,46 @@
+{% extends 'base/layout.html' %}
+
+
+{% block content_header %}
+  <h1 class="p-3">{{ page.path | preventXss }}</h1>
+{% endblock %}
+
+
+{% block content_main_before %}
+{% endblock %}
+{% block search %}
+{% endblock %}
+{% block head_warn_alert_siteurl_undefined %}
+{% endblock %}
+
+{% block content_main %}
+  <div class="row" id="is-shared-page" data-share-link-expired-at="{% if sharelink.expiredAt %}{{ sharelink.expiredAt|datetz('Y/m/d H:i:s')}}{% endif %}" data-share-link-created-at="{{ sharelink.createdAt|datetz('Y/m/d H:i:s')}}">
+    {% block content_page %}
+      <div class="col-12 col-xl-9 col-lg-8 bg-white round-corner">
+        <div id="share-link-alert"></div>
+
+        {% include '../widget/page_content.html' %}
+        {# force remove #revision-toc from #content_main of parent #}
+        <script>
+          $('#revision-toc').remove();
+        </script>
+
+      </div>
+
+      {# relocate #revision-toc #}
+      <div class="col-xl-3 col-lg-4 d-none d-lg-block revision-toc-container">
+        <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
+          <div id="revision-toc-content" class="revision-toc-content"></div>
+        </div>
+      </div>
+    {% endblock %}
+
+  </div>
+{% endblock %}
+
+
+{% block body_end %}
+  <div id="presentation-layer" class="fullscreen-layer">
+    <div id="presentation-container"></div>
+  </div>
+{% endblock %}

+ 2 - 0
src/server/views/widget/page_content.html

@@ -23,6 +23,8 @@
   data-page-creator="{% if page %}{{ page.creator|json }}{% endif %}"
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
+  data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
+  data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   >
 {% else %}

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

@@ -63,6 +63,11 @@
     </a>
   </li>
 
+  <!-- Outside-share-link -->
+  {% if !isTrashPage() %}
+    <li id="page-share-management" class="nav-item dropdown d-edit-none"></li>
+  {% endif %}
+
   <!-- icon-options-vertical -->
   {% if !isTrashPage() %}
     <li id="page-management" class="nav-item dropdown d-edit-none"></li>

+ 1 - 1
src/server/views/widget/page_tabs_kibela.html

@@ -61,7 +61,7 @@
   </li>
 
   {% if !isTrashPage() %}
-    <li id="page-management" class="nav-item dropdown"></li>
+    <li id="page-management" class="nav-item dropdown d-edit-none"></li>
   {% endif %}
 
 </ul>

+ 16 - 0
src/test/middlewares/login-required.test.js

@@ -52,6 +52,22 @@ describe('loginRequired', () => {
       expect(result).toBe('redirect');
     });
 
+    test('pass anyone into sharedPage when aclService.isGuestAllowedToRead() returns false', () => {
+
+      req.isSharedPage = true;
+
+      // prepare spy for AclService.isGuestAllowedToRead
+      const isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead')
+        .mockImplementation(() => false);
+
+      const result = loginRequired(req, res, next);
+
+      expect(isGuestAllowedToReadSpy).toHaveBeenCalled();
+      expect(next).toHaveBeenCalled();
+      expect(res.redirect).not.toHaveBeenCalled();
+      expect(result).toBe('next');
+    });
+
   });
 
 

+ 26 - 5
src/test/models/page.test.js

@@ -214,31 +214,52 @@ describe('Page', () => {
   });
 
   describe('.isAccessiblePageByViewer', () => {
-    describe('with a granted user', () => {
-      test('should return true', async() => {
+    describe('with a granted page', () => {
+      test('should return true with granted user', async() => {
         const user = await User.findOne({ email: 'anonymous0@example.com' });
         const page = await Page.findOne({ path: '/user/anonymous0/memo' });
 
+        const bool = await Page.isAccessiblePageByViewer(page.id, user);
+        expect(bool).toEqual(true);
+      });
+      test('should return false without user', async() => {
+        const user = null;
+        const page = await Page.findOne({ path: '/user/anonymous0/memo' });
+
         const bool = await Page.isAccessiblePageByViewer(page.id, user);
         expect(bool).toEqual(true);
       });
     });
 
     describe('with a public page', () => {
-      test('should return true', async() => {
+      test('should return true with user', async() => {
         const user = await User.findOne({ email: 'anonymous1@example.com' });
         const page = await Page.findOne({ path: '/grant/public' });
 
+        const bool = await Page.isAccessiblePageByViewer(page.id, user);
+        expect(bool).toEqual(true);
+      });
+      test('should return true with out', async() => {
+        const user = null;
+        const page = await Page.findOne({ path: '/grant/public' });
+
         const bool = await Page.isAccessiblePageByViewer(page.id, user);
         expect(bool).toEqual(true);
       });
     });
 
-    describe('with a restricted page and an user who has no grant', () => {
-      test('should return false', async() => {
+    describe('with a restricted page', () => {
+      test('should return false with user who has no grant', async() => {
         const user = await User.findOne({ email: 'anonymous1@example.com' });
         const page = await Page.findOne({ path: '/grant/owner' });
 
+        const bool = await Page.isAccessiblePageByViewer(page.id, user);
+        expect(bool).toEqual(false);
+      });
+      test('should return false without user', async() => {
+        const user = null;
+        const page = await Page.findOne({ path: '/grant/owner' });
+
         const bool = await Page.isAccessiblePageByViewer(page.id, user);
         expect(bool).toEqual(false);
       });

+ 114 - 0
src/test/models/shareLink.test.js

@@ -0,0 +1,114 @@
+const { getInstance } = require('../setup-crowi');
+
+describe('ShareLink', () => {
+  // eslint-disable-next-line no-unused-vars
+  let crowi;
+  let ShareLink;
+  let Page;
+
+  beforeAll(async(done) => {
+    crowi = await getInstance();
+    ShareLink = crowi.model('ShareLink');
+    Page = require('@server/routes/page')(crowi);
+
+
+    done();
+  });
+
+  describe('accessShareLink', () => {
+    const req = {
+      path: '/share/:id',
+      params: {
+        linkId: 'someLinkId',
+      },
+      query: {
+        revision: 'someRevision',
+      },
+    };
+
+    const res = {
+      render: jest.fn((page, renderVars = null) => { return { page, renderVars } }),
+    };
+
+    const findOneResult = {
+      populate: null,
+    };
+
+    const relatedPage = {
+      path: '/somePath',
+      populateDataToShowRevision: () => {
+        return {
+          revision: {},
+          creator: {},
+        };
+      },
+      initLatestRevisionField: (revisionId) => {
+        return revisionId;
+      },
+    };
+
+    test('share link is not found', async() => {
+
+      findOneResult.populate = jest.fn(() => { return null });
+
+      jest.spyOn(ShareLink, 'findOne').mockImplementation(() => {
+        return findOneResult;
+      });
+
+      const response = await Page.showSharedPage(req, res);
+
+      expect(findOneResult.populate).toHaveBeenCalled();
+      expect(res.render).toHaveBeenCalled();
+      expect(response.page).toEqual('layout-growi/not_found_shared_page');
+      expect(response.renderVars).toEqual(null);
+    });
+
+    test('share link is found, but it does not have Page', async() => {
+
+      findOneResult.populate = jest.fn(() => { return { _id: 'somePageId' } });
+
+      jest.spyOn(ShareLink, 'findOne').mockImplementation(() => {
+        return findOneResult;
+      });
+      const response = await Page.showSharedPage(req, res);
+
+      expect(findOneResult.populate).toHaveBeenCalled();
+      expect(res.render).toHaveBeenCalled();
+      expect(response.page).toEqual('layout-growi/not_found_shared_page');
+      expect(response.renderVars).toEqual(null);
+    });
+
+
+    test('share link is found, but it is expired', async() => {
+
+      findOneResult.populate = jest.fn(() => { return { _id: 'somePageId', relatedPage, isExpired: () => { return true } } });
+
+      jest.spyOn(ShareLink, 'findOne').mockImplementation(() => {
+        return findOneResult;
+      });
+
+      const response = await Page.showSharedPage(req, res);
+
+      expect(findOneResult.populate).toHaveBeenCalled();
+      expect(res.render).toHaveBeenCalled();
+      expect(response.page).toEqual('layout-growi/expired_shared_page');
+      expect(response.renderVars).toEqual(null);
+    });
+
+    test('share link is found, and it has the page you can see', async() => {
+
+      findOneResult.populate = jest.fn(() => { return { _id: 'somePageId', relatedPage, isExpired: () => { return false } } });
+
+      jest.spyOn(ShareLink, 'findOne').mockImplementation(() => {
+        return findOneResult;
+      });
+      const response = await Page.showSharedPage(req, res);
+
+      expect(findOneResult.populate).toHaveBeenCalled();
+      expect(res.render).toHaveBeenCalled();
+      expect(response.page).toEqual('layout-growi/shared_page');
+      expect(response.renderVars).not.toEqual(null);
+    });
+  });
+
+});