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

Merge branch 'imprv/page-v5-test-code-delete-completely' of https://github.com/weseek/growi into imprv/page-v5-test-code-delete-completely

yohei0125 4 лет назад
Родитель
Сommit
01b2f9c1e0
30 измененных файлов с 332 добавлено и 90 удалено
  1. 7 0
      .devcontainer/docker-compose.yml
  2. 7 1
      CHANGELOG.md
  3. 1 0
      packages/app/.env.development
  4. 5 0
      packages/app/docker/README.md
  5. 1 1
      packages/app/package.json
  6. 1 1
      packages/app/src/components/LoginForm.jsx
  7. 7 3
      packages/app/src/components/Navbar/SubNavButtons.tsx
  8. 8 4
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  9. 15 16
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  10. 3 2
      packages/app/src/components/User/SeenUserInfo.tsx
  11. 1 16
      packages/app/src/server/middlewares/http-error-handler.js
  12. 16 0
      packages/app/src/server/models/obsolete-page.js
  13. 9 2
      packages/app/src/server/routes/apiv3/forgot-password.js
  14. 32 2
      packages/app/src/server/routes/forgot-password.ts
  15. 5 1
      packages/app/src/server/routes/index.js
  16. 148 0
      packages/app/src/server/routes/ogp.ts
  17. 6 0
      packages/app/src/server/service/config-loader.ts
  18. 2 5
      packages/app/src/server/service/page.ts
  19. 2 2
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  20. 1 3
      packages/app/src/server/service/search-delegator/private-legacy-pages.ts
  21. 14 0
      packages/app/src/server/util/stream.ts
  22. 6 0
      packages/app/src/server/views/forgot-password/error.html
  23. 10 0
      packages/app/src/server/views/layout-growi/page.html
  24. 0 10
      packages/app/test/integration/service/page-grant.test.js
  25. 1 6
      packages/app/test/integration/service/v5.migration.test.js
  26. 0 1
      packages/app/test/integration/service/v5.page.test.ts
  27. 1 1
      packages/plugin-attachment-refs/package.json
  28. 1 1
      packages/slack/package.json
  29. 1 1
      packages/slackbot-proxy/package.json
  30. 21 11
      yarn.lock

+ 7 - 0
.devcontainer/docker-compose.yml

@@ -34,6 +34,13 @@ services:
     volumes:
       - /data/db
 
+  ogp:
+    image: ghcr.io/weseek/growi-unique-ogp:latest
+    ports:
+      - 8088:8088
+    restart: unless-stopped
+    tty: true
+
   # This container requires '../../growi-docker-compose' repository
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   elasticsearch:

+ 7 - 1
CHANGELOG.md

@@ -1,9 +1,15 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.13...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.14...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.14](https://github.com/weseek/growi/compare/v4.5.13...v4.5.14) - 2022-02-10
+
+### 💎 Features
+
+- feat: OGP in public wiki (#5304) @yuto-oweseek
+
 ## [v4.5.13](https://github.com/weseek/growi/compare/v4.5.12...v4.5.13) - 2022-02-08
 
 ### 🐛 Bug Fixes

+ 1 - 0
packages/app/.env.development

@@ -18,6 +18,7 @@ ELASTICSEARCH_REQUEST_TIMEOUT=15000
 #USE_ELASTICSEARCH_V6=true
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
+OGP_URI="http://ogp:8088"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"
 # S2SMSG_PUBSUB_SERVER_TYPE=nchan
 # PUBLISH_OPEN_API=true

+ 5 - 0
packages/app/docker/README.md

@@ -10,10 +10,15 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
+<<<<<<< HEAD
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`4.5.13`, `4.5`, `4`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.13/docker/Dockerfile)
 * [`4.5.13-nocdn`, `4.5-nocdn`, `4-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.13/docker/Dockerfile)
+=======
+* [`4.5.14`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
+* [`4.5.14-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
+>>>>>>> origin/release/current
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 1 - 1
packages/app/package.json

@@ -107,7 +107,7 @@
     "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
     "helmet": "^4.6.0",
-    "http-errors": "~1.8.0",
+    "http-errors": "^2.0.0",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",
     "i18next-node-fs-backend": "^2.1.3",

+ 1 - 1
packages/app/src/components/LoginForm.jsx

@@ -297,7 +297,7 @@ class LoginForm extends React.Component {
               <div className="front">
                 {isLocalOrLdapStrategiesEnabled && this.renderLocalOrLdapLoginForm()}
                 {isSomeExternalAuthEnabled && this.renderExternalAuthLoginForm()}
-                {isPasswordResetEnabled && (
+                {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
                   <div className="text-right mb-2">
                     <a href="/forgot-password" className="d-block link-switch">
                       <i className="icon-key"></i> {t('forgot_password.forgot_password')}

+ 7 - 3
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -29,7 +29,7 @@ type CommonProps = {
   onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean) => void,
 }
 
-type SubNavButtonsSubstanceProps= CommonProps & {
+type SubNavButtonsSubstanceProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
   revisionId: string,
@@ -132,7 +132,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
 
   const {
-    sumOfLikers, isLiked, bookmarkCount, isBookmarked,
+    sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
   } = pageInfo;
 
   const forceHideMenuItemsWithBookmark = forceHideMenuItems ?? [];
@@ -161,7 +161,11 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
         onBookMarkClicked={bookmarkClickHandler}
       />
       { !isCompactMode && (
-        <SeenUserInfo seenUsers={seenUsers} disabled={disableSeenUserInfoPopover} />
+        <SeenUserInfo
+          seenUsers={seenUsers}
+          sumOfSeenUsers={sumOfSeenUsers}
+          disabled={disableSeenUserInfoPopover}
+        />
       ) }
       { showPageControlDropdown && (
         <PageItemControl

+ 8 - 4
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -32,7 +32,8 @@ interface ItemProps {
   isEnabledAttachTitleHeader?: boolean
   onClickDuplicateMenuItem?(pageId: string, path: string): void
   onClickRenameMenuItem?(pageId: string, revisionId: string, path: string): void
-  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean): void
+  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean, callback?: VoidFunction): void
+  onSelfDeleted?: VoidFunction
 }
 
 // Utility to mark target
@@ -74,7 +75,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const {
     itemNode, targetPathOrId, isOpen: _isOpen = false, isEnabledAttachTitleHeader,
-    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, isEnableActions,
+    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, isEnableActions, onSelfDeleted,
   } = props;
 
   const { page, children } = itemNode;
@@ -262,8 +263,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     };
     const isAbleToDeleteCompletely = pageInfo?.isAbleToDeleteCompletely ?? false;
 
-    onClickDeleteMenuItem(pageToDelete, isAbleToDeleteCompletely);
-  }, [onClickDeleteMenuItem, page, pageInfo?.isAbleToDeleteCompletely]);
+    onClickDeleteMenuItem(pageToDelete, isAbleToDeleteCompletely, async() => {
+      if (onSelfDeleted != null) await onSelfDeleted();
+    });
+  }, [onClickDeleteMenuItem, page, pageInfo?.isAbleToDeleteCompletely, onSelfDeleted]);
 
   const onPressEnterForCreateHandler = async(inputText: string) => {
     setNewPageInputShown(false);
@@ -425,6 +428,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               onClickDuplicateMenuItem={onClickDuplicateMenuItem}
               onClickRenameMenuItem={onClickRenameMenuItem}
               onClickDeleteMenuItem={onClickDeleteMenuItem}
+              onSelfDeleted={async() => { await mutateChildren() }}
             />
           </div>
         ))

+ 15 - 16
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -67,7 +67,7 @@ const renderByInitialNode = (
     isEnabledAttachTitleHeader?: boolean,
     onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
     onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
-    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean) => void,
+    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean, onItemDeleted: VoidFunction) => void,
 ): JSX.Element => {
 
   return (
@@ -99,7 +99,6 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { t } = useTranslation();
 
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
-  const { mutate: mutateChildren } = useSWRxPageChildren(targetPathOrId);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { open: openDuplicateModal } = usePageDuplicateModal();
@@ -123,24 +122,24 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     openRenameModal(pageId, revisionId, path);
   };
 
-  const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
-    if (typeof pathOrPathsToDelete !== 'string') {
-      return;
-    }
+  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal, isAbleToDeleteCompletely, onItemDeleted: VoidFunction) => {
+    const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+      if (typeof pathOrPathsToDelete !== 'string') {
+        return;
+      }
 
-    mutateChildren();
+      onItemDeleted();
 
-    const path = pathOrPathsToDelete;
+      const path = pathOrPathsToDelete;
 
-    if (isCompletely) {
-      toastSuccess(t('deleted_pages_completely', { path }));
-    }
-    else {
-      toastSuccess(t('deleted_pages', { path }));
-    }
-  };
+      if (isCompletely) {
+        toastSuccess(t('deleted_pages_completely', { path }));
+      }
+      else {
+        toastSuccess(t('deleted_pages', { path }));
+      }
+    };
 
-  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal, isAbleToDeleteCompletely) => {
     openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
   };
 

+ 3 - 2
packages/app/src/components/User/SeenUserInfo.tsx

@@ -9,13 +9,14 @@ import UserPictureList from './UserPictureList';
 
 interface Props {
   seenUsers: IUser[],
+  sumOfSeenUsers?: number,
   disabled?: boolean,
 }
 
 const SeenUserInfo: FC<Props> = (props: Props) => {
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
-  const { seenUsers, disabled } = props;
+  const { seenUsers, sumOfSeenUsers, disabled } = props;
 
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
 
@@ -25,7 +26,7 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
         <span className="mr-1 footstamp-icon">
           <FootstampIcon />
         </span>
-        <span className="seen-user-count">{seenUsers.length}</span>
+        <span className="seen-user-count">{sumOfSeenUsers || seenUsers.length}</span>
       </button>
       <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
         <PopoverBody className="user-list-popover">

+ 1 - 16
packages/app/src/server/middlewares/http-error-handler.js

@@ -1,23 +1,8 @@
-import { HttpError } from 'http-errors';
+import { isHttpError } from 'http-errors';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middleware:htto-error-handler');
 
-const isHttpError = (val) => {
-  if (!val || typeof val !== 'object') {
-    return false;
-  }
-
-  if (val instanceof HttpError) {
-    return true;
-  }
-
-  return val instanceof Error
-    && typeof val.expose === 'boolean'
-    && typeof val.statusCode === 'number'
-    && val.status === val.statusCode;
-};
-
 module.exports = async(err, req, res, next) => {
   // handle if the err is a HttpError instance
   if (isHttpError(err)) {

+ 16 - 0
packages/app/src/server/models/obsolete-page.js

@@ -222,6 +222,22 @@ export class PageQueryBuilder {
     return this;
   }
 
+  async addConditionAsMigratablePages(user) {
+    this.query = this.query
+      .and({
+        $or: [
+          { grant: { $ne: GRANT_RESTRICTED } },
+          { grant: { $ne: GRANT_SPECIFIED } },
+        ],
+      });
+    this.addConditionAsNotMigrated();
+    this.addConditionAsNonRootPage();
+    this.addConditionToExcludeTrashed();
+    await this.addConditionForParentNormalization(user);
+
+    return this;
+  }
+
   addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
     const grantConditions = [
       { grant: null },

+ 9 - 2
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -5,6 +5,9 @@ import ErrorV3 from '~/server/models/vo/error-apiv3';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import loggerFactory from '~/utils/logger';
 
+import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
+import httpErrorHandler from '../../middlewares/http-error-handler';
+
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -40,6 +43,8 @@ module.exports = (crowi) => {
       'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
   });
 
+  const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
+
   async function sendPasswordResetEmail(txtFileName, i18n, email, url) {
     return mailService.send({
       to: email,
@@ -53,7 +58,7 @@ module.exports = (crowi) => {
     });
   }
 
-  router.post('/', async(req, res) => {
+  router.post('/', checkPassportStrategyMiddleware, async(req, res) => {
     const { email } = req.body;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
     const i18n = req.language || grobalLang;
@@ -81,7 +86,8 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/', apiLimiter, injectResetOrderByTokenMiddleware, csrf, validator.password, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.put('/', apiLimiter, checkPassportStrategyMiddleware, injectResetOrderByTokenMiddleware, csrf, validator.password, apiV3FormValidator, async(req, res) => {
     const { passwordResetOrder } = req;
     const { email } = passwordResetOrder;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
@@ -109,6 +115,7 @@ module.exports = (crowi) => {
   });
 
   // middleware to handle error
+  router.use(httpErrorHandler);
   router.use((error, req, res, next) => {
     if (error != null) {
       return res.apiv3Err(new ErrorV3(error.message, error.code));

+ 32 - 2
packages/app/src/server/routes/forgot-password.ts

@@ -1,8 +1,38 @@
 import {
   NextFunction, Request, RequestHandler, Response,
 } from 'express';
+
+import createError from 'http-errors';
+
+import loggerFactory from '~/utils/logger';
+
 import { ReqWithPasswordResetOrder } from '../middlewares/inject-reset-order-by-token-middleware';
 
+const logger = loggerFactory('growi:routes:forgot-password');
+
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
+export const checkForgotPasswordEnabledMiddlewareFactory = (crowi: any, forApi = false) => {
+
+  return (req: Request, res: Response, next: NextFunction): void => {
+    const isPasswordResetEnabled = crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled') as boolean | null;
+    const isLocalStrategySetup = crowi.passportService.isLocalStrategySetup as boolean ?? false;
+
+    const isEnabled = isLocalStrategySetup && isPasswordResetEnabled;
+
+    if (!isEnabled) {
+      const message = 'Forgot-password function is unavailable because neither LocalStrategy and LdapStrategy is not setup.';
+      logger.error(message);
+
+      const statusCode = forApi ? 405 : 404;
+      return next(createError(statusCode, message, { code: 'password-reset-is-unavailable' }));
+    }
+
+    next();
+  };
+
+};
+
 export const forgotPassword = (req: Request, res: Response): void => {
   return res.render('forgot-password');
 };
@@ -13,9 +43,9 @@ export const resetPassword = (req: ReqWithPasswordResetOrder, res: Response): vo
 };
 
 // middleware to handle error
-export const handleHttpErrosMiddleware = (error: Error & { code: string }, req: Request, res: Response, next: NextFunction): Promise<RequestHandler> | void => {
+export const handleErrosMiddleware = (error: Error & { code: string }, req: Request, res: Response, next: NextFunction): Promise<RequestHandler> | void => {
   if (error != null) {
     return res.render('forgot-password/error', { key: error.code });
   }
-  next();
+  next(error);
 };

+ 5 - 1
packages/app/src/server/routes/index.js

@@ -50,6 +50,7 @@ module.exports = function(crowi, app) {
   const tag = require('./tag')(crowi, app);
   const search = require('./search')(crowi, app);
   const hackmd = require('./hackmd')(crowi, app);
+  const ogp = require('./ogp')(crowi);
 
   const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
@@ -196,9 +197,10 @@ module.exports = function(crowi, app) {
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
   app.use('/forgot-password', express.Router()
+    .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
     .get('/', forgotPassword.forgotPassword)
     .get('/:token', apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
-    .use(forgotPassword.handleHttpErrosMiddleware));
+    .use(forgotPassword.handleErrosMiddleware));
 
   app.use('/_private-legacy-pages', express.Router()
     .get('/', privateLegacyPages.renderPrivateLegacyPages));
@@ -209,6 +211,8 @@ module.exports = function(crowi, app) {
 
   app.get('/share/:linkId', page.showSharedPage);
 
+  app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));
+
   app.get('/:id([0-9a-z]{24})'       , loginRequired , injectUserUISettings, page.showPage);
 
   app.get('/*/$'                   , loginRequired , injectUserUISettings, page.redirectorWithEndOfSlash);

+ 148 - 0
packages/app/src/server/routes/ogp.ts

@@ -0,0 +1,148 @@
+import {
+  Request, Response, NextFunction,
+} from 'express';
+import { param, validationResult, ValidationError } from 'express-validator';
+
+import path from 'path';
+import * as fs from 'fs';
+
+import { DevidedPagePath } from '@growi/core';
+import axios from '~/utils/axios';
+import loggerFactory from '~/utils/logger';
+import { projectRoot } from '~/utils/project-dir-utils';
+import { convertStreamToBuffer } from '../util/stream';
+
+const logger = loggerFactory('growi:routes:ogp');
+
+const DEFAULT_USER_IMAGE_URL = '/images/icons/user.svg';
+const DEFAULT_USER_IMAGE_PATH = `public${DEFAULT_USER_IMAGE_URL}`;
+
+let bufferedDefaultUserImageCache: Buffer = Buffer.from('');
+fs.readFile(path.join(projectRoot, DEFAULT_USER_IMAGE_PATH), (err, buffer) => {
+  if (err) throw err;
+  bufferedDefaultUserImageCache = buffer;
+});
+
+
+module.exports = function(crowi) {
+
+  const isUserImageAttachment = (userImageUrlCached: string): boolean => {
+    return /^\/attachment\/.+/.test(userImageUrlCached);
+  };
+
+  const getBufferedUserImage = async(userImageUrlCached: string): Promise<Buffer> => {
+
+    let bufferedUserImage: Buffer;
+
+    if (isUserImageAttachment(userImageUrlCached)) {
+      const { fileUploadService } = crowi;
+      const Attachment = crowi.model('Attachment');
+      const attachment = await Attachment.findById(userImageUrlCached);
+      const fileStream = await fileUploadService.findDeliveryFile(attachment);
+      bufferedUserImage = await convertStreamToBuffer(fileStream);
+      return bufferedUserImage;
+    }
+
+    return (await axios.get(
+      userImageUrlCached, {
+        responseType: 'arraybuffer',
+      },
+    )).data;
+
+  };
+
+  const renderOgp = async(req: Request, res: Response) => {
+
+    const { configManager } = crowi;
+    const ogpUri = configManager.getConfig('crowi', 'app:ogpUri');
+    const page = req.body.page;
+
+    let user;
+    let pageTitle: string;
+    let bufferedUserImage: Buffer;
+
+    try {
+      const User = crowi.model('User');
+      user = await User.findById(page.creator._id.toString());
+
+      bufferedUserImage = user.imageUrlCached === DEFAULT_USER_IMAGE_URL ? bufferedDefaultUserImageCache : (await getBufferedUserImage(user.imageUrlCached));
+      // todo: consider page title
+      pageTitle = (new DevidedPagePath(page.path)).latter;
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(500).send(`error: ${err}`);
+    }
+
+    let result;
+    try {
+      result = await axios.post(
+        ogpUri, {
+          data: {
+            title: pageTitle,
+            userName: user.username,
+            userImage: bufferedUserImage,
+          },
+        }, {
+          responseType: 'stream',
+        },
+      );
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(500).send(`error: ${err}`);
+    }
+
+    res.writeHead(200, {
+      'Content-Type': 'image/jpeg',
+    });
+    result.data.pipe(res);
+
+  };
+
+  const pageIdRequired = param('pageId').not().isEmpty().withMessage('page id is not included in the parameter');
+
+  const ogpValidator = async(req:Request, res:Response, next:NextFunction) => {
+    const { aclService, fileUploadService, configManager } = crowi;
+
+    const ogpUri = configManager.getConfig('crowi', 'app:ogpUri');
+
+    if (ogpUri == null) return res.status(400).send('OGP URI for GROWI has not been setup');
+    if (!fileUploadService.getIsUploadable()) return res.status(501).send('This GROWI can not upload file');
+    if (!aclService.isGuestAllowedToRead()) return res.status(501).send('This GROWI is not public');
+
+    const errors = validationResult(req);
+
+    if (errors.isEmpty()) {
+
+      try {
+        const Page = crowi.model('Page');
+        const page = await Page.findByIdAndViewer(req.params.pageId);
+
+        if (page == null || page.status !== Page.STATUS_PUBLISHED || (page.grant !== Page.GRANT_PUBLIC && page.grant !== Page.GRANT_RESTRICTED)) {
+          return res.status(400).send('the page does not exist');
+        }
+
+        req.body.page = page;
+      }
+      catch (error) {
+        logger.error(error);
+        return res.status(500).send(`error: ${error}`);
+      }
+
+      return next();
+    }
+
+    // errors.array length is one bacause pageIdRequired is used
+    const pageIdRequiredError: ValidationError = errors.array()[0];
+
+    return res.status(400).send(pageIdRequiredError.msg);
+  };
+
+  return {
+    renderOgp,
+    pageIdRequired,
+    ogpValidator,
+  };
+
+};

+ 6 - 0
packages/app/src/server/service/config-loader.ts

@@ -604,6 +604,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: 'ptog',
   },
+  OGP_URI: {
+    ns:      'crowi',
+    key:     'app:ogpUri',
+    type:    ValueType.STRING,
+    default: null,
+  },
 };
 
 

+ 2 - 5
packages/app/src/server/service/page.ts

@@ -314,7 +314,7 @@ class PageService {
   async renamePage(page, newPagePath, user, options) {
     const Page = this.crowi.model('Page');
 
-    const isExist = await Page.count({ path: newPagePath }) > 0;
+    const isExist = await Page.exists({ path: newPagePath });
     if (isExist) {
       // if page found, cannot rename to that path
       throw new Error('the path already exists');
@@ -2257,10 +2257,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
 
     const builder = new PageQueryBuilder(Page.count(), false);
-    builder.addConditionAsNotMigrated();
-    builder.addConditionAsNonRootPage();
-    builder.addConditionToExcludeTrashed();
-    await builder.addConditionForParentNormalization(user);
+    await builder.addConditionAsMigratablePages(user);
 
     const nMigratablePages = await builder.query.exec();
 

+ 2 - 2
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -386,7 +386,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
 
     const bookmarkCount = page.bookmarkCount || 0;
-    const seenUsersCount = page.seenUsers.length || 0;
+    const seenUsersCount = page.seenUsers?.length || 0;
     let document = {
       path: page.path,
       body: page.revision.body,
@@ -396,7 +396,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       comment_count: page.commentCount,
       bookmark_count: bookmarkCount,
       seenUsers_count: seenUsersCount,
-      like_count: page.liker.length || 0,
+      like_count: page.liker?.length || 0,
       created_at: page.createdAt,
       updated_at: page.updatedAt,
       tag_names: page.tagNames,

+ 1 - 3
packages/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -33,11 +33,9 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
     const { PageQueryBuilder } = Page;
 
     const queryBuilder = new PageQueryBuilder(Page.find());
+    await queryBuilder.addConditionAsMigratablePages(user);
 
     const _pages: PageDocument[] = await queryBuilder
-      .addConditionAsNonRootPage()
-      .addConditionAsNotMigrated()
-      .addConditionToFilteringByViewer(user, userGroups)
       .addConditionToPagenate(offset, limit)
       .query
       .populate('lastUpdateUser')

+ 14 - 0
packages/app/src/server/util/stream.ts

@@ -0,0 +1,14 @@
+export const convertStreamToBuffer = (stream: any): Promise<Buffer> => {
+
+  return new Promise((resolve, reject) => {
+
+    const buffer: Uint8Array[] = [];
+
+    stream.on('data', (chunk: Uint8Array) => {
+      buffer.push(chunk);
+    });
+    stream.on('end', () => resolve(Buffer.concat(buffer)));
+    stream.on('error', err => reject(err));
+
+  });
+};

+ 6 - 0
packages/app/src/server/views/forgot-password/error.html

@@ -33,6 +33,11 @@
             <div class="text-center">
               <h1><i class="icon-lock-open large"></i></h1>
               <h2 class="text-center">{{ t('forgot_password.reset_password') }}</h2>
+
+                {% if key === 'password-reset-is-unavailable' %}
+                <h3 class="text-muted">This feature is unavailable.</h3>
+                {% endif %}
+
                 {% if key === 'password-reset-order-is-not-appropriate' %}
                 <div>
                   <div class="alert alert-warning mb-3">
@@ -43,6 +48,7 @@
                   </a>
                 </div>
                 {% endif %}
+
             </div>
           </div>
         </div>

+ 10 - 0
packages/app/src/server/views/layout-growi/page.html

@@ -1,5 +1,15 @@
 {% extends 'base/layout.html' %}
 
+{% block html_additional_headers %}
+  {% parent %}
+
+  <!-- OGP -->
+  <meta property="og:site_name" content="{{ appService.getAppTitle() | preventXss }}" />
+  <meta property="og:title" content="{{ page.path | preventXss }}" />
+  <meta property="og:url" content="{{ appService.getSiteUrl() | preventXss }}/{{ page.id }}" />
+  <meta property="og:type" content="article" />
+  <meta property="og:image" content="{{ appService.getSiteUrl() | preventXss }}/ogp/{{ page.id }}" />
+{% endblock %}
 
 {% block content_main_before %}
 {% endblock %}

+ 0 - 10
packages/app/test/integration/service/page-grant.test.js

@@ -110,16 +110,6 @@ describe('PageGrantService', () => {
 
     // Root page (Depth: 0)
     rootPage = await Page.findOne({ path: '/' });
-    if (rootPage == null) {
-      const pages = await Page.insertMany([
-        {
-          path: '/',
-          grant: Page.GRANT_PUBLIC,
-        },
-      ]);
-      rootPage = pages[0];
-    }
-
 
     // Empty pages (Depth: 1)
     await Page.insertMany([

+ 1 - 6
packages/app/test/integration/service/v5.migration.test.js

@@ -28,7 +28,7 @@ describe('V5 page migration', () => {
       jest.restoreAllMocks();
 
       // initialize pages for test
-      let pages = await Page.insertMany([
+      const pages = await Page.insertMany([
         {
           path: '/private1',
           grant: Page.GRANT_OWNER,
@@ -59,11 +59,6 @@ describe('V5 page migration', () => {
         },
       ]);
 
-      if (!await Page.exists({ path: '/' })) {
-        const additionalPages = await Page.insertMany([{ path: '/', grant: Page.GRANT_PUBLIC }]);
-        pages = [...additionalPages, ...pages];
-      }
-
       const pageIds = pages.map(page => page._id);
       // migrate
       await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, testUser1);

+ 0 - 1
packages/app/test/integration/service/v5.page.test.ts

@@ -249,7 +249,6 @@ describe('PageService page operations with only public pages', () => {
         grant: Page.GRANT_PUBLIC,
         creator: dummyUser1,
         lastUpdateUser: dummyUser1._id,
-        parent: rootPage._id,
         status: Page.STATUS_DELETED,
       },
       {

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -24,7 +24,7 @@
   "dependencies": {
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
-    "http-errors": "^1.8.0",
+    "http-errors": "^2.0.0",
     "react-images": "~1.0.0",
     "react-motion": "^0.5.2",
     "universal-bunyan": "^0.9.2"

+ 1 - 1
packages/slack/package.json

@@ -18,7 +18,7 @@
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "extensible-custom-error": "^0.0.7",
-    "http-errors": "^1.8.0",
+    "http-errors": "^2.0.0",
     "qs": "^6.10.2",
     "universal-bunyan": "^0.9.2",
     "url-join": "^4.0.0"

+ 1 - 1
packages/slackbot-proxy/package.json

@@ -42,7 +42,7 @@
     "express-bunyan-logger": "^1.3.3",
     "extensible-custom-error": "^0.0.7",
     "helmet": "^4.6.0",
-    "http-errors": "^1.8.0",
+    "http-errors": "^2.0.0",
     "method-override": "^3.0.0",
     "mysql2": "^2.2.5",
     "read-pkg-up": "^7.0.1",

+ 21 - 11
yarn.lock

@@ -7162,14 +7162,14 @@ depd@1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359"
 
+depd@2.0.0, depd@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+
 depd@^1.1.2, depd@~1.1.1, depd@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
 
-depd@~2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
-
 deprecation@^2.0.0, deprecation@^2.3.1:
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
@@ -10243,16 +10243,16 @@ http-errors@1.7.3, http-errors@~1.7.2:
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
-http-errors@^1.8.0, http-errors@~1.8.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.0.tgz#75d1bbe497e1044f51e4ee9e704a62f28d336507"
-  integrity sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==
+http-errors@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
+  integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
   dependencies:
-    depd "~1.1.2"
+    depd "2.0.0"
     inherits "2.0.4"
     setprototypeof "1.2.0"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
+    statuses "2.0.1"
+    toidentifier "1.0.1"
 
 http-proxy-agent@^4.0.0, http-proxy-agent@^4.0.1:
   version "4.0.1"
@@ -18926,6 +18926,11 @@ static-extend@^0.1.1:
     define-property "^0.2.5"
     object-copy "^0.1.0"
 
+statuses@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
+  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
 "statuses@>= 1.3.1 < 2", "statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
@@ -20225,6 +20230,11 @@ toidentifier@1.0.0:
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
   integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
 
+toidentifier@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
 tough-cookie@^2.3.3, tough-cookie@~2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"